diff --git a/.gitignore b/.gitignore index ff64cce..dbaa7d1 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,10 @@ Desktop.ini *.pem *.key *.snk + +# Generated tenant evidence across all products; never publish. +Products/**/Reports/ +**/Deploy-*-Plan-*.html +**/Deploy-*-Plan-*.json +**/Test-PurviewTenantConfiguration-*.html +**/Test-PurviewTenantConfiguration-*.json diff --git a/AGENTS.md b/AGENTS.md index 7f890f4..b7ba56a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,8 +8,8 @@ last-reviewed: 2026-09-23 This is the master entry point for humans and coding agents working on SMBBestPracticeTool: repeatable, idempotent PowerShell automation for Microsoft -365 best-practice configurations. Microsoft Purview is the currently shipped -product. Read the linked guidance before editing; each topic has one owner below. +365 best-practice configurations. Each product's README defines its scope +and release status. Read the linked guidance before editing; each topic has one owner below. ## Context and verification diff --git a/CHANGELOG.md b/CHANGELOG.md index 8baf9d7..ee60ea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,176 +1,852 @@ ---- -title: Changelog -layout: default -nav_order: 90 -permalink: /changelog/ ---- - -# Changelog -{: .no_toc } - -All notable, **operator-facing** changes to the SMB Best Practice Tool are -recorded here — new features, changed defaults, fixes, and anything that -affects how you run the toolkit or what lands in a customer tenant. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -1. TOC -{:toc} - ---- - -## How versions work here - -This is an operations toolkit organised around **products** — a product is a -tenant-facing workload with its own deployment path (e.g. `[Purview]`). SemVer -(`MAJOR.MINOR.PATCH`) is read against those products: - -| Bump | Means | Example | -|---|---|---| -| **MAJOR** | A **new product** is added to the toolkit — a new tenant-facing workload with its own deployment path. | Adding Defender alongside Purview. | -| **MINOR** | A **new feature, switch, or a redesign _within_ an existing product** — backward compatible for whoever runs it. | A new optional `-Switch`; the sensitivity-label signature redesign. | -| **PATCH** | A **bug fix** with no behaviour change for correct usage. | A connect/auth reliability fix. | - -> A **changed default** (e.g. a switch that flips off→on, or a new tenant-side -> effect) does **not** by itself force a MAJOR bump — it rides the MINOR/PATCH of -> the product it belongs to — but it is **always** called out under **Changed** -> so operators see it. - -Each entry is grouped under **Added / Changed / Deprecated / Removed / Fixed / -Security**, and tagged by area (e.g. `[Purview]`, `[Site]`). Changes that have -landed but not yet been tagged in a release appear under **Unreleased**. - -> 🛠 **Maintainer note.** This is the *operator-facing* counterpart to the -> internal `Skills/` decision logs (which capture the *why* in detail). Keep -> entries short and operator-focused: "what changes when I run this." When you -> cut a release, rename `Unreleased` to `vX.Y.Z — YYYY-MM-DD` and create the -> matching git tag / GitHub Release. - ---- - -## [1.2.0] - 2026-07-01 - -> **Note — significant redesign (within Purview).** This reworks Purview sensitivity-label -> management to adopt Microsoft's built-in taxonomy by its stable signature, create MS-exact -> labels on blank tenants, and support the modern label scheme. It is a substantial redesign -> but stays within the existing Purview product, so it ships as a **MINOR** bump -> (`1.2.0`), not `2.0.0`. MAJOR (`2.x`) is reserved for adding a **new product** (e.g. Defender). - -### Added - -- **[Purview] Region- and scheme-proof label adoption + creation via the Microsoft - `defa4170` signature.** Built-in labels are now identified by their stable internal - `defa4170-…` name instead of the (localized) display name, so adoption works identically - on English/French/German/… tenants and on both classic and modern label schemes. On - blank tenants the toolkit now *creates* labels with the same `defa4170` names, so they are - byte-identical to Microsoft's defaults (multitenant tools that look up by the standard - name work everywhere). -- **[Purview] Blank modern-scheme tenant support.** Parents that own sub-labels are created - as label **groups** (`-IsLabelGroup`) on modern-scheme tenants, with automatic fallback to - a classic parent on classic tenants. Previously, deploying to a *blank* modern tenant - failed creating sub-labels with `InvalidParentLabelInModernLabelSchemeException`. -- **[Purview] "Inherit label from attachments" is on by default.** Label policies now set - `AttachmentAction = Automatic`, so an email inherits the highest-priority label from its - attachments. Set `LabelPolicy.AttachmentAction` to `'Recommended'` (prompt) or `$null` - (off) in `PurviewConfig.psd1` to change it. - -### Changed - -- **[Purview] Sensitivity-label taxonomy aligned to the Microsoft built-in defaults.** - Confidential publishes **All Employees** + **Trusted People**; Highly Confidential - publishes **All Employees** + **Specific People**; **General** is now a label **group** - with **Anyone (unrestricted)** + **All Employees (unrestricted)**. The custom - `Specific People` (under Confidential) and `Internal Exception` sub-labels are removed and - consolidated into Trusted People / Specific People. **⚠️ On tenants where a previous - version already created those extra sub-labels, they become unmanaged — delete them in - Purview Admin.** -- **[Purview] Email default targets the assignable `General\Anyone (unrestricted)` leaf** - (General is now a non-applicable group). -- **[Purview] Soft-delete tombstone rename no longer adds ` v2` to the user-visible - DisplayName.** When a ~30-day tombstone blocks a re-created label, only the internal name - is versioned; the DisplayName stays clean (a tombstone does not reserve the DisplayName). - -### Fixed - -- **[Purview] No more 45-second false "IPPS propagation" waits** when resolving already- - existing (adopted) labels — they resolve on the first pass, and `-WhatIf` never waits. A - `-WhatIf` that previously idled ~14 minutes now runs straight through. - ---- - -## [1.1.0] - 2026-06-27 - -### Added - -- **[Purview] Modern label-scheme publishing.** On tenants migrated to the - modern sensitivity-label scheme, the toolkit now detects label **groups** (a - parent that has sub-labels becomes a non-publishable container) and publishes - only their sub-labels — the service auto-includes the parent group. It also - substitutes a group used as the document or email default with the - appropriate child (e.g. the Outlook default falls back from `General` to - `General\Anyone (unrestricted)`), and excludes auto-managed group entries - from the label-policy diff so re-runs stay idempotent. **Classic-scheme - tenants are unchanged.** Previously, deploying to a modern-scheme tenant that - already had the Microsoft built-in labels failed to publish with - `Label group(s) ... can not be published`. - -### Fixed - -- **[Purview] No longer aborts on a localized display-name collision.** When a - configured sub-label's display name matches an existing label's *localized* - name under the same parent (e.g. `Specific People` vs the built-in - `Specified People`), the toolkit now adopts the existing label instead of - failing `New-Label` — which previously cascaded into a hard - `Sensitivity label ... not found` stop in the DLP step. -- **[Purview] Policy default label resolves when it is an adopted sub-label.** - Fixes `Default label 'AllEmployees' was not found after creation` on tenants - where the default sub-label was adopted from a pre-existing built-in label - (its live internal name is a GUID, not the configured name). - ---- - -## [1.0.0] - 2026-06-22 - -**Initial versioned release.** This establishes version tracking; `1.0.0` is a -concise baseline snapshot of the toolkit as it stands today. Granular pre-1.0 -development history is intentionally **not** itemised here — see the -[merged pull requests](https://github.com/microsoft/BestPractice_Deploy-Scripts/pulls?q=is%3Apr+is%3Amerged) -and [commit log](https://github.com/microsoft/BestPractice_Deploy-Scripts/commits/main) -for the detail. From the next release onward, every change is listed under its -own version above. - -### What's in the box - -- **[Purview] Data Security baseline** — idempotent, re-runnable deployment of - tenant settings, sensitivity labels (encryption + container scope), DLP, and - optional retention, all driven from one config file (`PurviewConfig.psd1`). -- **[Purview] License-aware** — auto-detects Business Premium vs E5 / Purview - Suite and applies what the licence supports (E5 / Purview Suite additionally - gets Endpoint DLP and AI governance / Copilot DLP). -- **[Purview] Zero-Trust connect** — Graph-first auth, WAM broker, and - automatic recovery from common MSAL / `Microsoft.Graph.Beta` module issues. -- **[Site] Documentation site** — this GitHub Pages site: deployment framework, - support-team guide, configuration reference, change-management timeline, - adoption guide, and scenarios reference. - -### Safety defaults - -- **DLP starts in simulation** — zero user impact on day 0; promote after a - Day-30 review. -- **Destructive / irreversible actions are opt-in** — retention - (`-ApplyRetention`) and label co-authoring (`-EnableLabelCoAuthoring`); - `-BPOnly` hard-blocks E5-only features. -- **Every run produces an HTML + JSON report** (secrets stripped) recording - what ran, was skipped, or failed. - -> **Upgrading from an earlier (untagged) build?** `-EnableContainerLabels` and -> `-ApplyAIControls` are now deprecated no-ops — both behaviours are default-on. -> Use `-SkipContainerLabels` / `-SkipAIControls` to opt out. - ---- - - -[Unreleased]: https://github.com/microsoft/BestPractice_Deploy-Scripts/compare/v1.1.0...HEAD -[1.1.0]: https://github.com/microsoft/BestPractice_Deploy-Scripts/compare/v1.0.0...v1.1.0 -[1.0.0]: https://github.com/microsoft/BestPractice_Deploy-Scripts/releases/tag/v1.0.0 +--- +title: Changelog +layout: default +nav_order: 90 +permalink: /changelog/ +--- + +# Changelog +{: .no_toc } + +All notable, **operator-facing** changes to the SMB Best Practice Tool are +recorded here — new features, changed defaults, fixes, and anything that +affects how you run the toolkit or what lands in a customer tenant. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +1. TOC +{:toc} + +--- + +## How versions work here + +This is an operations toolkit organised around **products** — a product is a +tenant-facing workload with its own deployment path (e.g. `[Purview]`). SemVer +(`MAJOR.MINOR.PATCH`) is read against those products: + +| Bump | Means | Example | +|---|---|---| +| **MAJOR** | A **new product** is added to the toolkit — a new tenant-facing workload with its own deployment path. | Adding Defender alongside Purview. | +| **MINOR** | A **new feature, switch, or a redesign _within_ an existing product** — backward compatible for whoever runs it. | A new optional `-Switch`; the sensitivity-label signature redesign. | +| **PATCH** | A **bug fix** with no behaviour change for correct usage. | A connect/auth reliability fix. | + +> A **changed default** (e.g. a switch that flips off→on, or a new tenant-side +> effect) does **not** by itself force a MAJOR bump — it rides the MINOR/PATCH of +> the product it belongs to — but it is **always** called out under **Changed** +> so operators see it. + +Each entry is grouped under **Added / Changed / Deprecated / Removed / Fixed / +Security**, and tagged by area (e.g. `[Purview]`, `[Site]`). Changes that have +landed but not yet been tagged in a release appear under **Unreleased**. + + +--- + +## [Unreleased] + +### Added + +- **[Entra] First-run and recovery guidance.** Adds an operator guide and + evidence/troubleshooting guide, linked from the product and shared starting + pages. Emergency-access instructions now distinguish Conditional Access + exclusions from mandatory MFA and require tested replacement access before + retiring an emergency account. + +- **[Entra] P1 coverage and optional admin hardening.** Entra 0.4.0 adds + report-only device-code blocking and security-information registration + protection to the default baseline, plus opt-in phishing-resistant MFA for + privileged roles. Existing matches require manual review, and P2 risk + policies remain guidance-only. + +- **[Entra] Deployment guidance and policy naming.** Adds colored text-labeled + output, numbered stages, module summaries and a first-preview apply checklist. + An optional naming prefix adds sortable tier, sequence and scope codes while + preserving existing policy names. Security Defaults remains a visible, + read-only deployment gate and is never disabled automatically. + +### Fixed + +- [Defender] Target Graph sign-in at the delegated customer and verify the + requested operator before reusing a cached session or reporting preflight + success. Explicit tenant IDs retain precedence; permissions and blocked + app-only authentication remain unchanged. + +- [Entra] Target the guest MFA policy at guests and external users instead + of all members of the pilot group. New policies remain report-only by + default. Existing guest policies require manual migration review, and + readback rejects unexpected guest exclusions or additional workforce scope. + +- [Defender] Report a module as `FAILED` when any operation or readback fails, + even if other entries are blocked. +- [Entra] Correct role and Azure-management targeting and include the MFA + alternative in the device-or-MFA policy. Verify managed session controls and + device filters during readback. Existing corrected policies require manual + review rather than automatic adoption; new policies remain report-only by + default. +- [Intune] Treat exclusion-only assignments as unknown scope. Verify managed + compliance and enrollment settings and their assignments instead of accepting + a matching display name as proof of deployment. Keep enrollment inventory + and write payloads aligned with their documented Graph API versions. + Existing app-protection policies remain unchanged and are reported as + requiring review rather than verified compliance. + +- **[Entra, Intune] Report incomplete work accurately.** Modules that never + start after an earlier failure now carry explicit skipped/blocked evidence, + rather than appearing successful from configuration messages alone. + Entra console, HTML, and JSON summaries agree without a child summary + changing its parent's result. Evidence-write warnings cannot replace the + deployment error when warning preferences are set to stop. + +- **[Entra, Intune] Find saved run reports.** Final output now shows the full + paths of successfully saved HTML and JSON reports and a command to open the + HTML manually, including previews and failures after logging starts. + Report-write failures are visible and do not hide the original deployment + error. Report filenames and tenant behavior are unchanged. + +- **[Intune] Keep Graph prerequisite versions aligned.** DeviceManagement is + installed and imported at the same SDK version as Authentication. Existing + loaded versions are respected, missing versions still require installation + approval, and conflicting assemblies stop with fresh-session recovery + guidance instead of an unpinned reinstall. + +- **[Entra] Existing-policy review and exclusion payloads.** Review-only + policies retain a manual-review outcome even when compared fields match. + Emergency-access exclusions merge as flat Graph string collections while + preserving existing guest and group exclusions. + +- **[Entra] Disabled-policy verification.** A deliberately disabled new + policy can be verified and rerun without a false readback failure. A disabled + existing policy is still not considered compliant with an active target. + +- **[Intune] Conditional Access and default-compliance reliability.** Intune + now requests the documented Conditional Access read/write permission pair, + verifies the Microsoft Intune Enrollment service principal after creating it, + retries bounded service-principal and Conditional Access propagation checks, + and treats nullable default-compliance projections as unknown. Unknown + pre-write state cannot trigger a tenant change, while unavailable post-write + readback blocks continuation with portal verification guidance. + +### Security + +- **[Purview] Tenant validation rejects broader Graph consent.** The read-only + validator now checks the effective token, disconnects contexts containing + permissions outside its two documented Graph read scopes, and supports an + explicit fresh-session reauthentication path followed by an approved + isolated public client fallback plus device authentication. +- **[Entra] Security Defaults readiness gate.** Entra 0.3.0 reads Security + Defaults before setup and at selected write boundaries. Enabled or + unverifiable state withholds all tenant writes, including account creation + and opted-in tenant settings, while supported assessments can continue. + The toolkit never disables Security Defaults. Reports expose readiness + state and the deployment guide maps coverage and the approved transition, + without claiming report-only policies replace active protection. + +- **[Entra][Intune] Purview-aligned delegated Graph authentication.** Entra + 0.2.0 and Intune 0.3.0 now connect Microsoft Graph once per run using the + administrator UPN or GDAP customer domain, reuse only account-, scope-, and + tenant-matched contexts, disconnect stale sessions, and verify live tenant + identity before setup begins. The product-specific `-TenantId`/`-ClientId` + delegated-app path and the blocked `-CertificateThumbprint` placeholder are + removed, and `-AutoInstallModules` adds Purview-compatible module readiness. + `-NonInteractive` suppresses toolkit prompts but does not enable app-only + authentication. + +- **[Defender] Pre-consent ASR validation.** Rejects conflicting or incomplete + ASR apply and recovery requests before permission planning or Microsoft Graph + connection, so invalid input cannot trigger a write-scope consent request. + ASR pagination now accepts only bounded HTTPS links on the Microsoft Graph + host. + +- **[Defender] Standalone ASR write authorization.** Requires the selected + permission plan to contain the verified apply or recovery operation before + the Defender for Business module can reach Microsoft Graph. Policy and + assignment readback mismatches now record an exact blocked result. + +### Changed + +- **[Defender] Available release scope.** Lists the Defender toolkit as + Available with a read-only default and the pilot-validated ASR Audit managed + configuration path. Safe Attachments, quarantine, MDE advanced, and MDCA + writes remain `GuidedOnly` pending operation-specific promotion evidence. + +- **[Defender] Operator parameter reference.** Lists every Defender deployment + parameter, default, safety interaction, and unsupported boundary in the + product README. Detailed authentication, permission, lifecycle, evidence, + and recovery guidance now lives on one canonical child page per topic. + +### Fixed + +- **[Purview] Deployment Plan intent parity.** Publish-all label policies, + singular DLP `LabelPath`, encryption lifetime, managed ownership, custom + recipient sets, supporting-guide ordering, and paired HTML/JSON output paths + now match the effective deployment contract. The plan also uses a canonical + Microsoft 365 Copilot location token and opaque custom-location digests, + excludes unsupported-only retention scope, and rejects malformed built-in + label signatures before sign-in, producing deterministic validator-ready + schema 1.2 artifacts. + +- **[Entra] App-protection template and migration safety.** New Android/iOS + policies use the name "Require app protection policy" with the existing + `compliantApplication` grant and no conflicting platform exclusions. + Current and legacy names are recognized across all policy pages; existing + policies require manual migration review and are not duplicated or silently + changed, including with `-AdoptExisting`. Ambiguous matches and corrected + readback mismatches block the operation. Custom configuration must include + the current app-policy migration fields. + +- **[Intune] Write-capable documentation parity and pilot assignment.** Corrects stale read-only + preview language across the product README, operator and deployment guides, + examples, support guidance, configuration reference, and roadmap. The + documentation now identifies the six supported write paths, + their pilot and high-risk gates, and the remaining guided or blocked tasks. + Enrollment restriction writes now honor the resolved pilot scope unless + tenant-wide assignment was explicitly approved. Create-only writers also + leave existing same-purpose objects unchanged when `-AdoptExisting` is + supplied instead of attempting a duplicate create. Unmanaged same-name + objects now block the run, service-created IDs are encoded before follow-up + requests, and supported post-write readback mismatches stop the run. + App-protection targeting and assignment remain explicitly portal-verified. + Conditional Access is restricted to the report-only state with explicit + emergency-access exclusion IDs in the current release. A short first-run + path now links pilot group setup, interactive sign-in recovery, separate + high-risk stage commands, and object-specific partial-deployment recovery. + The documentation hub links directly to that Intune first-run path. + Renamed managed enrollment/compliance objects block duplicate creation; + equivalent renamed Conditional Access policies remain unchanged. + +### Added + +- **[Purview] Standalone tenant validation.** Added a read-only validator that + compares live state with schema 1.2 intended state or runs a guide-only Good, + Better, and Best assessment without assuming default configuration. Matching + HTML and JSON artifacts separate observation, plan comparison, and baseline + results, calculate proven and provisional levels, anonymize unmatched + objects, and request only `Organization.Read.All` and + `GroupSettings.Read.All`. A new operator runbook documents required modules, + report commands and locations, version-conflict troubleshooting, and the + evidence needed to qualify future exact-version module profiles. Pilot tenant + evidence remains pending. + +- **[Purview] Tiered pre-connection Deployment Plan.** The plan now compares + selected actions with the supplied Good, Better, and Best Data Security guide + and keeps Microsoft Learn as a separate supporting reference. Each HTML and + JSON pair has a short plan reference, while the deterministic fingerprint + continues to identify equivalent intent. The JSON carries the identity fields + and sanitized intended-state snapshot used by the standalone tenant + validation handoff. Tenant identity, live object GUIDs, mailbox UPNs, + resolved domains, and local paths remain excluded. The primary mapping now + follows the supplied guide's task priorities: audit and foundational label + controls are Good, Exchange retention is Better, and advanced DLP, + auto-labeling, encryption, custom classification, and DSPM are Best. The + Action Preview now labels these as Deployment Priority Levels and includes a + closed glossary for its intent, priority, and recommendation statuses. + +- **[Defender] Limited-access quarantine policy planning.** The MDO/EOP module + now reads and plans a custom quarantine policy with limited end-user access, + notifications enabled, blocked-sender messages excluded, and no protection + policy assignments. Production mutation remains `GuidedOnly`. A quarantine + policy the toolkit does not own is never adopted, even with `-AdoptExisting`, + because the module cannot read which anti-spam, anti-phishing, anti-malware, + or Safe Attachments policies already reference it; the run stops with + guidance to rename the existing policy or configure a different name. A + configured name that differs from an existing policy only by capitalization + is also stopped, because Exchange Online treats those as the same policy. The + block also covers drift on policies the toolkit owns. Existing policies are + accepted only when already compliant, and the module has no + `Set-QuarantinePolicy` path. A future create requires the verified apply + operation to be present in the permission plan selected for that run. If + the quarantine step cannot proceed, the Safe Attachments and outbound + auto-forwarding assessments still run and record their evidence before the + run reports the quarantine failure, and a configuration that omits the + quarantine section records a skipped entry instead of failing. + +- **[Entra] Read-only deployment health check.** Every run now finishes with a + health check that reports whether the Conditional Access baseline has drifted + since it was deployed, and above all whether each managed policy still + excludes the emergency-access (break-glass) principals. An enforcing policy + that lost its exclusion is reported as a lockout risk, and a tenant where the + exclusion could not be evaluated is reported as indeterminate rather than + healthy. The check issues GET requests only, changes nothing, and can be + skipped with `-SkipDeploymentHealth`. + +- **[Defender] Best-practice coverage guide.** Adds title-led summaries for 25 + in-scope practices and maps 33 practices outside the automated apply scope + to the relevant Microsoft SMB deployment guide sections and pages. + +- **[Defender] Managed ASR Audit pilot configuration.** Adds an explicit + `-EnableAsrAuditPolicy` path that verifies a security-enabled pilot group, + creates or reuses the toolkit-managed Intune policy, and confirms all 19 + Audit settings plus the isolated direct-group assignment. Partial failures + retain the captured policy ID; separately acknowledged recovery can delete + only that exactly verified policy. Default execution remains read-only. + +- **[Intune] Documentation published to the site.** The Intune operator, + deployment, configuration-reference, change-management, evidence/ + troubleshooting, support-team, end-user-adoption, scenarios, and + future-write-capabilities guides now render on the docs site under + `/intune/`, and Intune is listed with a Site link in the repository README + and the site landing page (previously README-only). + +- **[Entra] Conditional Access baseline (new product).** Adds the write-capable + Microsoft Entra toolkit that deploys the Identity Protection guide's ten + Conditional Access policies. Every policy is created report-only by default and + always excludes an emergency-access (break-glass) account, which the toolkit + verifies or creates. High-risk gates (IncludeHighRisk with approval, break-glass + and rollback confirmation), pilot-group scope by default, idempotent on display + name, and ShouldProcess + shared retry + readback + structured JSON/HTML + evidence. The Intune enrollment Conditional Access policy is owned by the Intune + product and is not duplicated here. Validated against a Business Premium test + tenant in report-only state (created, verified, and torn down). + +- **[Entra] Zero Trust tenant-security settings.** Adds + `Setup-TenantSecuritySettings`, covering the Zero Trust "increased security" + identity hardening (restrict user consent, admin consent workflow, restrict + guest access defaults, disable password expiration) over GA `v1.0` Graph APIs. + Every setting is opt-in via its `Apply` flag and reports guided-only until + enabled; the coverage doc records the settings the guidance recommends that are + out of scope or need a Product-Group ask. + +- **[Intune] Tenant write paths for the enrollment and device-management + baseline.** Six baseline tasks now apply to a tenant, not just assess: + Microsoft 365 Apps deployment, app protection (MAM) Level 1, secure-by-default + compliance, per-platform device compliance (Windows now includes Secure Boot + and code integrity), device platform enrollment restrictions, and the + device-based Conditional Access policy for Intune enrollment. Every write is + idempotent (read-before-write on the managed-by tag), gated by + `-WhatIf`/`ShouldProcess`, wrapped in the shared transient-retry boundary, and + read back into the structured JSON/HTML evidence. High-risk items stay behind + `-IncludeHighRisk` and their category switches, and Conditional Access is + created report-only by default. Validated end-to-end on a Business Premium + tenant. The hash-verified policy catalog remains apply-blocked. + +- **[Defender] Read-only Exchange Online auto-forwarding planning.** Adds delegated EXO + connection and tenant verification, runtime prior-state capture, and an + ownership-neutral managed-write adapter for the built-in `Default` policy. + Read-only assessment does not require setter permission; exact setter RBAC + is checked immediately before an approved write. The outbound + auto-forwarding write remains guided-only in production configuration. + +- **[Defender] Safe Attachments planning and readback.** Reads the built-in + ATP policy without requiring setter permission, no-ops when Safe Attachments + is already enabled for SharePoint, OneDrive, and Teams, and prepares guarded + write/readback/recovery behavior. Exact setter RBAC is checked immediately + before an approved write. The write remains guided-only in production + configuration. + +- **[Defender] Module verdicts in deployment reports.** Defender JSON and HTML + reports now include a shared per-module summary using `OK`, `FAILED`, + `SKIPPED`, or `BLOCKED` so release evidence has an unambiguous module-level + outcome. + +- [Defender] Report configured recommendations with their current workload + capability status instead of implying that unavailable controls are ready. + +- **[Defender] Delegated authentication alternatives.** Documents browser + authentication as the default and device code flow as a fallback, including + explicit tenant targeting, process-scoped contexts, and known WAM/token + handoff troubleshooting. Neither path changes the preview's read-only and + guided-only boundaries. + +- **[Defender] Corrected preview boundaries.** Defender public guidance now + describes read-only, guided-only readiness coverage, uses the exact report + names, qualifies contributor-reported pilot observations, and does not + claim MDCA policy-management support or tenant deployment. + +- **[Intune] Contributor policy catalog.** Integrates all 19 mobile and + Windows policy payloads from the existing contributor implementation into a + hash-verified candidate catalog. Export-only IDs and timestamps are removed, + a placeholder Factory Reset Protection account is removed, Settings Catalog + payload shape and discriminators are repaired, task 7 evidence reports the + seven compliance candidates, and a reserved + `-EnablePolicyCatalogWrite` gate always denies tenant apply. + +- **[Intune] Device enrollment deployment guide.** Adds a guide-aligned + partner runbook for the prerequisite, Priority 1, Priority 2, Priority 3, + and post-configuration enrollment workflow. It identifies the current + read-only and guided boundaries for every task. + +- **[Purview] Offline Deployment Plan.** Each invocation now writes matching + HTML and JSON intent artifacts before authentication, including config + fingerprints, effective scope, and a versioned comparison to Microsoft's + Data Security guide. Use `-NoDeploymentPlan` to suppress them. + +### Changed + +- **[Purview] Deployment Plan schema is now 1.2.** Plans now carry a sanitized + intended-state handoff with action `IntendedStateKeys`, top-level + `ManagedByTag`, handoff capabilities, and deterministic fingerprints. The + standalone validator derives allowlisted adapters, selectors, prerequisites, + comparators, and expected values from that data; the plan does not carry + action-level validation instructions. Plans generated before schema 1.2 + remain readable but are rejected as validation baselines with an instruction + to regenerate. + +- **[Defender] Preview operator guide.** Documents the current read-only scope, + prerequisites, reports, safety gates, explicit exclusions, and the + guided-only boundary for unverified tenant writes. +- **[Defender] Explicit workload readiness records.** Graph tenant identity and + license preflight are distinguished from unavailable EXO/IPPS, Intune, + Defender portal, and MDCA connections; operator docs and release/SFI gates + now preserve the Planned status until pilot evidence exists. +- **[Defender] Initial low-touch product foundation.** Adds the independent + PowerShell Defender product boundary, configuration contract, tenant/API + preflight, license capability classification, safety gates, and + Purview-aligned structured JSON/HTML evidence. The current implementation is + pre-release and does not yet claim production-ready tenant enforcement. +- **[Intune] Apple MDM push certificate health assessment.** The enrollment + prerequisites module now reads the Apple MDM push certificate through + Microsoft Graph and reports whether it is present and unexpired. A valid + certificate is recorded as already compliant, an expired one is recorded as + guided with renewal direction, and a missing, incomplete, or unreadable + response fails the run instead of guessing. The read needs + `DeviceManagementServiceConfig.Read.All`. Downloading the certificate signing + request and uploading a certificate remain manual steps in the Apple portal. +- **[Intune] Default compliance setting assessment.** The compliance baseline + module now reads the tenant `secureByDefault` setting and reports whether + devices with no targeted compliance policy are already treated as + noncompliant. It never changes the setting. Enabling it stays a manual, + gated decision because it can deny access once device-based Conditional + Access is enforced. The read requires the + `Microsoft.Graph.DeviceManagement` PowerShell module. +- **[Intune] Android and iOS app protection assessment.** The app protection + module now reads Android and iOS/iPadOS managed app protection policies, + assignments, and targeted-app readback through Microsoft Graph GET requests. + It reports one guided-only assessment per platform with normalized counts and + assignment scope, redacts policy, group, app, tenant, and operator + identifiers from evidence, and leaves policy creation, assignment, targeting, + update, and deletion blocked pending human approval and pilot evidence. +- **[Intune] Device enrollment restriction inventory.** The enrollment + restriction module now reads GA platform-restriction configurations and + assignments, reports normalized counts and assignment scope without retaining + identifiers, and continues to block all create, update, assignment, and + delete operations pending pilot evidence. +- **[Intune] Per-platform compliance policy inventory.** Adds an independent + GET-only module that inventories policies, assignments, and scheduled actions, + classifies GA, beta-only, and unknown platform types, and emits redacted + normalized evidence while all compliance writes remain blocked. +- **[Intune] Initial device management product foundation.** Adds the + independent PowerShell Intune product boundary, configuration contract, + tenant/API preflight, license capability classification, safety gates, and + Purview-aligned structured JSON/HTML evidence for the Device Management + Deployment Guide for Small Business baseline (ten tasks across three + priorities). The current implementation is pre-release and read-only: it + changes no tenant state, and no baseline item ships apply behavior until its + API, permissions, licensing, readback, and rollback are verified. + +### Changed + +- **[Intune] Guide-task evidence follows deployment order.** Intune + configuration and license evidence now lists tasks by Priority and guide task + number, rather than alphabetically by toolkit key. + +### Fixed + +- **[Purview] Unsupported DLP workloads no longer terminate the module.** + Workloads outside Exchange, SharePoint and OneDrive, and Endpoint are skipped + with structured evidence, and the Deployment Plan excludes them from intended + state instead of implying a supported runtime path. + +- **[Defender] Configured-item summaries now reflect module outcomes.** Run-log + entries remain individually filterable, and MDO/EOP terminal evidence carries + its configured best-practice key so final reports show the actual compliant, + blocked, or guided-only result instead of an unattributed fallback. + +- **[Defender] Missing quarantine policies now plan creation correctly.** The + MDO/EOP module enumerates quarantine policies and selects the configured name + locally. This avoids Exchange module versions that raise an object-not-found + error for an unknown identity while preserving collision checks and the + create-only, `GuidedOnly` boundary. + +- **[Entra] Permanent emergency-access role verification.** Break-glass checks + now use active role assignment schedule instances and require a tenant-wide + Global Administrator assignment with no expiry. Temporary PIM activations + and expiring assignments no longer qualify. + +- **[Entra] Graph pagination destination validation.** Entra directory reads + now parse continuation links and require HTTPS, the configured Graph host, + the default port, and the configured API path boundary before requesting the + next page. Deceptive hostnames and lookalike path prefixes are rejected. + +- **[Intune] Enrollment restriction exclusion counts are now order independent.** + The assignment-scope assessment stopped counting exclusion groups as soon as it + saw an all-users or all-devices target, so the `ExclusionCount` reported in + enrollment restriction evidence depended on the order Microsoft Graph happened + to return assignments and could read zero while exclusions existed. Both Intune + modules now share one assignment-scope classifier, so the two cannot drift + apart again. The assessment now also inspects every assignment instead of + stopping at the first broad target, so a malformed assignment fails the + assessment rather than being skipped depending on response order. + +- **[Defender] Managed-write failures now fail the deployment.** MDO/EOP + operations preserve every item-level recovery entry, then return a failing + module and run outcome when a write fails or exact readback cannot confirm + the tenant state. + +- **[Defender] Least-privilege Graph examples.** Corrects delegated tenant + identity examples to request the operation-selected `User.Read` scope rather + than the application-only `Organization.Read.All` permission. + +- **[Defender] Cmdlet throttling retries.** Retries supported EXO, IPPS, and + SPO cmdlet failures when their error messages indicate throttling or + temporary service unavailability, while preserving fail-fast handling for + deterministic failures. + +- **[Defender] Delegated tenant targeting.** Allows `-TenantId` without + certificate parameters for delegated browser or device-code authentication; + certificate validation still requires the complete tenant, client ID, and + thumbprint tuple. + +- **[Defender] Device code deployment switch.** Adds + `-UseDeviceAuthentication` to route the orchestrator through the documented + delegated device-code fallback while preserving the read-only preview + boundary. + +- **[Intune] Unconfigured Apple certificate assessment continues safely.** + Microsoft Graph `404` responses from the Apple certificate singleton now + produce a sanitized `GuidedOnly` result with portal verification direction, + rather than aborting the remaining read-only assessments. + +- **[Intune] Default compliance service state is assessed correctly.** A + nullable `SecureByDefault` value now maps to Microsoft's documented + `Compliant` default and a `GuidedOnly` result instead of aborting a + read-only run. + +- **[Intune] Compliance inventory survives unavailable action readback.** When + the documented scheduled-action relationship has no matching v1.0 GET route, + the toolkit now preserves policy and assignment inventory and reports the + action count as unavailable instead of failing or reporting zero. + +- **[Intune] Enrollment inventory accepts service-defined IDs.** Opaque + configuration identifiers are now URL-encoded for assignment reads instead + of being rejected when they are not GUIDs. + +- **[Intune] App-protection inventory accepts service-defined IDs.** Opaque + Android and iOS/iPadOS policy identifiers are now URL-encoded for assignment + and target-app reads instead of being rejected when they are not GUIDs. + +- **[Intune] Public gate messages no longer expose internal milestone names.** + Runtime refusals, module help, configuration comments, and README guidance + now name the actual API, permission, role, consent, licensing, readback, and + rollback conditions. Public tests no longer require private planning paths. + +- **[Intune] Policy catalog integrity is cross-platform.** Catalog hashes now + use a canonical LF representation, so Windows CRLF checkout settings no + longer cause Linux or macOS validation failures. A catalog integrity failure + is reported separately and no longer prevents live read-only compliance + inventory from completing. + +- **[Intune] Enrollment restriction scope evidence no longer conflates default, + custom, and exclusion assignments.** The assessment now separates assigned + and unassigned restriction signals, counts exclusions independently, + recognizes all-users and all-devices targets as broad, accepts valid OData + type prefixes, and preserves legitimate empty Graph collections. + +- **[Intune] Read-only assessment evidence stays consistent and sanitized.** + Expected unsupported targeted-app responses no longer make a guided-only + assessment report fail, successful retries no longer leave failed evidence, + and subscribed SKU, Apple certificate, and default compliance failures no + longer retain raw service-authored error text. +- **[Defender] Corrected the high-risk approval contract and review lifecycle.** + Removed obsolete customer approval token parameters, aligned safety-gate + fixtures and documentation with explicit operator opt-in, and clarified that + independent review and tenant pilot validation occur during preflight rather + than in the build-stage evidence record. +- **[Defender] Redacted tenant identifiers in JSON and HTML evidence.** + Reports no longer expose the tenant GUID while retaining masked operator + identity and structured evidence. + + +### Changed + +- **[Intune] Apple certificate renewal is flagged before expiry.** The + read-only Apple MDM push certificate assessment now reports guided renewal + direction when the certificate enters a configurable warning window, which + defaults to 30 days, rather than waiting until the certificate has expired. + CSR generation, the Apple portal exchange, and certificate upload remain + guided-only. + +- **[Intune] Managed Google Play guidance identifies the safe ownership + boundary.** The guided result now calls for a customer-controlled Entra + account with an active mailbox, the required Intune role, a second Google + enterprise owner, and verification through the four automatically added + Android apps. It also states that GDAP does not replace interactive customer + consent and that disconnect is destructive. + +- **[Intune] Future write capability roadmap.** Adds a partner/customer-facing + register that distinguishes work in progress, pilot blockers, guided-only + tasks, genuine API gaps, and the precise stakeholder help needed without + presenting beta surfaces as supported automation. +- **[Intune] Complete read-only deployment guidance.** Adds a ten-task scenario + matrix, deployment framework, change-management playbook, current portal + routes, support-team guide, end-user communication kit, worked examples, + configuration reference, and evidence/troubleshooting guidance. The material + clearly separates the current assessment from later customer-approved portal + changes and blocked write automation. +- **[Intune] Microsoft 365 Apps deployment guidance.** Task 8 now emits a + GuidedOnly result with the supported Intune portal path, configuration + decisions, pilot assignment, and installation-status verification. +- **[Intune] Enterprise State Roaming guidance follows the current management + model.** Task 9 now directs operators to Windows Backup for Organizations in + Intune and explicitly rejects the obsolete post-June 2026 Entra portal path + while policy API and migration behavior remain under verification. +- **[Intune] Conditional Access remains explicitly non-enforcing.** Task 10 now + emits GuidedOnly evidence naming every prerequisite for a future pilot-scoped + report-only policy, while creation and enforcement remain blocked. + +- **[Intune] Withholding a write switch no longer hides the default compliance + reading.** The default compliance assessment is read-only, so it now runs + even when `-IncludeHighRisk` or `-EnableComplianceEnforcement` is not + supplied. The missing write authorization is recorded separately in the + report instead of blocking the check, so you can see the tenant's current + state before deciding whether to approve a change. Enrollment restrictions, + device compliance policies, and Conditional Access are unchanged and stay + blocked without their switches. + +- **[Intune] Per-item license findings are labelled as license findings.** + Preflight now records each item's license result as `LicenseDisposition` + rather than `Disposition`, so a licensing result is no longer easy to mistake + for the outcome of the task itself. The Apple certificate state is now + reported once, by the task that reads it, instead of also appearing as a + fixed preflight note. + +- **[Intune] Policies are assigned to a pilot group by default.** The source + guide recommends assigning to all users. This toolkit deliberately defaults to + `-PilotGroupId` instead, because enrollment restrictions, compliance + enforcement, and Conditional Access can deny access to every user in the + tenant on a first run. Tenant-wide assignment is an explicit opt-in through + `-AssignTenantWide`, which also requires `-RollbackAcknowledged` and must be + permitted by `Assignment.AllowTenantWideAssignmentForHighRisk` in + configuration. + +- **[Intune] Device compliance policies are classified high risk.** The source + guide presents them as routine. Creating a compliance policy can mark existing + devices noncompliant, and where the customer already operates Conditional + Access requiring a compliant device, those users lose access immediately. Four + of the ten baseline items now require `-IncludeHighRisk`, and a blocked item is + recorded in the report with its reason rather than silently omitted. + +- **[Intune] A run refused by a safety gate still produces a report.** Evidence + is initialised before gates are evaluated, so a rejected run records which gate + refused it and why. Customer approval references and approval artifacts are + recorded only as SHA-256 digests. + +- **[Repo] Shared prerequisites no longer claim every toolkit installs modules + automatically.** The root README and documentation home previously stated that + the toolkits auto-detect missing modules and that `-AutoInstallModules` + installs them silently. That is a Purview behavior, not a framework guarantee, + and Intune does not expose the switch. Both pages now direct you to the + product's own documentation for its supported installation path. + +### Fixed + +- **[Intune] Safety-gate errors remain diagnostic under strict mode.** Empty + certificate-argument sets are counted safely, and non-HTTP exceptions retain + their original refusal reason instead of being masked by status-code parsing. + +- **[Intune] Deployment evidence no longer stores raw tenant or approval + identifiers.** JSON and HTML reports retain only the first tenant-ID segment + for correlation, redact the remaining GUID segments, redact tenant domains + including those quoted inside service error messages, omit approval paths, and + hash customer approval references. Microsoft service endpoints such as + `graph.microsoft.com` stay readable so failures remain diagnosable. + +- **[Intune] The configured Graph endpoint is now the only one used.** + `Api.GraphBaseUri` was defined in configuration while the tenant-identity and + license requests hardcoded the endpoint, so changing the setting had no + effect. Both requests are now composed from the configured value, and the + value is validated as an approved HTTPS Microsoft Graph host before use. + +- **[Intune] Per-category approval is enforced alongside global approval.** + An item was recorded as unblocked once `-IncludeHighRisk` was supplied, even + when its own switch, such as `-EnableComplianceEnforcement`, was withheld. + The blocklist now evaluates both, so it agrees with the gate a module would + apply and names the missing switch as the reason. The modules are read-only + today, so this closes a future safety boundary rather than an active write. + +- **[Intune] A failed configuration check now reports its own reason.** When + validation rejected a configuration before the run log existed, the + orchestrator's error and cleanup paths read an uninitialised variable and + replaced the real message with a variable-access error. The run now reports + which configuration value was rejected. `ConditionalAccess.DefaultState` is + also validated by value, so a typo cannot reach a Conditional Access decision. + +- **[Intune] Conditional Access permission requirements are now documented in + full.** The API permission matrix listed only + `Policy.ReadWrite.ConditionalAccess` for Conditional Access create, update, + delete, and enable. Microsoft documents the least-privilege requirement as the + pair `Policy.Read.All` and `Policy.ReadWrite.ConditionalAccess`, and records a + known permissions issue confirming POST and PATCH need `Policy.Read.All` + consent. All four rows now list both permissions, and the known issue is + recorded as a Task 10 prerequisite so a run is not consented into a 403 at + apply time. + +- **[Purview] [Defender] `-WhatIf` no longer discards the deployment report.** + The HTML report and JSON sidecar are written with `-WhatIf:$false` because + they are local evidence, not a tenant change. Previously these writes + inherited `$WhatIfPreference` from the orchestrator, so a `-WhatIf` dry run + produced **no report at all** while still printing `HTML report written:` with + a path that did not exist. Normal (non-`-WhatIf`) runs are byte-for-byte + unchanged. + +### Security + +- **[Intune] Delegated consent now matches implemented reads.** The Preview uses + `User.Read` and `LicenseAssignment.Read.All` for tenant and SKU discovery and + no longer requests unused `Organization.Read.All` or `Policy.Read.All`. + Delegated authentication is process-scoped so historical user tokens cannot + restore removed scopes. + +- **[Defender] Least-privilege consent model now fails closed.** Read-only Graph + permissions are selected per operation (`User.Read` for tenant identity and + `LicenseAssignment.Read.All` for license inventory), malformed permission + manifests are rejected, app-only Defender authorization remains blocked until + operation-level evidence exists, and unverified write operations cannot reach + module dispatch. + +- **[Defender] Safe-by-default readiness checks.** Noninteractive runs require + certificate authentication parameters; audit readiness and emergency-access + safety remain explicit guided-only decisions when no stable unattended check is + claimed; secrets and operator UPNs are redacted from evidence. + +- **[Intune] Safe-by-default readiness checks and access-denial gates.** + Certificate-based application authentication is blocked until its + operation-level permissions, roles, and consent are verified. The device-based + Conditional Access policy defaults to report-only and needs + `-IncludeHighRisk`, `-BreakGlassExclusionsConfirmed`, and + `-RollbackAcknowledged` before it can be created enabled, because it can lock + administrators out of the tenant. Marking unevaluated devices as not compliant + and creating enrollment restrictions are separate explicit opt-ins. The Apple + MDM push certificate and managed Google Play connection are recorded as + guided-only and are never attempted, since both require human credentials and + interactive browser flows. Secrets, tenant identifiers, tenant domains, and + operator identities are redacted from evidence. + +--- + +## [1.2.0] - 2026-07-01 + +> **Note — significant redesign (within Purview).** This reworks Purview sensitivity-label +> management to adopt Microsoft's built-in taxonomy by its stable signature, create MS-exact +> labels on blank tenants, and support the modern label scheme. It is a substantial redesign +> but stays within the existing Purview product, so it ships as a **MINOR** bump +> (`1.2.0`), not `2.0.0`. MAJOR (`2.x`) is reserved for adding a **new product** (e.g. Defender). + +### Added + +- **[Purview] Region- and scheme-proof label adoption + creation via the Microsoft + `defa4170` signature.** Built-in labels are now identified by their stable internal + `defa4170-…` name instead of the (localized) display name, so adoption works identically + on English/French/German/… tenants and on both classic and modern label schemes. On + blank tenants the toolkit now *creates* labels with the same `defa4170` names, so they are + byte-identical to Microsoft's defaults (multitenant tools that look up by the standard + name work everywhere). +- **[Purview] Blank modern-scheme tenant support.** Parents that own sub-labels are created + as label **groups** (`-IsLabelGroup`) on modern-scheme tenants, with automatic fallback to + a classic parent on classic tenants. Previously, deploying to a *blank* modern tenant + failed creating sub-labels with `InvalidParentLabelInModernLabelSchemeException`. +- **[Purview] "Inherit label from attachments" is on by default.** Label policies now set + `AttachmentAction = Automatic`, so an email inherits the highest-priority label from its + attachments. Set `LabelPolicy.AttachmentAction` to `'Recommended'` (prompt) or `$null` + (off) in `PurviewConfig.psd1` to change it. + +### Changed + +- **[Purview] Sensitivity-label taxonomy aligned to the Microsoft built-in defaults.** + Confidential publishes **All Employees** + **Trusted People**; Highly Confidential + publishes **All Employees** + **Specific People**; **General** is now a label **group** + with **Anyone (unrestricted)** + **All Employees (unrestricted)**. The custom + `Specific People` (under Confidential) and `Internal Exception` sub-labels are removed and + consolidated into Trusted People / Specific People. **⚠️ On tenants where a previous + version already created those extra sub-labels, they become unmanaged — delete them in + Purview Admin.** +- **[Purview] Email default targets the assignable `General\Anyone (unrestricted)` leaf** + (General is now a non-applicable group). +- **[Purview] Soft-delete tombstone rename no longer adds ` v2` to the user-visible + DisplayName.** When a ~30-day tombstone blocks a re-created label, only the internal name + is versioned; the DisplayName stays clean (a tombstone does not reserve the DisplayName). + +### Fixed + +- **[Purview] No more 45-second false "IPPS propagation" waits** when resolving already- + existing (adopted) labels — they resolve on the first pass, and `-WhatIf` never waits. A + `-WhatIf` that previously idled ~14 minutes now runs straight through. + +--- + +## [1.1.0] - 2026-06-27 + +### Added + +- **[Purview] Modern label-scheme publishing.** On tenants migrated to the + modern sensitivity-label scheme, the toolkit now detects label **groups** (a + parent that has sub-labels becomes a non-publishable container) and publishes + only their sub-labels — the service auto-includes the parent group. It also + substitutes a group used as the document or email default with the + appropriate child (e.g. the Outlook default falls back from `General` to + `General\Anyone (unrestricted)`), and excludes auto-managed group entries + from the label-policy diff so re-runs stay idempotent. **Classic-scheme + tenants are unchanged.** Previously, deploying to a modern-scheme tenant that + already had the Microsoft built-in labels failed to publish with + `Label group(s) ... can not be published`. + +### Fixed + +- **[Purview] No longer aborts on a localized display-name collision.** When a + configured sub-label's display name matches an existing label's *localized* + name under the same parent (e.g. `Specific People` vs the built-in + `Specified People`), the toolkit now adopts the existing label instead of + failing `New-Label` — which previously cascaded into a hard + `Sensitivity label ... not found` stop in the DLP step. +- **[Purview] Policy default label resolves when it is an adopted sub-label.** + Fixes `Default label 'AllEmployees' was not found after creation` on tenants + where the default sub-label was adopted from a pre-existing built-in label + (its live internal name is a GUID, not the configured name). + +--- + +## [1.0.0] - 2026-06-22 + +**Initial versioned release.** This establishes version tracking; `1.0.0` is a +concise baseline snapshot of the toolkit as it stands today. Granular pre-1.0 +development history is intentionally **not** itemised here — see the +[merged pull requests](https://github.com/microsoft/BestPractice_Deploy-Scripts/pulls?q=is%3Apr+is%3Amerged) +and [commit log](https://github.com/microsoft/BestPractice_Deploy-Scripts/commits/main) +for the detail. From the next release onward, every change is listed under its +own version above. + +### What's in the box + +- **[Purview] Data Security baseline** — idempotent, re-runnable deployment of + tenant settings, sensitivity labels (encryption + container scope), DLP, and + optional retention, all driven from one config file (`PurviewConfig.psd1`). +- **[Purview] License-aware** — auto-detects Business Premium vs E5 / Purview + Suite and applies what the licence supports (E5 / Purview Suite additionally + gets Endpoint DLP and AI governance / Copilot DLP). +- **[Purview] Zero-Trust connect** — Graph-first auth, WAM broker, and + automatic recovery from common MSAL / `Microsoft.Graph.Beta` module issues. +- **[Site] Documentation site** — this GitHub Pages site: deployment framework, + support-team guide, configuration reference, change-management timeline, + adoption guide, and scenarios reference. + +### Safety defaults + +- **DLP starts in simulation** — zero user impact on day 0; promote after a + Day-30 review. +- **Destructive / irreversible actions are opt-in** — retention + (`-ApplyRetention`) and label co-authoring (`-EnableLabelCoAuthoring`); + `-BPOnly` hard-blocks E5-only features. +- **Every run produces an HTML + JSON report** (secrets stripped) recording + what ran, was skipped, or failed. + +> **Upgrading from an earlier (untagged) build?** `-EnableContainerLabels` and +> `-ApplyAIControls` are now deprecated no-ops — both behaviours are default-on. +> Use `-SkipContainerLabels` / `-SkipAIControls` to opt out. + +--- + + +[Unreleased]: https://github.com/microsoft/BestPractice_Deploy-Scripts/compare/v1.1.0...HEAD +[1.1.0]: https://github.com/microsoft/BestPractice_Deploy-Scripts/compare/v1.0.0...v1.1.0 +[1.0.0]: https://github.com/microsoft/BestPractice_Deploy-Scripts/releases/tag/v1.0.0 diff --git a/Gemfile b/Gemfile index 4dd9e1e..fe2aa0c 100644 --- a/Gemfile +++ b/Gemfile @@ -1,23 +1,23 @@ -# Gemfile — only needed for local preview with `bundle exec jekyll serve`. -# GitHub Pages itself uses its own pinned gem set; the remote_theme plugin -# pulls just-the-docs at build time. - -source "https://rubygems.org" - -# Match the GitHub Pages gem so local preview matches production rendering. -gem "github-pages", group: :jekyll_plugins - -group :jekyll_plugins do - gem "jekyll-remote-theme" - gem "jekyll-seo-tag" - gem "jekyll-sitemap" -end - -# Windows + JRuby don't include zoneinfo files by default. -platforms :mingw, :x64_mingw, :mswin, :jruby do - gem "tzinfo", ">= 1", "< 3" - gem "tzinfo-data" -end - -# webrick was removed from the Ruby 3+ standard library. -gem "webrick", "~> 1.8" +# Gemfile — only needed for local preview with `bundle exec jekyll serve`. +# GitHub Pages itself uses its own pinned gem set; the remote_theme plugin +# pulls just-the-docs at build time. + +source "https://rubygems.org" + +# Match the GitHub Pages gem so local preview matches production rendering. +gem "github-pages", group: :jekyll_plugins + +group :jekyll_plugins do + gem "jekyll-remote-theme" + gem "jekyll-seo-tag" + gem "jekyll-sitemap" +end + +# Windows + JRuby don't include zoneinfo files by default. +platforms :mingw, :x64_mingw, :mswin, :jruby do + gem "tzinfo", ">= 1", "< 3" + gem "tzinfo-data" +end + +# webrick was removed from the Ruby 3+ standard library. +gem "webrick", "~> 1.8" diff --git a/LICENSE b/LICENSE index 6d541e6..8254965 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,21 @@ -MIT License - -Copyright (c) 2026 Amir Jafarian - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +MIT License + +Copyright (c) 2026 Amir Jafarian + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Products/Defender/Config/DefenderConfig.psd1 b/Products/Defender/Config/DefenderConfig.psd1 new file mode 100644 index 0000000..ea9b96d --- /dev/null +++ b/Products/Defender/Config/DefenderConfig.psd1 @@ -0,0 +1,384 @@ +@{ + ProductName = 'Microsoft Defender Best Practice Toolkit' + ProductVersion = '0.1.0' + ManagedByTag = '[Managed by SMBTool Defender Toolkit]' + + StateManagement = @{ + ObjectDefinitions = @{ + Policy = @{ + IdentifierProperties = @('Name') + ManagedProperties = @('Name', 'Settings') + ManagedTagProperty = 'ManagedBy' + } + Connector = @{ + IdentifierProperties = @('Name', 'ConnectorType') + ManagedProperties = @('Name', 'ConnectorType', 'Settings') + ManagedTagProperty = 'ManagedBy' + } + Recipient = @{ + IdentifierProperties = @('Address') + ManagedProperties = @('Address', 'Role') + ManagedTagProperty = 'ManagedBy' + } + Template = @{ + IdentifierProperties = @('Name', 'TemplateType') + ManagedProperties = @('Name', 'TemplateType', 'Settings') + ManagedTagProperty = 'ManagedBy' + } + IntunePolicy = @{ + IdentifierProperties = @('Name', 'Platform') + ManagedProperties = @('Name', 'Platform', 'Settings') + ManagedTagProperty = 'ManagedBy' + } + QuarantinePolicy = @{ + IdentifierProperties = @('Name') + ManagedProperties = @( + 'Name' + 'EndUserQuarantinePermissionsValue' + 'ESNEnabled' + 'IncludeMessagesFromBlockedSenderAddress' + ) + ManagedTagProperty = 'AdminDisplayName' + } + } + } + + DefenderForOffice365 = @{ + QuarantinePolicy = @{ + Key = 'mdo-quarantine-limited-access' + Name = 'SMBTool-Quarantine-LimitedAccess' + EndUserQuarantinePermissionsValue = 43 + ESNEnabled = $true + IncludeMessagesFromBlockedSenderAddress = $false + ProtectionPolicyAssignments = @() + } + } + + DefenderForBusiness = @{ + AsrAuditPolicy = @{ + Key = 'mde-asr-audit' + Name = 'SMBTool - ASR rules in audit mode' + Description = 'Configures the Microsoft-recommended ASR rules in audit mode.' + TemplateFamily = 'endpointSecurityAttackSurfaceReduction' + TemplateVersion = 1 + TemplateSettingCount = 5 + Platform = 'windows10' + Technologies = @('mdm', 'microsoftSense') + ScopeTagIds = @('0') + RuleMode = 'Audit' + } + } + + # Keep policy names, application-internal keys, and risk classifications in + # configuration. Keys are not Microsoft recommendation identifiers. + BestPracticeItems = @( + @{ + Key = 'mdo-auto-forward' + Name = 'Block outbound auto-forwarding' + Module = 'Setup-MdoEopBaseline' + CapabilityKey = 'exchange-online' + LicenseCapability = 'mdo' + Risk = 'High' + RequiresHighRiskGate = $true + PermissionOperations = @('mdo-auto-forward-read') + } + @{ + Key = 'mdo-safe-attachments' + Name = 'Enable Safe Attachments for SPO, OneDrive, and Teams' + Module = 'Setup-MdoEopBaseline' + CapabilityKey = 'exchange-online' + LicenseCapability = 'mdo' + Risk = 'Standard' + RequiresHighRiskGate = $false + PermissionOperations = @('mdo-safe-attachments-read') + } + @{ + Key = 'mdo-quarantine-limited-access' + Name = 'Create a limited-access quarantine policy' + Module = 'Setup-MdoEopBaseline' + CapabilityKey = 'exchange-online' + LicenseCapability = 'mdo' + Risk = 'Standard' + RequiresHighRiskGate = $false + PermissionOperations = @('mdo-quarantine-policy-read') + } + @{ + Key = 'mde-asr-audit' + Name = 'Deploy ASR rules in audit mode' + Module = 'Setup-DefenderForBusiness' + CapabilityKey = 'intune-asr-audit' + LicenseCapability = 'mde' + Risk = 'Standard' + RequiresHighRiskGate = $false + PermissionOperations = @('mde-asr-audit-read') + } + ) + + # Licensing is documented for operator review only. Runtime readiness is + # established by workload/API availability and authorized readback, not by + # inferring entitlement from SKU or service-plan names. + LicenseCapabilities = @{ + mdo = @{ + DocumentationUrl = 'https://learn.microsoft.com/en-us/defender-office-365/service-description-mdvo' + Requirement = 'Microsoft Defender for Office 365 licensing; suites and add-ons can satisfy this requirement.' + } + mde = @{ + DocumentationUrl = 'https://learn.microsoft.com/en-us/defender-endpoint/microsoft-defender-endpoint' + Requirement = 'Microsoft Defender for Endpoint licensing; suites and add-ons can satisfy this requirement.' + } + mdca = @{ + DocumentationUrl = 'https://learn.microsoft.com/en-us/defender-cloud-apps/what-is-defender-for-cloud-apps' + Requirement = 'Microsoft Defender for Cloud Apps licensing; suites and add-ons can satisfy this requirement.' + } + } + + # Placeholder for Graph resource IDs, recipient IDs, policy templates, and + # other tenant-specific values. Do not place secrets or tokens here. + Api = @{ + GraphBaseUri = 'https://graph.microsoft.com/v1.0' + GraphScopes = @() + RequiredCommands = @( + 'Connect-MgGraph' + 'Invoke-MgGraphRequest' + 'Get-MgContext' + ) + } + + # Capability records describe what this preview can truthfully connect to. + # They are readiness metadata, not permission to dispatch write operations. + WorkloadCapabilities = @( + @{ + Key = 'graph-tenant-identity' + Workload = 'Microsoft Graph' + Status = 'Verified' + Mode = 'ReadOnly' + Detail = 'Tenant identity preflight through delegated Graph; licensing is documented, not inferred from SKU metadata.' + } + @{ + Key = 'exchange-online' + Workload = 'Exchange Online / EOP' + Status = 'Verified' + Mode = 'ReadOnly' + Detail = 'Delegated EXO connection, organization targeting, and outbound auto-forwarding policy readback are verified; setter RBAC is checked only before an approved write.' + } + @{ + Key = 'information-protection' + Workload = 'Security and Compliance PowerShell' + Status = 'GuidedOnly' + Mode = 'Unavailable' + Detail = 'No IPPS session or policy readback is claimed by this preview.' + } + @{ + Key = 'intune-asr-audit' + Workload = 'Microsoft Intune ASR Audit policy' + Status = 'Verified' + Mode = 'ManagedConfiguration' + Detail = 'ASR Audit policy readback and the explicitly enabled managed apply and recovery paths are verified; tenant capability, permissions, schema, and pilot-group scope are checked before mutation.' + } + @{ + Key = 'defender-portals' + Workload = 'Defender portals and MDCA' + Status = 'GuidedOnly' + Mode = 'Unavailable' + Detail = 'No Defender portal or Cloud Apps connector session is claimed by this preview.' + } + ) + + # Permission requirements are operation-scoped. WriteApply is deliberately + # unverified until each write endpoint has authoritative API, role, license, + # readback, and rollback evidence; it must not be silently consented. + PermissionModel = @{ + Profiles = @{ + ReadOnlyPreflight = @{ + MinimumRoles = @('User') + MinimumGdapRoles = @() + ConsentOwner = 'Tenant administrator or delegated application administrator' + Status = 'Verified' + } + WriteApply = @{ + MinimumRoles = @() + MinimumGdapRoles = @() + ConsentOwner = 'Defined by each explicitly verified write operation' + Status = 'GuidedOnly' + } + } + Operations = @( + @{ + Key = 'tenant-identity' + Module = 'Connect-DefenderServices' + Phase = 'ReadOnlyPreflight' + ReadWrite = 'Read' + Endpoint = '/organization?$select=id,verifiedDomains' + License = 'Microsoft Entra tenant' + # User.Read covers id and verifiedDomains on /organization. + GraphDelegatedScopes = @('User.Read') + GraphApplicationPermissions = @('Organization.Read.All') + MinimumRoles = @('User') + MinimumGdapRoles = @() + VerificationStatus = 'Verified' + Readback = 'Organization response contains tenant id and verified domains.' + Rollback = 'Read-only operation; no rollback required.' + } + @{ + Key = 'mdo-auto-forward-read' + Module = 'Setup-MdoEopBaseline' + Phase = 'ReadOnlyPreflight' + ReadWrite = 'Read' + Endpoint = 'Get-HostedOutboundSpamFilterPolicy -Identity Default; Get-ManagementRole; Get-ManagementRoleAssignment -RoleAssignee' + License = 'Exchange Online Protection workload availability; commercial licensing remains operator-reviewed' + GraphDelegatedScopes = @() + GraphApplicationPermissions = @() + WorkloadDelegatedPermissions = @('Exchange Online delegated session') + WorkloadApplicationPermissions = @('Exchange.ManageAsApp') + MinimumRoles = @('Security Admin or Transport Hygiene through Exchange RBAC') + MinimumGdapRoles = @('Exchange Administrator') + VerificationStatus = 'Verified' + Readback = 'Default outbound spam-filter policy and effective operator RBAC are readable.' + Rollback = 'Read-only operation; no rollback required.' + } + @{ + Key = 'mdo-auto-forward-apply' + Module = 'Setup-MdoEopBaseline' + Phase = 'GuidedOnly' + ReadWrite = 'Write' + Endpoint = 'Set-HostedOutboundSpamFilterPolicy -Identity Default -AutoForwardingMode Off' + License = 'Microsoft Defender for Office 365; see LicenseCapabilities.mdo.DocumentationUrl' + GraphDelegatedScopes = @() + GraphApplicationPermissions = @() + WorkloadDelegatedPermissions = @('Exchange Online delegated session') + WorkloadApplicationPermissions = @('Exchange.ManageAsApp') + MinimumRoles = @('Exchange Administrator (or equivalent custom Exchange RBAC role group)') + MinimumGdapRoles = @('Exchange Administrator') + VerificationStatus = 'GuidedOnly' + Readback = 'Get-HostedOutboundSpamFilterPolicy -Identity Default; verify AutoForwardingMode is Off.' + Rollback = 'Manual Exchange Online policy recovery after operator review.' + } + @{ + Key = 'mdo-safe-attachments-read' + Module = 'Setup-MdoEopBaseline' + Phase = 'ReadOnlyPreflight' + ReadWrite = 'Read' + Endpoint = 'Get-AtpPolicyForO365 -Identity Default; Get-ManagementRole; Get-ManagementRoleAssignment -RoleAssignee' + License = 'Exchange Online Protection workload availability; commercial licensing remains operator-reviewed' + GraphDelegatedScopes = @() + GraphApplicationPermissions = @() + WorkloadDelegatedPermissions = @('Exchange Online delegated session') + WorkloadApplicationPermissions = @('Exchange.ManageAsApp') + MinimumRoles = @('Security Admin or Transport Hygiene through Exchange RBAC') + MinimumGdapRoles = @('Exchange Administrator') + VerificationStatus = 'Verified' + Readback = 'Default ATP policy and effective operator RBAC are readable.' + Rollback = 'Read-only operation; no rollback required.' + } + @{ + Key = 'mdo-safe-attachments-apply' + Module = 'Setup-MdoEopBaseline' + Phase = 'GuidedOnly' + ReadWrite = 'Write' + Endpoint = 'Set-AtpPolicyForO365 -Identity Default -EnableATPForSPOTeamsODB $true' + License = 'Microsoft Defender for Office 365; see LicenseCapabilities.mdo.DocumentationUrl' + GraphDelegatedScopes = @() + GraphApplicationPermissions = @() + WorkloadDelegatedPermissions = @('Exchange Online delegated session') + WorkloadApplicationPermissions = @('Exchange.ManageAsApp') + MinimumRoles = @('Exchange Administrator (or equivalent custom Exchange RBAC role group)') + MinimumGdapRoles = @('Exchange Administrator') + VerificationStatus = 'GuidedOnly' + Readback = 'Get-AtpPolicyForO365 -Identity Default; verify EnableATPForSPOTeamsODB is True.' + Rollback = 'Allow up to 30 minutes for propagation, then restore the captured prior Boolean after operator review if recovery is required.' + } + @{ + Key = 'mdo-quarantine-policy-read' + Module = 'Setup-MdoEopBaseline' + Phase = 'ReadOnlyPreflight' + ReadWrite = 'Read' + Endpoint = 'Get-QuarantinePolicy; filter exact name SMBTool-Quarantine-LimitedAccess; Get-ManagementRole; Get-ManagementRoleAssignment -RoleAssignee' + License = 'Microsoft Defender for Office 365; see LicenseCapabilities.mdo.DocumentationUrl' + GraphDelegatedScopes = @() + GraphApplicationPermissions = @() + WorkloadDelegatedPermissions = @('Exchange Online delegated session') + WorkloadApplicationPermissions = @('Exchange.ManageAsApp') + MinimumRoles = @('Security Administrator or Quarantine role through Exchange RBAC') + MinimumGdapRoles = @('Exchange Administrator') + VerificationStatus = 'Verified' + Readback = 'Get-QuarantinePolicy; filter and verify the exact policy name before accepting the result.' + Rollback = 'Read-only operation; no rollback required.' + } + @{ + Key = 'mdo-quarantine-policy-apply' + Module = 'Setup-MdoEopBaseline' + Phase = 'GuidedOnly' + ReadWrite = 'Write' + Endpoint = 'New-QuarantinePolicy for SMBTool-Quarantine-LimitedAccess' + License = 'Microsoft Defender for Office 365; see LicenseCapabilities.mdo.DocumentationUrl' + GraphDelegatedScopes = @() + GraphApplicationPermissions = @() + WorkloadDelegatedPermissions = @('Exchange Online delegated session') + WorkloadApplicationPermissions = @('Exchange.ManageAsApp') + MinimumRoles = @('Exchange Administrator or equivalent custom Exchange RBAC role') + MinimumGdapRoles = @('Exchange Administrator') + VerificationStatus = 'GuidedOnly' + Readback = 'Get-QuarantinePolicy; filter the exact policy name and verify permissions 43, notifications enabled, and blocked-sender messages excluded.' + Rollback = 'Leave the newly created, unassigned policy in place pending an explicit removal decision. Existing policies are never updated.' + } + @{ + Key = 'mde-asr-audit-read' + Module = 'Setup-DefenderForBusiness' + Phase = 'ReadOnlyPreflight' + ReadWrite = 'Read' + Endpoint = 'GET /deviceManagement/configurationPolicies, /configurationPolicyTemplates, and linked configurationSettings (Microsoft Graph beta)' + License = 'Microsoft Intune and Microsoft Defender for Endpoint; see LicenseCapabilities.mde.DocumentationUrl' + GraphDelegatedScopes = @('DeviceManagementConfiguration.Read.All') + GraphApplicationPermissions = @('DeviceManagementConfiguration.Read.All') + MinimumRoles = @('Endpoint Security Manager') + MinimumGdapRoles = @('Intune Administrator') + VerificationStatus = 'Verified' + Readback = 'One active endpointSecurityAttackSurfaceReduction template exposes five parent settings and 19 child rule definitions; existing policy state is readable.' + Rollback = 'Read-only operation; no rollback required.' + } + @{ + Key = 'mde-asr-audit-apply' + Module = 'Setup-DefenderForBusiness' + Phase = 'WriteApply' + ReadWrite = 'Write' + Endpoint = 'POST /deviceManagement/configurationPolicies and POST /deviceManagement/configurationPolicies/{id}/assign (Microsoft Graph beta)' + License = 'Microsoft Intune and Microsoft Defender for Endpoint; see LicenseCapabilities.mde.DocumentationUrl' + GraphDelegatedScopes = @('DeviceManagementConfiguration.ReadWrite.All', 'Group.Read.All') + GraphApplicationPermissions = @('DeviceManagementConfiguration.ReadWrite.All', 'Group.Read.All') + MinimumRoles = @('Endpoint Security Manager') + MinimumGdapRoles = @('Intune Administrator') + VerificationStatus = 'Verified' + Readback = 'GET the captured policy, settings, and assignments; verify the exact ASR Audit payload and one unfiltered direct pilot-group target.' + Rollback = 'Retain the captured policy ID and use separately approved pilot-owned policy recovery after exact ownership and assignment-isolation readback.' + } + @{ + Key = 'mde-asr-audit-recovery' + Module = 'Setup-DefenderForBusiness' + Phase = 'WriteApply' + ReadWrite = 'Write' + Endpoint = 'DELETE /deviceManagement/configurationPolicies/{id} (Microsoft Graph beta)' + License = 'Microsoft Intune and Microsoft Defender for Endpoint; see LicenseCapabilities.mde.DocumentationUrl' + GraphDelegatedScopes = @('DeviceManagementConfiguration.ReadWrite.All', 'Group.Read.All') + GraphApplicationPermissions = @('DeviceManagementConfiguration.ReadWrite.All', 'Group.Read.All') + MinimumRoles = @('Endpoint Security Manager') + MinimumGdapRoles = @('Intune Administrator') + VerificationStatus = 'Verified' + Readback = 'GET /deviceManagement/configurationPolicies; verify the captured pilot-owned policy ID is absent.' + Rollback = 'Recovery is the deletion; never recreate or modify a pre-existing customer-managed policy.' + } + ) + } + + # These checks are deliberately explicit where Microsoft does not expose a + # stable, least-privilege read API suitable for unattended verification. + Preflight = @{ + AuditReadinessDisposition = 'GuidedOnly' + EmergencyAccessDisposition = 'GuidedOnly' + } + + Report = @{ + OutputDirectory = '.\Reports' + JsonLogFileName = 'defender-run-log.json' + HtmlReportFileName = 'defender-run-report.html' + } +} diff --git a/Products/Defender/Deploy-DefenderBestPractice.ps1 b/Products/Defender/Deploy-DefenderBestPractice.ps1 new file mode 100644 index 0000000..8ab85b5 --- /dev/null +++ b/Products/Defender/Deploy-DefenderBestPractice.ps1 @@ -0,0 +1,382 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Scaffolding entry point for the Microsoft Defender Best Practice Toolkit. + +.DESCRIPTION + Applies verified Defender recommendations using Purview-compatible + orchestration semantics. The scaffold is intentionally read-only until + each BP item has a verified supported API, permission model, license gate, + readback projection, and rollback decision. + +.PARAMETER EnableAsrAuditPolicy + Explicitly creates or reuses the toolkit-owned Intune endpoint security + ASR policy in Audit mode and assigns it only to PilotGroupId. The default + path is read-only. + +.PARAMETER RecoverAsrAuditPolicy + Permanently deletes the captured toolkit-owned ASR Audit policy after + exact ownership and isolated-assignment readback. Requires + RecoveryPolicyId, PilotGroupId, and RollbackAcknowledged. This operation + cannot be undone; recreate the policy with EnableAsrAuditPolicy if needed. + API authority: https://learn.microsoft.com/graph/api/intune-deviceconfigv2-devicemanagementconfigurationpolicy-delete?view=graph-rest-beta + +.PARAMETER RecoveryPolicyId + Captured Intune configuration policy GUID to delete during separately + approved ASR recovery. The identifier must resolve to the exact + toolkit-owned policy and approved pilot-group assignment. + +.PARAMETER RollbackAcknowledged + Acknowledges that a selected recovery operation is irreversible. ASR + recovery permanently deletes the verified toolkit-owned policy and also + requires RecoverAsrAuditPolicy, RecoveryPolicyId, PilotGroupId, exact + ownership readback, and assignment-isolation readback. This switch alone + does not authorize deletion. + +.PARAMETER EnableAsrBlockMode + Reserved safety gate that also requires IncludeHighRisk. ASR Block mode + is not implemented, so satisfying the high-risk gate still stops the run + without changing tenant state. +#> + +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +param( + [Parameter(Mandatory)] [ValidatePattern('^[^@\s]+@[^@\s]+\.[^@\s]+$')] [string] $TenantAdminUpn, + [ValidatePattern('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$')] [string] $TenantId, + [ValidatePattern('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$')] [string] $ClientId, + [ValidatePattern('^[0-9a-fA-F]{40}$')] [string] $CertificateThumbprint, + [ValidatePattern('^[^@\s]+\.[^@\s]+$')] [string] $DelegatedOrganization, + [string] $ConfigPath, + [switch] $NonInteractive, + [switch] $UseDeviceAuthentication, + [switch] $AutoInstallModules, + [switch] $AdoptExisting, + [switch] $SkipPreflight, + [switch] $SkipMdoEop, + [switch] $SkipDefenderForBusiness, + [switch] $SkipMdeAdvanced, + [switch] $SkipDefenderForCloudApps, + [switch] $IncludeHighRisk, + [switch] $RollbackAcknowledged, + [switch] $EnableMailFlowImpactingChanges, + [switch] $EnableAutomatedRemediation, + [switch] $EnableAsrAuditPolicy, + [switch] $RecoverAsrAuditPolicy, + [switch] $EnableAsrBlockMode, + [switch] $EnableMdcaEnforcement, + [switch] $CloudDiscoveryReviewed, + [string] $PilotGroupId, + [string] $RecoveryPolicyId +) + +$ErrorActionPreference = 'Stop' +$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$moduleRoot = Join-Path $scriptRoot 'Modules' +$runId = [guid]::NewGuid() +$startTime = [datetime]::UtcNow +$jsonPath = $null +$htmlPath = $null + +. (Join-Path $moduleRoot 'DefenderRunLog.ps1') +. (Join-Path $moduleRoot 'Invoke-WithTransientRetry.ps1') +. (Join-Path $moduleRoot 'Get-DefenderPermissionPlan.ps1') +. (Join-Path $moduleRoot 'DefenderSafetyGates.ps1') +. (Join-Path $moduleRoot 'DefenderOrchestrationHelpers.ps1') +. (Join-Path $moduleRoot 'Write-DefenderHtmlReport.ps1') + +function Test-DefenderConfigContract { + [CmdletBinding()] + param([Parameter(Mandatory)] [hashtable] $Config) + + Assert-DefenderPermissionManifest -Config $Config + if (-not $Config.BestPracticeItems) { + throw 'Defender configuration must define at least one BestPracticeItems entry.' + } + $keys = @() + foreach ($item in @($Config.BestPracticeItems)) { + foreach ($property in @('Key', 'Name', 'Module', 'Risk', 'CapabilityKey')) { + if ([string]::IsNullOrWhiteSpace([string] $item[$property])) { + throw "BestPracticeItems entries must define '$property'." + } + } + if (@($Config.WorkloadCapabilities | Where-Object { + $_.Key -eq $item.CapabilityKey + }).Count -eq 0) { + throw "BestPracticeItems entry '$($item.Key)' must reference a configured WorkloadCapability." + } + if ([string]::IsNullOrWhiteSpace([string] $item.LicenseCapability) -or + -not $Config.LicenseCapabilities.ContainsKey([string] $item.LicenseCapability)) { + throw "BestPracticeItems entry '$($item.Key)' must reference a configured LicenseCapability." + } + if ($item.Key -notmatch '^[a-z0-9]+(?:-[a-z0-9]+)+$') { + throw "BestPracticeItems key '$($item.Key)' must be lowercase kebab-case." + } + if ($keys -contains $item.Key) { + throw "Duplicate BestPracticeItems key '$($item.Key)'." + } + if ($null -eq $item.PermissionOperations) { + throw "BestPracticeItems entry '$($item.Key)' must declare PermissionOperations." + } + $keys += $item.Key + } + if (-not $Config.WorkloadCapabilities -or @($Config.WorkloadCapabilities).Count -eq 0) { + throw 'Defender configuration must declare workload capability records.' + } + foreach ($capability in @($Config.WorkloadCapabilities)) { + foreach ($property in @('Key', 'Workload', 'Status', 'Mode', 'Detail')) { + if ([string]::IsNullOrWhiteSpace([string] $capability[$property])) { + throw "Workload capability entries must define '$property'." + } + } + } + $quarantinePolicy = $Config.DefenderForOffice365.QuarantinePolicy + if ($null -ne $quarantinePolicy) { + foreach ($property in @('Key', 'Name')) { + if ([string]::IsNullOrWhiteSpace([string] $quarantinePolicy[$property])) { + throw "DefenderForOffice365.QuarantinePolicy must define '$property'." + } + } + foreach ($property in @( + 'EndUserQuarantinePermissionsValue' + 'ESNEnabled' + 'IncludeMessagesFromBlockedSenderAddress' + 'ProtectionPolicyAssignments')) { + if (-not $quarantinePolicy.ContainsKey($property)) { + throw "DefenderForOffice365.QuarantinePolicy must define '$property'." + } + } + if (@($quarantinePolicy.ProtectionPolicyAssignments).Count -ne 0) { + throw 'DefenderForOffice365.QuarantinePolicy.ProtectionPolicyAssignments must remain empty; protection-policy assignment is out of scope.' + } + if ($null -eq $Config.StateManagement.ObjectDefinitions.QuarantinePolicy) { + throw 'DefenderForOffice365.QuarantinePolicy requires StateManagement.ObjectDefinitions.QuarantinePolicy.' + } + } +} + +try { + # TenantId alone is valid for delegated sign-in; certificate mode requires + # the client ID and thumbprint in addition to the tenant. + $certificateArguments = @($ClientId, $CertificateThumbprint) | + ForEach-Object { -not [string]::IsNullOrWhiteSpace($_) } + if (($certificateArguments | Where-Object { $_ }).Count -gt 0 -and + ($certificateArguments | Where-Object { -not $_ }).Count -gt 0) { + throw 'TenantId, ClientId, and CertificateThumbprint must be supplied together for certificate authentication.' + } + + if (-not $ConfigPath) { + $ConfigPath = Join-Path $scriptRoot 'Config\DefenderConfig.psd1' + } + if (-not (Test-Path -LiteralPath $ConfigPath)) { + throw "Config file not found: $ConfigPath" + } + + $config = Import-PowerShellDataFile -Path $ConfigPath + Test-DefenderConfigContract -Config $config + $reportDirectory = Join-Path $scriptRoot $config.Report.OutputDirectory + $jsonPath = Join-Path $reportDirectory $config.Report.JsonLogFileName + $htmlPath = Join-Path $reportDirectory $config.Report.HtmlReportFileName + Initialize-DefenderRunLog -JsonPath $jsonPath -RunId $runId ` + -StartTime $startTime -ScriptVersion $config.ProductVersion ` + -TenantId $TenantId -TenantAdminUpn $TenantAdminUpn + + Add-DefenderRunLogEntry -Module 'Deploy-DefenderBestPractice' ` + -Action 'Run' -Status 'Started' -Detail 'Defender scaffold run started.' + if ($WhatIfPreference) { + Add-DefenderRunLogEntry -Module 'Deploy-DefenderBestPractice' ` + -Action 'WhatIf' -Status 'Info' ` + -Detail 'WhatIf preview enabled; child modules may not change tenant state.' + } + + # Safety gates run after run-log initialization so a gate-blocked invocation + # still produces structured evidence (a 'Failed' entry via the outer catch) + # instead of exiting with no report at all. + try { + Assert-DefenderSafetyGates -IncludeHighRisk:$IncludeHighRisk ` + -RollbackAcknowledged:$RollbackAcknowledged ` + -EnableMailFlowImpactingChanges:$EnableMailFlowImpactingChanges ` + -EnableAutomatedRemediation:$EnableAutomatedRemediation ` + -EnableAsrBlockMode:$EnableAsrBlockMode ` + -EnableMdcaEnforcement:$EnableMdcaEnforcement | Out-Null + } + catch { + Add-DefenderRunLogEntry -Module 'Deploy-DefenderBestPractice' ` + -Action 'SafetyGate' -Status 'Failed' -Disposition 'Blocked' ` + -Detail $_.Exception.Message + throw + } + + # Validate complete ASR write intent before permission planning or service + # connection. This prevents an invalid invocation from requesting consent. + try { + if ($EnableAsrAuditPolicy -and $RecoverAsrAuditPolicy) { + throw 'EnableAsrAuditPolicy and RecoverAsrAuditPolicy cannot be used together.' + } + if ($EnableAsrAuditPolicy -or $RecoverAsrAuditPolicy) { + $pilotGroupGuid = [guid]::Empty + if ([string]::IsNullOrWhiteSpace($PilotGroupId) -or + -not [guid]::TryParse($PilotGroupId, [ref] $pilotGroupGuid)) { + throw 'ASR apply and recovery operations require PilotGroupId as a valid group GUID.' + } + } + if ($RecoverAsrAuditPolicy) { + if (-not $RollbackAcknowledged) { + throw 'RecoverAsrAuditPolicy requires RollbackAcknowledged.' + } + $recoveryPolicyGuid = [guid]::Empty + if ([string]::IsNullOrWhiteSpace($RecoveryPolicyId) -or + -not [guid]::TryParse($RecoveryPolicyId, [ref] $recoveryPolicyGuid)) { + throw 'RecoverAsrAuditPolicy requires RecoveryPolicyId as the captured policy GUID.' + } + } + } + catch { + Add-DefenderRunLogEntry -Module 'Deploy-DefenderBestPractice' ` + -Action 'AsrIntentGuard' -Status 'Failed' -Disposition 'Blocked' ` + -Detail $_.Exception.Message + throw + } + + $moduleSkipped = @{ + 'Setup-DefenderPreflight' = $SkipPreflight + 'Setup-MdoEopBaseline' = $SkipMdoEop + 'Setup-DefenderForBusiness' = $SkipDefenderForBusiness + 'Setup-MdeAdvanced' = $SkipMdeAdvanced + 'Setup-DefenderForCloudApps' = $SkipDefenderForCloudApps + } + $permissionOperations = @('tenant-identity') + foreach ($item in @($config.BestPracticeItems)) { + if ($moduleSkipped.ContainsKey($item.Module) -and -not $moduleSkipped[$item.Module]) { + $permissionOperations += @($item.PermissionOperations) + } + } + if ($EnableAsrAuditPolicy -and -not $SkipDefenderForBusiness) { + $permissionOperations += 'mde-asr-audit-apply' + } + if ($RecoverAsrAuditPolicy -and -not $SkipDefenderForBusiness) { + $permissionOperations += 'mde-asr-audit-recovery' + } + $permissionOperations = @($permissionOperations | Sort-Object -Unique) + $permissionPlan = Get-DefenderPermissionPlan -Config $config ` + -OperationKeys $permissionOperations + if ($permissionPlan.Status -ne 'Ready') { + throw "Defender permission plan is not ready for dispatch: $($permissionPlan.Status)." + } + Add-DefenderRunLogEntry -Module 'Deploy-DefenderBestPractice' ` + -Action 'PermissionPlan' -Status 'Info' ` + -Detail "Permission plan ready. Operations=$($permissionPlan.Operations.Key -join ','); GraphScopes=$($permissionPlan.GraphDelegatedScopes -join ','); GraphApplicationPermissions=$($permissionPlan.GraphApplicationPermissions -join ',')." + + $connectScript = Join-Path $moduleRoot 'Connect-DefenderServices.ps1' + $connectArgs = @{ + TenantAdminUpn = $TenantAdminUpn + TenantId = $TenantId + ClientId = $ClientId + CertificateThumbprint = $CertificateThumbprint + DelegatedOrganization = $DelegatedOrganization + AutoInstallModules = $AutoInstallModules + NonInteractive = $NonInteractive + UseDeviceAuthentication = $UseDeviceAuthentication + ConnectGraph = $permissionPlan.GraphDelegatedScopes.Count -gt 0 + GraphScopes = $permissionPlan.GraphDelegatedScopes + } + $connectionInfo = & $connectScript @connectArgs + + $commonContext = @{ + TenantAdminUpn = $TenantAdminUpn + TenantId = $TenantId + ConnectionInfo = $connectionInfo + ClientId = $ClientId + CertificateThumbprint = $CertificateThumbprint + DelegatedOrganization = $DelegatedOrganization + IncludeHighRisk = [bool] $IncludeHighRisk + RollbackAcknowledged = [bool] $RollbackAcknowledged + WhatIf = [bool] $WhatIfPreference + NonInteractive = [bool] $NonInteractive + UseDeviceAuthentication = [bool] $UseDeviceAuthentication + PermissionPlan = $permissionPlan + } + $tasks = @( + @{ Name = 'Setup-DefenderPreflight'; SkipSwitch = '-SkipPreflight'; Script = 'Setup-DefenderPreflight.ps1'; Skip = $SkipPreflight; Args = @{ Config = $config; Context = $commonContext; TenantAdminUpn = $TenantAdminUpn } } + @{ Name = 'Setup-MdoEopBaseline'; SkipSwitch = '-SkipMdoEop'; Script = 'Setup-MdoEopBaseline.ps1'; Skip = $SkipMdoEop; Args = @{ Config = $config; Context = $commonContext; AdoptExisting = $AdoptExisting; IncludeHighRisk = $IncludeHighRisk; EnableMailFlowImpactingChanges = $EnableMailFlowImpactingChanges; RollbackAcknowledged = $RollbackAcknowledged } } + @{ Name = 'Setup-DefenderForBusiness'; SkipSwitch = '-SkipDefenderForBusiness'; Script = 'Setup-DefenderForBusiness.ps1'; Skip = $SkipDefenderForBusiness; Args = @{ Config = $config; Context = $commonContext; PilotGroupId = $PilotGroupId; RecoveryPolicyId = $RecoveryPolicyId; EnableAsrAuditPolicy = $EnableAsrAuditPolicy; RecoverAsrAuditPolicy = $RecoverAsrAuditPolicy; RollbackAcknowledged = $RollbackAcknowledged; EnableAsrBlockMode = $EnableAsrBlockMode } } + @{ Name = 'Setup-MdeAdvanced'; SkipSwitch = '-SkipMdeAdvanced'; Script = 'Setup-MdeAdvanced.ps1'; Skip = $SkipMdeAdvanced; Args = @{ Config = $config; Context = $commonContext; IncludeHighRisk = $IncludeHighRisk; EnableAutomatedRemediation = $EnableAutomatedRemediation } } + @{ Name = 'Setup-DefenderForCloudApps'; SkipSwitch = '-SkipDefenderForCloudApps'; Script = 'Setup-DefenderForCloudApps.ps1'; Skip = $SkipDefenderForCloudApps; Args = @{ Config = $config; Context = $commonContext; IncludeHighRisk = $IncludeHighRisk; EnableMdcaEnforcement = $EnableMdcaEnforcement; CloudDiscoveryReviewed = $CloudDiscoveryReviewed } } + ) + + foreach ($task in $tasks) { + if ($task.Skip) { + Add-DefenderRunLogEntry -Module $task.Name -Action 'Module' ` + -Status 'Skipped' -Disposition 'Skipped' ` + -Detail (Get-DefenderContinuationCommand ` + -Reason 'the module was intentionally skipped' ` + -SwitchToRemove $task.SkipSwitch) + continue + } + + $taskPath = Join-Path $moduleRoot $task.Script + Add-DefenderRunLogEntry -Module $task.Name -Action 'Module' ` + -Status 'Started' -Detail 'Module process started.' + $taskArgs = $task.Args + if ($WhatIfPreference) { + $taskArgs['WhatIf'] = $true + } + try { + Invoke-DefenderModuleProcess -ScriptPath $taskPath -Arguments $taskArgs + Add-DefenderRunLogEntry -Module $task.Name -Action 'Module' ` + -Status 'Succeeded' -Detail 'Module process completed.' + } + catch { + $status = Get-DefenderHttpStatusCode -ErrorRecord $_ + Add-DefenderRunLogEntry -Module $task.Name -Action 'Module' ` + -Status 'Failed' -HttpStatusCode $status ` + -Detail $_.Exception.Message + throw + } + } + + $terminalOutcomeStatuses = @('Succeeded', 'Created', 'Updated', 'Adopted', 'Skipped', 'Failed') + foreach ($item in @($config.BestPracticeItems)) { + $itemEntries = @(Get-DefenderRunLog | Where-Object { + $_.BestPracticeKey -eq $item.Key -and + $_.Action -ne 'ConfiguredItem' -and + $_.Status -in $terminalOutcomeStatuses + }) + if ($itemEntries.Count -gt 0) { + $outcome = $itemEntries[-1] + $status = [string] $outcome.Status + $disposition = [string] $outcome.Disposition + $detail = "Recommendation=$($item.Name); ActualOutcome=$status/$disposition." + } + else { + $status = 'Skipped' + $disposition = if ($moduleSkipped[$item.Module]) { 'Skipped' } else { 'GuidedOnly' } + $detail = "Recommendation=$($item.Name); no operation-specific outcome was recorded." + } + Add-DefenderRunLogEntry -Module $item.Module -Action 'ConfiguredItem' ` + -BestPracticeKey $item.Key -FriendlyName $item.Name ` + -Status $status -Disposition $disposition -Detail $detail + } + + Add-DefenderRunLogEntry -Module 'Deploy-DefenderBestPractice' ` + -Action 'Run' -Status 'Succeeded' -Detail 'Defender scaffold run completed.' +} +catch { + $status = Get-DefenderHttpStatusCode -ErrorRecord $_ + if ($global:DefenderRunLogPath) { + Add-DefenderRunLogEntry -Module 'Deploy-DefenderBestPractice' ` + -Action 'Run' -Status 'Failed' -HttpStatusCode $status ` + -Detail $_.Exception.Message + } + throw +} +finally { + if ($global:DefenderRunLog) { + $endTime = [datetime]::UtcNow + Save-DefenderRunLogJson -EndTime $endTime + Write-DefenderHtmlReport -Path $htmlPath -Entries (Get-DefenderRunLog) ` + -RunId $runId -StartTime $startTime -EndTime $endTime ` + -TenantId $TenantId -TenantAdminUpn $TenantAdminUpn ` + -ScriptVersion $config.ProductVersion + Clear-DefenderRunLog + } +} diff --git a/Products/Defender/Modules/Assert-DefenderExchangeParameterRbac.ps1 b/Products/Defender/Modules/Assert-DefenderExchangeParameterRbac.ps1 new file mode 100644 index 0000000..a071ded --- /dev/null +++ b/Products/Defender/Modules/Assert-DefenderExchangeParameterRbac.ps1 @@ -0,0 +1,28 @@ +#requires -Version 7.0 + +function Assert-DefenderExchangeParameterRbac { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $RoleAssignee, + [Parameter(Mandatory)] [string] $Command, + [Parameter(Mandatory)] [string] $Parameter, + [AllowEmptyCollection()] [object[]] $RoleAssignments + ) + + $roles = @(Get-ManagementRole -Cmdlet $Command ` + -CmdletParameters $Parameter -ErrorAction Stop | + Select-Object -ExpandProperty Name) + $candidateAssignments = if ($PSBoundParameters.ContainsKey('RoleAssignments')) { + @($RoleAssignments) + } + else { + @(Get-ManagementRoleAssignment -RoleAssignee $RoleAssignee ` + -Delegating:$false -ErrorAction Stop) + } + $assignments = @($candidateAssignments | + Where-Object { $_.Enabled -and $_.Role -in $roles }) + if ($roles.Count -eq 0 -or $assignments.Count -eq 0) { + throw "The connected operator does not have effective Exchange RBAC for $Command -$Parameter." + } + return $assignments +} \ No newline at end of file diff --git a/Products/Defender/Modules/Connect-DefenderServices.ps1 b/Products/Defender/Modules/Connect-DefenderServices.ps1 new file mode 100644 index 0000000..b0d2ba4 --- /dev/null +++ b/Products/Defender/Modules/Connect-DefenderServices.ps1 @@ -0,0 +1,358 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Establishes supported Defender service connections and verifies tenant scope. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $TenantAdminUpn, + [string] $TenantId, + [string] $ClientId, + [string] $CertificateThumbprint, + [string] $DelegatedOrganization, + [switch] $AutoInstallModules, + [switch] $NonInteractive, + [switch] $UseDeviceAuthentication, + [switch] $ConnectGraph, + [string[]] $GraphScopes +) + +. (Join-Path $PSScriptRoot 'DefenderRunLog.ps1') +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') +. (Join-Path $PSScriptRoot 'Get-DefenderPermissionPlan.ps1') + +function Assert-DefenderCertificateArguments { + [CmdletBinding()] + param( + [string] $TenantId, + [string] $ClientId, + [string] $CertificateThumbprint + ) + + $certificateValues = @($ClientId, $CertificateThumbprint) + $provided = @($certificateValues | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }).Count + if ($provided -gt 0 -and + ([string]::IsNullOrWhiteSpace($TenantId) -or + [string]::IsNullOrWhiteSpace($ClientId) -or + [string]::IsNullOrWhiteSpace($CertificateThumbprint))) { + throw 'TenantId, ClientId, and CertificateThumbprint must be supplied together for certificate authentication.' + } +} + +function Get-DefenderExchangeConnectionParameters { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $TenantAdminUpn, + [string] $DelegatedOrganization, + [switch] $UseDeviceAuthentication + ) + + $parameters = @{ + UserPrincipalName = $TenantAdminUpn + ShowBanner = $false + ErrorAction = 'Stop' + } + if (-not [string]::IsNullOrWhiteSpace($DelegatedOrganization)) { + $parameters.DelegatedOrganization = $DelegatedOrganization + } + if ($UseDeviceAuthentication) { + $parameters.Device = $true + } + return $parameters +} + +function Assert-DefenderExchangeOrganization { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $ExpectedDomain, + [Parameter(Mandatory)] [object] $Organization + ) + + $organizationName = if ($Organization.PSObject.Properties.Name -contains 'DomainName') { + [string]$Organization.DomainName + } + else { + [string]$Organization.Name + } + if ([string]::IsNullOrWhiteSpace($organizationName) -or + $organizationName -ine $ExpectedDomain) { + throw "Connected Exchange Online organization '$organizationName' does not match expected domain '$ExpectedDomain'." + } +} + +function Connect-DefenderExchangeOnline { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $TenantAdminUpn, + [string] $DelegatedOrganization, + [switch] $NonInteractive, + [switch] $UseDeviceAuthentication + ) + + if ($NonInteractive) { + throw 'NonInteractive Exchange Online authentication is blocked until app-only authorization evidence is approved.' + } + + try { + $parameters = Get-DefenderExchangeConnectionParameters ` + -TenantAdminUpn $TenantAdminUpn ` + -DelegatedOrganization $DelegatedOrganization ` + -UseDeviceAuthentication:$UseDeviceAuthentication + Connect-ExchangeOnline @parameters + + $connection = Get-ConnectionInformation -ErrorAction Stop | + Where-Object { $_.State -eq 'Connected' } | + Select-Object -First 1 + if (-not $connection) { + throw 'Exchange Online connection information did not report a connected session.' + } + + $expectedDomain = if ($DelegatedOrganization) { + $DelegatedOrganization + } + elseif ($TenantAdminUpn -match '@(?[^@]+)$') { + $Matches.domain + } + else { + throw 'Unable to derive an expected Exchange Online organization from the supplied identity.' + } + Get-OrganizationConfig -ErrorAction Stop | Out-Null + $organization = Get-AcceptedDomain -Identity $expectedDomain -ErrorAction Stop + Assert-DefenderExchangeOrganization -ExpectedDomain $expectedDomain ` + -Organization $organization + + Add-DefenderRunLogEntry -Module 'Connect-DefenderServices' ` + -Action 'Connect-ExchangeOnline' -Status 'Succeeded' ` + -Detail 'Exchange Online connection and organization verified.' + return $connection + } + catch { + $status = Get-DefenderHttpStatusCode -ErrorRecord $_ + Add-DefenderRunLogEntry -Module 'Connect-DefenderServices' ` + -Action 'Connect-ExchangeOnline' -Status 'Failed' ` + -HttpStatusCode $status -Detail $_.Exception.Message + throw + } +} + +function Assert-DefenderGraphAccount { + [CmdletBinding()] + param( + [AllowNull()] $GraphContext, + [Parameter(Mandatory)] [string] $TenantAdminUpn + ) + + if (-not $GraphContext -or $GraphContext.AuthType -ne 'Delegated' -or + [string]::IsNullOrWhiteSpace([string] $GraphContext.Account) -or + $GraphContext.Account -ine $TenantAdminUpn) { + throw 'Microsoft Graph is not authenticated as the requested delegated operator. Reconnect with TenantAdminUpn before continuing.' + } +} + +function Connect-DefenderGraph { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string[]] $Scopes, + [Parameter(Mandatory)] [string] $ExpectedDomain, + [Parameter(Mandatory)] [string] $TenantAdminUpn, + [string] $TenantId, + [string] $ClientId, + [string] $CertificateThumbprint, + [switch] $NonInteractive, + [switch] $UseDeviceAuthentication + ) + + try { + Assert-DefenderCertificateArguments -TenantId $TenantId -ClientId $ClientId ` + -CertificateThumbprint $CertificateThumbprint + if (-not $Scopes -or $Scopes.Count -eq 0) { + throw 'Microsoft Graph scopes must be supplied by the operation-scoped Defender permission plan.' + } + if ($NonInteractive -and + ([string]::IsNullOrWhiteSpace($TenantId) -or + [string]::IsNullOrWhiteSpace($ClientId) -or + [string]::IsNullOrWhiteSpace($CertificateThumbprint))) { + throw 'NonInteractive Graph authentication requires TenantId, ClientId, and CertificateThumbprint; delegated sign-in cannot prompt.' + } + + if ($ClientId -and $CertificateThumbprint -and $TenantId) { + throw 'App-only Microsoft Graph authorization is not enabled for the Defender least-privilege consent model; certificate-based runs are blocked before connection until operation-level application permission evidence is approved.' + } + $existingContext = Get-MgContext -ErrorAction SilentlyContinue + $contextAccountMatches = $existingContext -and + $existingContext.AuthType -eq 'Delegated' -and + $existingContext.Account -ieq $TenantAdminUpn + $contextTenantMatches = [string]::IsNullOrWhiteSpace($TenantId) -or + ($existingContext -and $existingContext.TenantId -eq $TenantId) + $missingScopes = @($Scopes | Where-Object { + -not $existingContext -or @($existingContext.Scopes) -notcontains $_ + }) + if ($contextAccountMatches -and $contextTenantMatches -and $missingScopes.Count -eq 0) { + $response = Invoke-WithTransientRetry -Description 'Verify cached Defender tenant' -Action { + Invoke-DefenderGraphRequest -Method GET ` + -Uri 'https://graph.microsoft.com/v1.0/organization?$select=id,verifiedDomains' + } + $organizations = @($response.value) + $contextTenantMatches = $organizations.Count -eq 1 -and + -not [string]::IsNullOrWhiteSpace([string] $existingContext.TenantId) -and + $organizations[0].id -eq $existingContext.TenantId -and + @($organizations[0].verifiedDomains | ForEach-Object name) -icontains $ExpectedDomain + } + $reuseContext = $contextAccountMatches -and $contextTenantMatches -and $missingScopes.Count -eq 0 + if (-not $reuseContext) { + if ($existingContext) { + Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null + } + $connectParameters = @{ + Scopes = $Scopes + ContextScope = 'Process' + NoWelcome = $true + ErrorAction = 'Stop' + } + $connectParameters.TenantId = if (-not [string]::IsNullOrWhiteSpace($TenantId)) { + $TenantId + } else { $ExpectedDomain } + if ($UseDeviceAuthentication) { + $connectParameters.UseDeviceAuthentication = $true + } + Connect-MgGraph @connectParameters | Out-Null + } + $verifiedContext = Get-MgContext -ErrorAction Stop + Assert-DefenderGraphAccount -GraphContext $verifiedContext -TenantAdminUpn $TenantAdminUpn + if (-not [string]::IsNullOrWhiteSpace($TenantId) -and $verifiedContext.TenantId -ine $TenantId) { + throw 'Microsoft Graph did not connect to the explicitly requested tenant ID.' + } + Add-DefenderRunLogEntry -Module 'Connect-DefenderServices' ` + -Action 'Connect-MgGraph' -Status 'Succeeded' ` + -Detail $(if ($reuseContext) { 'Verified Microsoft Graph context reused for the requested operator and tenant.' } + else { 'Microsoft Graph connection established for the requested operator.' }) + } + catch { + $status = Get-DefenderHttpStatusCode -ErrorRecord $_ + Add-DefenderRunLogEntry -Module 'Connect-DefenderServices' ` + -Action 'Connect-MgGraph' -Status 'Failed' -HttpStatusCode $status ` + -Detail $_.Exception.Message + throw + } +} + +function Invoke-DefenderGraphRequest { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [ValidateSet('GET','POST','PATCH','PUT','DELETE')] + [string] $Method, + [Parameter(Mandatory)] [string] $Uri, + [System.Collections.IDictionary] $Body + ) + + try { + $request = @{ + Method = $Method + Uri = $Uri + ErrorAction = 'Stop' + } + if ($Body) { + # Graph payloads are JSON objects whose property names and nesting + # must match the resource schema documented for the target API. + $request.Body = $Body | ConvertTo-Json -Depth 20 + $request.ContentType = 'application/json' + } + return Invoke-MgGraphRequest @request + } + catch { + $status = Get-DefenderHttpStatusCode -ErrorRecord $_ + Add-DefenderRunLogEntry -Module 'Connect-DefenderServices' ` + -Action 'Invoke-MgGraphRequest' -Status 'Failed' ` + -Target $Uri -HttpStatusCode $status -Detail $_.Exception.Message + throw + } +} + +function Get-DefenderTenantIdentity { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $ExpectedDomain, + [Parameter(Mandatory)] [string] $TenantAdminUpn + ) + + try { + $response = Invoke-DefenderGraphRequest -Method 'GET' ` + -Uri 'https://graph.microsoft.com/v1.0/organization?$select=id,verifiedDomains' + $organization = @($response.value)[0] + if ($null -eq $organization -or $null -eq $organization.verifiedDomains) { + throw 'Microsoft Graph organization response is missing verifiedDomains.' + } + $domains = @($organization.verifiedDomains | ForEach-Object { $_.name.ToLowerInvariant() }) + $expected = $ExpectedDomain.ToLowerInvariant() + if ($domains -notcontains $expected) { + throw "Connected tenant does not contain expected domain '$ExpectedDomain'." + } + + Add-DefenderRunLogEntry -Module 'Connect-DefenderServices' ` + -Action 'TenantIdentity' -Status 'Succeeded' ` + -Detail 'Tenant identity verified against the connected organization.' + return [pscustomobject]@{ + TenantId = $organization.id + ExpectedDomain = $ExpectedDomain + VerifiedDomains = $domains + TenantAdminUpn = $TenantAdminUpn + } + } + catch { + $status = Get-DefenderHttpStatusCode -ErrorRecord $_ + Add-DefenderRunLogEntry -Module 'Connect-DefenderServices' ` + -Action 'TenantIdentity' -Status 'Failed' ` + -HttpStatusCode $status -Detail $_.Exception.Message + throw + } +} + +$tenantIdentity = $null +if ($ConnectGraph) { + $expectedDomain = if ($DelegatedOrganization) { + $DelegatedOrganization + } + elseif ($TenantAdminUpn -match '@(?[^@]+)$') { + $Matches.domain + } + else { + throw 'Unable to derive an expected tenant domain from the supplied identity.' + } + Connect-DefenderGraph -Scopes $GraphScopes -TenantId $TenantId -ExpectedDomain $expectedDomain ` + -TenantAdminUpn $TenantAdminUpn ` + -ClientId $ClientId -CertificateThumbprint $CertificateThumbprint ` + -NonInteractive:$NonInteractive ` + -UseDeviceAuthentication:$UseDeviceAuthentication + $graphContext = Get-MgContext + if (-not $graphContext) { + throw 'Microsoft Graph context is not available after connection.' + } + try { + Assert-DefenderGraphAccount -GraphContext $graphContext -TenantAdminUpn $TenantAdminUpn + $consent = Test-DefenderGraphConsent -GraphContext $graphContext ` + -RequiredDelegatedScopes $GraphScopes + } + catch { + $status = Get-DefenderHttpStatusCode -ErrorRecord $_ + Add-DefenderRunLogEntry -Module 'Connect-DefenderServices' ` + -Action 'PermissionCheck' -Status 'Failed' -HttpStatusCode $status ` + -Detail $_.Exception.Message + throw + } + Add-DefenderRunLogEntry -Module 'Connect-DefenderServices' ` + -Action 'PermissionCheck' -Status 'Succeeded' ` + -Detail "Graph permission disposition: $($consent.Status)." + $tenantIdentity = Get-DefenderTenantIdentity ` + -ExpectedDomain $expectedDomain -TenantAdminUpn $TenantAdminUpn +} + +[pscustomobject]@{ + TenantAdminUpn = $TenantAdminUpn + TenantId = $TenantId + DelegatedOrganization = $DelegatedOrganization + GraphConnected = [bool] $ConnectGraph + GraphScopes = @($GraphScopes) + TenantIdentity = $tenantIdentity + GraphRequest = ${function:Invoke-DefenderGraphRequest} +} diff --git a/Products/Defender/Modules/DefenderAsrConfiguration.ps1 b/Products/Defender/Modules/DefenderAsrConfiguration.ps1 new file mode 100644 index 0000000..3df0695 --- /dev/null +++ b/Products/Defender/Modules/DefenderAsrConfiguration.ps1 @@ -0,0 +1,365 @@ +#requires -Version 7.0 + +function Test-DefenderAsrToken { + [CmdletBinding()] + param( + [AllowNull()] [object] $Value, + [Parameter(Mandatory)] [string] $Expected + ) + + $tokens = if ($Value -is [System.Collections.IEnumerable] -and + $Value -isnot [string]) { + @($Value) + } + else { + @(([string] $Value) -split ',') + } + return @($tokens | ForEach-Object { ([string] $_).Trim() }) -contains $Expected +} + +function New-DefenderAsrAuditPolicyBody { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [hashtable] $PolicyConfig, + [Parameter(Mandatory)] [string] $ManagedByTag, + [Parameter(Mandatory)] [object] $Template, + [Parameter(Mandatory)] [object[]] $SettingTemplates, + [Parameter(Mandatory)] [object[]] $ParentDefinitions, + [Parameter(Mandatory)] [object[]] $ChildDefinitions, + [Parameter(Mandatory)] [string[]] $ScopeTagIds + ) + + if ([string] $Template.templateFamily -ne $PolicyConfig.TemplateFamily -or + [string] $Template.lifecycleState -ne 'active' -or + [int] $Template.version -ne [int] $PolicyConfig.TemplateVersion -or + -not (Test-DefenderAsrToken -Value $Template.platforms -Expected $PolicyConfig.Platform) -or + @($PolicyConfig.Technologies | Where-Object { + -not (Test-DefenderAsrToken -Value $Template.technologies -Expected $_) + }).Count -gt 0) { + throw 'The discovered ASR template does not match the configured active template contract.' + } + if (@($SettingTemplates).Count -ne 5 -or @($ParentDefinitions).Count -ne 5 -or + @($ChildDefinitions).Count -ne 19) { + throw 'The discovered ASR schema must contain five parent settings and 19 child rules.' + } + + $ruleParents = @($ParentDefinitions | Where-Object { @($_.childIds).Count -eq 19 }) + if ($ruleParents.Count -ne 1) { + throw "Expected one ASR rule-group parent, found $($ruleParents.Count)." + } + $ruleParent = $ruleParents[0] + $ruleChildIds = @($ruleParent.childIds | ForEach-Object { [string] $_ }) + if (@($ruleChildIds | Sort-Object -Unique).Count -ne 19) { + throw 'The ASR rule-group parent must reference 19 distinct child definitions.' + } + $parentTemplate = @($SettingTemplates | Where-Object { + $_.settingInstanceTemplate.settingDefinitionId -eq $ruleParent.id + }) + if ($parentTemplate.Count -ne 1 -or + [string]::IsNullOrWhiteSpace([string] $parentTemplate[0].settingInstanceTemplate.settingInstanceTemplateId)) { + throw 'The ASR rule-group parent does not have one setting-instance template.' + } + + $childrenById = @{} + foreach ($definition in $ChildDefinitions) { + if ([string]::IsNullOrWhiteSpace([string] $definition.id) -or + $childrenById.ContainsKey([string] $definition.id)) { + throw 'The ASR child schema contains a missing or duplicate definition identifier.' + } + $childrenById[[string] $definition.id] = $definition + } + if (@($childrenById.Keys | Where-Object { $_ -notin $ruleChildIds }).Count -gt 0) { + throw 'The discovered ASR child definitions do not exactly match the rule-group parent.' + } + + $childInstances = foreach ($childId in @($ruleParent.childIds)) { + $definition = $childrenById[[string] $childId] + if ($null -eq $definition) { + throw "ASR child definition '$childId' was not returned by discovery." + } + $auditOptions = @($definition.options | Where-Object { + [string] $_.displayName -ceq $PolicyConfig.RuleMode + }) + if ($auditOptions.Count -ne 1 -or + [string]::IsNullOrWhiteSpace([string] $auditOptions[0].itemId)) { + throw "ASR child definition '$childId' does not expose one exact Audit option." + } + [ordered]@{ + '@odata.type' = '#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance' + settingDefinitionId = [string] $definition.id + choiceSettingValue = [ordered]@{ + '@odata.type' = '#microsoft.graph.deviceManagementConfigurationChoiceSettingValue' + value = [string] $auditOptions[0].itemId + children = @() + } + } + } + + $description = "$($PolicyConfig.Description) $ManagedByTag".Trim() + return [ordered]@{ + '@odata.type' = '#microsoft.graph.deviceManagementConfigurationPolicy' + name = [string] $PolicyConfig.Name + description = $description + platforms = [string] $PolicyConfig.Platform + technologies = (@($PolicyConfig.Technologies) -join ',') + roleScopeTagIds = @($ScopeTagIds) + settings = @( + [ordered]@{ + '@odata.type' = '#microsoft.graph.deviceManagementConfigurationSetting' + settingInstance = [ordered]@{ + '@odata.type' = '#microsoft.graph.deviceManagementConfigurationGroupSettingCollectionInstance' + settingDefinitionId = [string] $ruleParent.id + settingInstanceTemplateReference = [ordered]@{ + '@odata.type' = '#microsoft.graph.deviceManagementConfigurationSettingInstanceTemplateReference' + settingInstanceTemplateId = [string] $parentTemplate[0].settingInstanceTemplate.settingInstanceTemplateId + } + groupSettingCollectionValue = @( + [ordered]@{ + '@odata.type' = '#microsoft.graph.deviceManagementConfigurationGroupSettingValue' + children = @($childInstances) + } + ) + } + } + ) + templateReference = [ordered]@{ + '@odata.type' = '#microsoft.graph.deviceManagementConfigurationPolicyTemplateReference' + templateId = [string] $Template.id + templateFamily = [string] $Template.templateFamily + templateDisplayName = [string] $Template.displayName + templateDisplayVersion = [string] $Template.displayVersion + } + } +} + +function New-DefenderAsrAssignmentBody { + [CmdletBinding()] + param([Parameter(Mandatory)] [guid] $PilotGroupId) + + return [ordered]@{ + assignments = @( + [ordered]@{ + target = [ordered]@{ + '@odata.type' = '#microsoft.graph.groupAssignmentTarget' + groupId = $PilotGroupId.ToString() + deviceAndAppManagementAssignmentFilterId = $null + deviceAndAppManagementAssignmentFilterType = 'none' + } + } + ) + } +} + +function Assert-DefenderAsrPolicyReadback { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object] $Policy, + [Parameter(Mandatory)] [object[]] $Settings, + [Parameter(Mandatory)] [hashtable] $ExpectedBody, + [Parameter(Mandatory)] [string] $ManagedByTag + ) + + $expectedTechnologies = @(([string] $ExpectedBody.technologies) -split ',' | + ForEach-Object { $_.Trim() } | Sort-Object -Unique) + $actualTechnologies = @(([string] $Policy.technologies) -split ',' | + ForEach-Object { $_.Trim() } | Sort-Object -Unique) + $expectedScopeTags = @($ExpectedBody.roleScopeTagIds | ForEach-Object { [string] $_ } | + Sort-Object -Unique) + $actualScopeTags = @($Policy.roleScopeTagIds | ForEach-Object { [string] $_ } | + Sort-Object -Unique) + if ([string]::IsNullOrWhiteSpace([string] $Policy.id) -or + [string] $Policy.name -cne [string] $ExpectedBody.name -or + -not ([string] $Policy.description).Contains( + $ManagedByTag, + [StringComparison]::Ordinal) -or + [string] $Policy.platforms -ne [string] $ExpectedBody.platforms -or + ($actualTechnologies -join ',') -ne ($expectedTechnologies -join ',') -or + ($actualScopeTags -join ',') -ne ($expectedScopeTags -join ',') -or + [string] $Policy.templateReference.templateId -ne [string] $ExpectedBody.templateReference.templateId -or + [string] $Policy.templateReference.templateFamily -ne [string] $ExpectedBody.templateReference.templateFamily -or + [string] $Policy.templateReference.templateDisplayVersion -ne [string] $ExpectedBody.templateReference.templateDisplayVersion) { + throw 'ASR policy readback mismatch in managed identity, platform, technologies, scope tags, or template.' + } + + if (@($Settings).Count -ne 1) { + throw "ASR policy readback mismatch: expected one managed setting group, found $(@($Settings).Count)." + } + $expectedInstance = $ExpectedBody.settings[0].settingInstance + $actualInstance = $Settings[0].settingInstance + if ([string] $actualInstance.settingDefinitionId -ne [string] $expectedInstance.settingDefinitionId -or + [string] $actualInstance.settingInstanceTemplateReference.settingInstanceTemplateId -ne + [string] $expectedInstance.settingInstanceTemplateReference.settingInstanceTemplateId) { + throw 'ASR policy readback mismatch in the managed setting-group identity.' + } + + $expectedChildren = @($expectedInstance.groupSettingCollectionValue[0].children) + $actualValues = @{} + foreach ($child in @($actualInstance.groupSettingCollectionValue[0].children)) { + $definitionId = [string] $child.settingDefinitionId + if ([string]::IsNullOrWhiteSpace($definitionId) -or $actualValues.ContainsKey($definitionId)) { + throw 'ASR policy readback contains a missing or duplicate child definition identifier.' + } + $actualValues[$definitionId] = [string] $child.choiceSettingValue.value + } + if ($actualValues.Count -ne 19 -or $expectedChildren.Count -ne 19) { + throw 'ASR policy readback mismatch: expected exactly 19 managed child rules.' + } + foreach ($expectedChild in $expectedChildren) { + $definitionId = [string] $expectedChild.settingDefinitionId + if (-not $actualValues.ContainsKey($definitionId) -or + $actualValues[$definitionId] -ne [string] $expectedChild.choiceSettingValue.value) { + throw "ASR policy readback mismatch for child definition '$definitionId'." + } + } + return $true +} + +function Assert-DefenderAsrAssignmentReadback { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object[]] $Assignments, + [Parameter(Mandatory)] [guid] $PilotGroupId + ) + + if (@($Assignments).Count -ne 1) { + throw "ASR assignment readback mismatch: expected one target, found $(@($Assignments).Count)." + } + $target = $Assignments[0].target + if ([string] $target.'@odata.type' -ne '#microsoft.graph.groupAssignmentTarget' -or + [string] $target.groupId -ne $PilotGroupId.ToString() -or + -not [string]::IsNullOrWhiteSpace([string] $target.deviceAndAppManagementAssignmentFilterId) -or + [string] $target.deviceAndAppManagementAssignmentFilterType -ne 'none') { + throw 'ASR assignment readback mismatch: expected the approved unfiltered direct-group target.' + } + return $true +} + +function Get-DefenderAsrSchema { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [scriptblock] $GraphRequest, + [Parameter(Mandatory)] [hashtable] $PolicyConfig + ) + + $policies = @(Get-DefenderAsrGraphCollection -GraphRequest $GraphRequest ` + -InitialUri 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies') + $templates = @(Get-DefenderAsrGraphCollection -GraphRequest $GraphRequest ` + -InitialUri 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicyTemplates') + $templateMatches = @(foreach ($candidate in $templates) { + $hasTechnologies = $true + foreach ($technology in @($PolicyConfig.Technologies)) { + if (-not (Test-DefenderAsrToken -Value $candidate.technologies -Expected $technology)) { + $hasTechnologies = $false + break + } + } + if ([string] $candidate.templateFamily -eq $PolicyConfig.TemplateFamily -and + [string] $candidate.lifecycleState -eq 'active' -and + [int] $candidate.version -eq [int] $PolicyConfig.TemplateVersion -and + [int] $candidate.settingTemplateCount -eq [int] $PolicyConfig.TemplateSettingCount -and + (Test-DefenderAsrToken -Value $candidate.platforms -Expected $PolicyConfig.Platform) -and + $hasTechnologies) { + $candidate + } + }) + if ($templateMatches.Count -ne 1) { + throw "Expected one active ASR template matching the configured contract, found $($templateMatches.Count)." + } + + $configuredScopeTagIds = @($PolicyConfig.ScopeTagIds | Where-Object { + -not [string]::IsNullOrWhiteSpace([string] $_) + }) + if ($configuredScopeTagIds.Count -ne 1) { + throw "Expected the ASR policy config to declare one scope-tag identifier, found $($configuredScopeTagIds.Count)." + } + + $template = $templateMatches[0] + $templateId = [uri]::EscapeDataString([string] $template.id) + $settingTemplates = @(Get-DefenderAsrGraphCollection -GraphRequest $GraphRequest ` + -InitialUri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicyTemplates/$templateId/settingTemplates") + $parentDefinitions = foreach ($settingTemplate in $settingTemplates) { + $definitionId = [string] $settingTemplate.settingInstanceTemplate.settingDefinitionId + if ([string]::IsNullOrWhiteSpace($definitionId)) { + throw 'An ASR setting template did not expose its linked definition identifier.' + } + & $GraphRequest -Method GET -Uri ( + 'https://graph.microsoft.com/beta/deviceManagement/configurationSettings/{0}' -f + [uri]::EscapeDataString($definitionId)) + } + $childIds = foreach ($parentDefinition in @($parentDefinitions)) { + if ($parentDefinition -is [System.Collections.IDictionary] -and + $parentDefinition.Contains('childIds')) { + @($parentDefinition['childIds'] | Where-Object { $_ }) + } + else { + $childIdsProperty = $parentDefinition.PSObject.Properties['childIds'] + if ($null -ne $childIdsProperty) { + @($childIdsProperty.Value | Where-Object { $_ }) + } + } + } + $childDefinitions = foreach ($childId in @($childIds)) { + & $GraphRequest -Method GET -Uri ( + 'https://graph.microsoft.com/beta/deviceManagement/configurationSettings/{0}' -f + [uri]::EscapeDataString([string] $childId)) + } + + return [pscustomobject]@{ + Policies = @($policies) + Template = $template + SettingTemplates = @($settingTemplates) + ParentDefinitions = @($parentDefinitions) + ChildDefinitions = @($childDefinitions) + ScopeTagIds = @([string] $configuredScopeTagIds[0]) + } +} + +function Get-DefenderAsrGraphCollection { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [scriptblock] $GraphRequest, + [Parameter(Mandatory)] [string] $InitialUri, + [ValidateRange(1, 1000)] [int] $MaximumPageCount = 100 + ) + + $results = [System.Collections.Generic.List[object]]::new() + $visitedUris = [System.Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase) + $nextUri = $InitialUri + $pageCount = 0 + while (-not [string]::IsNullOrWhiteSpace($nextUri)) { + $parsedUri = $null + if (-not [uri]::TryCreate($nextUri, [UriKind]::Absolute, [ref] $parsedUri) -or + $parsedUri.Scheme -cne 'https' -or + $parsedUri.Host -cne 'graph.microsoft.com' -or + -not $parsedUri.IsDefaultPort -or + -not [string]::IsNullOrEmpty($parsedUri.UserInfo) -or + -not [string]::IsNullOrEmpty($parsedUri.Fragment)) { + throw "Microsoft Graph returned an untrusted pagination link for '$InitialUri'." + } + $pageCount++ + if ($pageCount -gt $MaximumPageCount) { + throw "Microsoft Graph pagination exceeded the $MaximumPageCount page limit for '$InitialUri'." + } + if (-not $visitedUris.Add($nextUri)) { + throw "Microsoft Graph returned a repeated pagination link for '$InitialUri'." + } + $response = & $GraphRequest -Method GET -Uri $nextUri + $value = if ($response -is [System.Collections.IDictionary]) { + $response['value'] + } + else { + $response.PSObject.Properties['value'].Value + } + foreach ($item in @($value)) { + if ($null -ne $item) { $results.Add($item) } + } + $nextUri = if ($response -is [System.Collections.IDictionary]) { + [string] $response['@odata.nextLink'] + } + else { + [string] $response.PSObject.Properties['@odata.nextLink'].Value + } + } + return @($results) +} \ No newline at end of file diff --git a/Products/Defender/Modules/DefenderOrchestrationHelpers.ps1 b/Products/Defender/Modules/DefenderOrchestrationHelpers.ps1 new file mode 100644 index 0000000..3e34a69 --- /dev/null +++ b/Products/Defender/Modules/DefenderOrchestrationHelpers.ps1 @@ -0,0 +1,54 @@ +#requires -Version 7.0 + +function Invoke-DefenderModuleProcess { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $ScriptPath, + [hashtable] $Arguments = @{}, + [scriptblock] $ProcessInvoker = { + param($Path, $BoundArguments) + & $Path @BoundArguments + } + ) + + if (-not (Test-Path -LiteralPath $ScriptPath -PathType Leaf)) { + throw "Module script not found: $ScriptPath" + } + & $ProcessInvoker $ScriptPath $Arguments +} + +function Invoke-DefenderModuleSequence { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object[]] $Tasks, + [scriptblock] $ProcessInvoker = { + param($Path, $BoundArguments) + & $Path @BoundArguments + }, + [switch] $ContinueOnError + ) + + $results = [System.Collections.Generic.List[object]]::new() + foreach ($task in @($Tasks)) { + try { + Invoke-DefenderModuleProcess -ScriptPath $task.ScriptPath ` + -Arguments $task.Arguments -ProcessInvoker $ProcessInvoker + $results.Add([pscustomobject]@{ + Name = [string] $task.Name + Status = 'Succeeded' + Error = $null + }) + } + catch { + $results.Add([pscustomobject]@{ + Name = [string] $task.Name + Status = 'Failed' + Error = $_.Exception.Message + }) + if (-not $ContinueOnError) { + throw + } + } + } + return @($results) +} diff --git a/Products/Defender/Modules/DefenderRunLog.ps1 b/Products/Defender/Modules/DefenderRunLog.ps1 new file mode 100644 index 0000000..0733246 --- /dev/null +++ b/Products/Defender/Modules/DefenderRunLog.ps1 @@ -0,0 +1,231 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Structured console and local JSON logging for Defender deployments. + +.DESCRIPTION + Mirrors the Purview run-log contract while adding Defender-specific + best-practice keys and HTTP status evidence. Logging failures are surfaced + through verbose output but do not interrupt the deployment. +#> + +function Initialize-DefenderRunLog { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $JsonPath, + [guid] $RunId = [guid]::NewGuid(), + [datetime] $StartTime = [datetime]::UtcNow, + [string] $ScriptVersion = 'unknown', + [string] $TenantId, + [string] $TenantAdminUpn + ) + + $directory = Split-Path -Parent $JsonPath + if ($directory -and -not (Test-Path -LiteralPath $directory)) { + # Evidence output is local, not a tenant change. It must be written even + # during -WhatIf, otherwise a dry run produces no report at all, which + # is precisely when the operator most needs one. + New-Item -ItemType Directory -Path $directory -Force -WhatIf:$false | Out-Null + } + + $global:DefenderRunLog = [System.Collections.Generic.List[hashtable]]::new() + $global:DefenderRunLogPath = $JsonPath + $global:DefenderRunMetadata = @{ + RunId = $RunId + StartTime = $StartTime + ScriptVersion = $ScriptVersion + TenantId = Protect-DefenderIdentity -Value $TenantId + TenantAdminUpn = Protect-DefenderIdentity -Value $TenantAdminUpn + } + Save-DefenderRunLogJson +} + +function Save-DefenderRunLogJson { + [CmdletBinding()] + param( + [string] $Path = $global:DefenderRunLogPath, + [datetime] $EndTime = [datetime]::UtcNow + ) + + try { + if (-not $Path) { return } + $entries = if ($global:DefenderRunLog) { @($global:DefenderRunLog) } else { @() } + $metadata = if ($global:DefenderRunMetadata) { $global:DefenderRunMetadata } else { @{} } + $moduleSummary = @($entries | Group-Object Module | Sort-Object Name | ForEach-Object { + $items = @($_.Group) + [ordered]@{ + module = $_.Name + result = Get-DefenderModuleVerdict -Entries $items + entryCount = $items.Count + } + }) + $payload = [ordered]@{ + runId = if ($metadata.RunId) { ([guid] $metadata.RunId).ToString() } else { $null } + scriptVersion = $metadata.ScriptVersion + startTime = if ($metadata.StartTime) { ([datetime] $metadata.StartTime).ToString('o') } else { $null } + endTime = $EndTime.ToString('o') + durationSec = if ($metadata.StartTime) { [int] (($EndTime - [datetime] $metadata.StartTime).TotalSeconds) } else { $null } + tenantId = $metadata.TenantId + tenantAdmin = $metadata.TenantAdminUpn + entryCount = $entries.Count + moduleSummary = $moduleSummary + entries = @($entries | ForEach-Object { + [ordered]@{ + timestamp = $_.Timestamp + module = $_.Module + action = $_.Action + bestPracticeKey = $_.BestPracticeKey + friendlyName = $_.FriendlyName + disposition = $_.Disposition + target = $_.Target + status = $_.Status + detail = $_.Detail + httpStatusCode = $_.HttpStatusCode + attempt = $_.Attempt + elapsedMs = $_.ElapsedMs + readback = $_.Readback + } + }) + } + # Evidence output is local, not a tenant change; never suppress it under + # -WhatIf. + $payload | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $Path -Encoding utf8 -WhatIf:$false + } + catch { + Write-Verbose "Save-DefenderRunLogJson failed: $($_.Exception.Message)" + } +} + +function Add-DefenderRunLogEntry { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Module, + [Parameter(Mandatory)] [string] $Action, + [ValidateSet('Started','Succeeded','Created','Updated','Adopted','Skipped','Failed','Retried','Info')] + [string] $Status = 'Info', + [string] $BestPracticeKey, + [string] $FriendlyName, + [ValidateSet('Applicable','AlreadyCompliant','WillChange','GuidedOnly','Skipped','Blocked')] + [string] $Disposition, + [string] $Target, + [string] $Detail, + [int] $HttpStatusCode, + [int] $Attempt, + [int] $ElapsedMs, + [ValidateSet('NotApplicable','Pending','Succeeded','Failed')] + [string] $Readback + ) + + try { + if (-not $global:DefenderRunLog) { + $global:DefenderRunLog = [System.Collections.Generic.List[hashtable]]::new() + } + + $entry = [ordered]@{ + Timestamp = [datetime]::UtcNow.ToString('o') + Module = $Module + Action = $Action + Status = $Status + BestPracticeKey = $BestPracticeKey + FriendlyName = Protect-DefenderLogText -Value $FriendlyName + Disposition = $Disposition + Target = Protect-DefenderLogText -Value $Target + Detail = Protect-DefenderLogText -Value $Detail + HttpStatusCode = if ($PSBoundParameters.ContainsKey('HttpStatusCode')) { $HttpStatusCode } else { $null } + Attempt = if ($PSBoundParameters.ContainsKey('Attempt')) { $Attempt } else { $null } + ElapsedMs = if ($PSBoundParameters.ContainsKey('ElapsedMs')) { $ElapsedMs } else { $null } + Readback = $Readback + } + + $global:DefenderRunLog.Add($entry) + Write-Verbose ("[{0}] {1}/{2}: {3}" -f $Status, $Module, $Action, $entry.Detail) + Write-Host ("[{0}] {1}: {2}" -f $Status, $Action, $entry.Detail) + Save-DefenderRunLogJson + } + catch { + Write-Verbose "Add-DefenderRunLogEntry failed: $($_.Exception.Message)" + } +} + +function Protect-DefenderLogText { + [CmdletBinding()] + param([AllowNull()] [object] $Value) + + if ($null -eq $Value) { return $null } + $text = [string] $Value + $text = $text -replace '(?i)(authorization\s*:\s*bearer\s+)[^\s,;]+', '$1[REDACTED]' + $text = $text -replace '(?i)(access[_-]?token|refresh[_-]?token|client[_-]?secret|password)\s*[=:]\s*[^&\s,;]+', '$1=[REDACTED]' + return $text +} + +function Protect-DefenderIdentity { + [CmdletBinding()] + param([AllowNull()] [string] $Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { return $null } + if ($Value -match '^([^@\s])[^@\s]*(@[^@\s]+)$') { + return "$($Matches[1])***$($Matches[2])" + } + return '[REDACTED]' +} + +function Get-DefenderRunLog { + [CmdletBinding()] + [OutputType([hashtable[]])] + param() + + if (-not $global:DefenderRunLog) { return @() } + return @($global:DefenderRunLog.ToArray()) +} + +function Get-DefenderEntryField { + [CmdletBinding()] + param( + [AllowNull()] [object] $Entry, + [Parameter(Mandatory)] [string] $Name + ) + + if ($null -eq $Entry) { return $null } + if ($Entry -is [System.Collections.IDictionary]) { + if ($Entry.Contains($Name)) { return $Entry[$Name] } + return $null + } + + $property = $Entry.PSObject.Properties[$Name] + if ($property) { return $property.Value } + return $null +} + +function Get-DefenderModuleVerdict { + <# + Single source of truth for the per-module verdict shown in the JSON + sidecar and HTML report. Empty modules are treated as OK, matching the + existing product report contract. + #> + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Entries) + + $items = @($Entries) + if (@($items | Where-Object { + (Get-DefenderEntryField -Entry $_ -Name 'Status') -eq 'Failed' -or + (Get-DefenderEntryField -Entry $_ -Name 'Readback') -eq 'Failed' + }).Count -gt 0) { return 'FAILED' } + if (@($items | Where-Object { + (Get-DefenderEntryField -Entry $_ -Name 'Disposition') -eq 'Blocked' + }).Count -gt 0) { return 'BLOCKED' } + if (@($items | Where-Object { + (Get-DefenderEntryField -Entry $_ -Name 'Status') -eq 'Skipped' -or + (Get-DefenderEntryField -Entry $_ -Name 'Disposition') -eq 'GuidedOnly' + }).Count -gt 0) { return 'SKIPPED' } + return 'OK' +} + +function Clear-DefenderRunLog { + [CmdletBinding()] + param() + + Remove-Variable -Name 'DefenderRunLog' -Scope Global -ErrorAction SilentlyContinue -WhatIf:$false + Remove-Variable -Name 'DefenderRunLogPath' -Scope Global -ErrorAction SilentlyContinue -WhatIf:$false + Remove-Variable -Name 'DefenderRunMetadata' -Scope Global -ErrorAction SilentlyContinue -WhatIf:$false +} diff --git a/Products/Defender/Modules/DefenderSafetyGates.ps1 b/Products/Defender/Modules/DefenderSafetyGates.ps1 new file mode 100644 index 0000000..ccae398 --- /dev/null +++ b/Products/Defender/Modules/DefenderSafetyGates.ps1 @@ -0,0 +1,55 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Validates explicit Defender safety gates before module dispatch. +#> + +function Get-DefenderContinuationCommand { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Reason, + [string] $SwitchToRemove, + [string] $SwitchToAdd + ) + + if ($SwitchToRemove) { + return "Review $Reason, then re-run Deploy-DefenderBestPractice.ps1 without $SwitchToRemove." + } + if ($SwitchToAdd) { + return "Review $Reason, then re-run Deploy-DefenderBestPractice.ps1 with $SwitchToAdd." + } + return "Review $Reason, then re-run Deploy-DefenderBestPractice.ps1 with the required approval and recovery parameters." +} + +function Assert-DefenderSafetyGates { + [CmdletBinding()] + param( + [switch] $IncludeHighRisk, + [switch] $RollbackAcknowledged, + [switch] $EnableMailFlowImpactingChanges, + [switch] $EnableAutomatedRemediation, + [switch] $EnableAsrBlockMode, + [switch] $EnableMdcaEnforcement + ) + + $categoryGates = @( + @{ Enabled = $EnableMailFlowImpactingChanges; Name = 'EnableMailFlowImpactingChanges' } + @{ Enabled = $EnableAutomatedRemediation; Name = 'EnableAutomatedRemediation' } + @{ Enabled = $EnableAsrBlockMode; Name = 'EnableAsrBlockMode' } + @{ Enabled = $EnableMdcaEnforcement; Name = 'EnableMdcaEnforcement' } + ) + + foreach ($gate in $categoryGates) { + if ($gate.Enabled -and -not $IncludeHighRisk) { + throw (Get-DefenderContinuationCommand -Reason "$($gate.Name) requires IncludeHighRisk" -SwitchToAdd '-IncludeHighRisk') + } + } + if ($EnableMailFlowImpactingChanges -and -not $RollbackAcknowledged) { + throw (Get-DefenderContinuationCommand -Reason 'EnableMailFlowImpactingChanges requires RollbackAcknowledged') + } + return [pscustomobject]@{ + Status = 'Ready' + HighRisk = [bool] $IncludeHighRisk + RollbackAcknowledged = [bool] $RollbackAcknowledged + } +} diff --git a/Products/Defender/Modules/DefenderStateManagement.ps1 b/Products/Defender/Modules/DefenderStateManagement.ps1 new file mode 100644 index 0000000..8f6ec00 --- /dev/null +++ b/Products/Defender/Modules/DefenderStateManagement.ps1 @@ -0,0 +1,464 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Shared read-modify-write planning for Defender objects. + +.DESCRIPTION + Keeps state decisions deterministic before a supported Defender write is + enabled. The helper preserves unrelated customer properties, recognizes + toolkit-managed objects, and fails closed on unmanaged collisions. +#> + +# Resolve-DefenderRecoveryAction reuses the shared continuation-command +# phrasing so operator guidance stays identical to the safety-gate messages. +. (Join-Path $PSScriptRoot 'DefenderSafetyGates.ps1') + +function Get-DefenderPropertyValue { + [CmdletBinding()] + param( + [AllowNull()] [object] $InputObject, + [Parameter(Mandatory)] [string] $PropertyName + ) + + if ($null -eq $InputObject) { return $null } + if ($InputObject -is [System.Collections.IDictionary]) { + if ($InputObject.Contains($PropertyName)) { return $InputObject[$PropertyName] } + return $null + } + $property = $InputObject.PSObject.Properties[$PropertyName] + if ($property) { return $property.Value } + return $null +} + +function Get-DefenderManagedTag { + [CmdletBinding()] + param([Parameter(Mandatory)] [hashtable] $Config) + + if ([string]::IsNullOrWhiteSpace([string] $Config.ManagedByTag)) { + throw 'Defender configuration must define ManagedByTag.' + } + return [string] $Config.ManagedByTag +} + +function Get-DefenderStableIdentifier { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object] $Object, + [Parameter(Mandatory)] [hashtable] $Definition + ) + + $parts = foreach ($propertyName in @($Definition.IdentifierProperties)) { + $value = Get-DefenderPropertyValue -InputObject $Object -PropertyName $propertyName + if ($null -eq $value -or [string]::IsNullOrWhiteSpace([string] $value)) { + throw "Defender object is missing stable identifier property '$propertyName'." + } + ([string] $value).Trim().ToLowerInvariant() + } + return ($parts -join '|') +} + +function ConvertTo-DefenderComparableValue { + [CmdletBinding()] + param([AllowNull()] [object] $Value) + + if ($null -eq $Value) { return $null } + if ($Value -is [System.Collections.IDictionary]) { + $result = [ordered]@{} + foreach ($key in ($Value.Keys | Sort-Object)) { + $result[[string] $key] = ConvertTo-DefenderComparableValue -Value $Value[$key] + } + return $result + } + if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { + return @($Value | ForEach-Object { ConvertTo-DefenderComparableValue -Value $_ }) + } +if ($Value -is [string]) { return $Value } + $properties = @($Value.PSObject.Properties | Where-Object { + $_.MemberType -in @('NoteProperty', 'Property') + }) + if ($properties.Count -gt 0) { + foreach ($property in ($properties | Sort-Object Name)) { + $result[$property.Name] = ConvertTo-DefenderComparableValue -Value $property.Value + } + return $result + } + return $Value +} + +function Test-DefenderDesiredState { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object] $Current, + [Parameter(Mandatory)] [object] $Desired, + [Parameter(Mandatory)] [hashtable] $Definition + ) + + foreach ($propertyName in @($Definition.ManagedProperties)) { + $currentValue = ConvertTo-DefenderComparableValue ( + Get-DefenderPropertyValue -InputObject $Current -PropertyName $propertyName + ) + $desiredValue = ConvertTo-DefenderComparableValue ( + Get-DefenderPropertyValue -InputObject $Desired -PropertyName $propertyName + ) + $currentJson = $currentValue | ConvertTo-Json -Depth 20 -Compress + $desiredJson = $desiredValue | ConvertTo-Json -Depth 20 -Compress + if ($currentJson -cne $desiredJson) { return $false } + } + return $true +} + +function Merge-DefenderManagedState { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object] $Current, + [Parameter(Mandatory)] [object] $Desired, + [Parameter(Mandatory)] [hashtable] $Definition, + [Parameter(Mandatory)] [hashtable] $Config + ) + + $merged = [ordered]@{} + foreach ($property in @($Current.PSObject.Properties | Where-Object { + $_.MemberType -in @('NoteProperty', 'Property') + })) { + $merged[$property.Name] = $property.Value + } + if ($Current -is [System.Collections.IDictionary]) { + foreach ($key in $Current.Keys) { $merged[[string] $key] = $Current[$key] } + } + foreach ($propertyName in @($Definition.ManagedProperties)) { + $desiredValue = Get-DefenderPropertyValue -InputObject $Desired -PropertyName $propertyName + if ($null -ne $desiredValue) { $merged[$propertyName] = $desiredValue } + } + $managedProperty = [string] $Definition.ManagedTagProperty + $merged[$managedProperty] = Get-DefenderManagedTag -Config $Config + return $merged +} + +function Resolve-DefenderStatePlan { + [CmdletBinding()] + param( + [AllowNull()] [object] $Current, + [Parameter(Mandatory)] [object] $Desired, + [Parameter(Mandatory)] [hashtable] $Definition, + [Parameter(Mandatory)] [hashtable] $Config, + [switch] $AdoptExisting, + [switch] $BuiltInObject + ) + + $identifier = Get-DefenderStableIdentifier -Object $Desired -Definition $Definition + if ($BuiltInObject) { + if ($null -eq $Current) { + throw "Built-in Defender object '$identifier' was not found; creation and adoption are not permitted." + } + $currentIdentifier = Get-DefenderStableIdentifier -Object $Current -Definition $Definition + if ($currentIdentifier -cne $identifier) { + throw "Defender state collision: current identifier '$currentIdentifier' does not match desired '$identifier'." + } + if (Test-DefenderDesiredState -Current $Current -Desired $Desired -Definition $Definition) { + return [pscustomobject]@{ + Action = 'NoOp' + Status = 'Succeeded' + Disposition = 'AlreadyCompliant' + StableIdentifier = $identifier + State = $Current + Detail = 'Existing built-in object already matches the managed intent.' + } + } + return [pscustomobject]@{ + Action = 'Update' + Status = 'Updated' + Disposition = 'WillChange' + StableIdentifier = $identifier + State = $Desired + Detail = 'Existing built-in object will be updated without toolkit ownership or adoption.' + } + } + + if ($null -eq $Current) { + return [pscustomobject]@{ + Action = 'Create' + Status = 'Created' + Disposition = 'WillChange' + StableIdentifier = $identifier + State = Merge-DefenderManagedState -Current @{} -Desired $Desired -Definition $Definition -Config $Config + Detail = 'No existing object matched the stable identifier.' + } + } + + $currentIdentifier = Get-DefenderStableIdentifier -Object $Current -Definition $Definition + if ($currentIdentifier -cne $identifier) { + throw "Defender state collision: current identifier '$currentIdentifier' does not match desired '$identifier'." + } + + $managedTag = Get-DefenderManagedTag -Config $Config + $currentTag = [string] (Get-DefenderPropertyValue -InputObject $Current -PropertyName $Definition.ManagedTagProperty) + if ($currentTag -ne $managedTag -and -not $AdoptExisting) { + throw "Unmanaged Defender object collision for '$identifier'. Supply -AdoptExisting only after reviewing the existing object." + } + + if (Test-DefenderDesiredState -Current $Current -Desired $Desired -Definition $Definition) { + return [pscustomobject]@{ + Action = 'NoOp' + Status = 'Succeeded' + Disposition = 'AlreadyCompliant' + StableIdentifier = $identifier + State = $Current + Detail = 'Existing object already matches the managed intent.' + } + } + + $action = if ($currentTag -eq $managedTag) { 'Update' } else { 'Adopt' } + $status = if ($action -eq 'Adopt') { 'Adopted' } else { 'Updated' } + return [pscustomobject]@{ + Action = $action + Status = $status + Disposition = 'WillChange' + StableIdentifier = $identifier + State = Merge-DefenderManagedState -Current $Current -Desired $Desired -Definition $Definition -Config $Config + Detail = if ($action -eq 'Adopt') { + 'Existing unmanaged object was explicitly adopted; unrelated properties were preserved.' + } else { + 'Existing managed object will be updated; unrelated properties were preserved.' + } + } +} + +function Test-DefenderReadback { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object] $Expected, + [Parameter(Mandatory)] [object] $Actual, + [Parameter(Mandatory)] [hashtable] $Definition + ) + + if (-not (Test-DefenderDesiredState -Current $Actual -Desired $Expected -Definition $Definition)) { + throw "Defender readback mismatch for stable identifier '$(Get-DefenderStableIdentifier -Object $Expected -Definition $Definition)'." + } + return $true +} + +function Resolve-DefenderRecoveryAction { + <# + .SYNOPSIS + The shared recovery contract: turns a write outcome plus readback + result into one deterministic next action, instead of leaving that + decision to per-module ad hoc prose. + + .DESCRIPTION + Every Setup-* module reaches one of the same handful of situations + after attempting a write: the write itself failed, the write + succeeded but readback could not confirm it, or the write succeeded + and readback confirmed it. This helper is the single place that maps + those situations to a run-log-ready Disposition/Readback pair and an + operator-actionable continuation command, so recovery guidance is + consistent and testable rather than duplicated as free text per + module. + + This function only classifies and recommends; it never retries, + rolls back, or mutates tenant state itself. Retrying belongs to + Invoke-WithTransientRetry, and rollback execution is always a human + decision gated by Assert-DefenderSafetyGates. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [ValidateSet('Succeeded', 'Failed')] [string] $WriteOutcome, + [ValidateSet('NotApplicable', 'Succeeded', 'Failed')] [string] $ReadbackOutcome = 'NotApplicable', + [Parameter(Mandatory)] [string] $ModuleName, + [Parameter(Mandatory)] [string] $RollbackGuidance + ) + + if ($WriteOutcome -eq 'Failed' -and $ReadbackOutcome -ne 'NotApplicable') { + throw "Resolve-DefenderRecoveryAction: WriteOutcome 'Failed' is incompatible with ReadbackOutcome '$ReadbackOutcome'. Readback only applies after a successful write; pass 'NotApplicable' when the write failed." + } + + if ($WriteOutcome -eq 'Failed') { + return [pscustomobject]@{ + RecoveryAction = 'RetryOrEscalate' + Disposition = 'Blocked' + Readback = 'NotApplicable' + RunLogStatus = 'Failed' + ContinuationCommand = (Get-DefenderContinuationCommand ` + -Reason "$ModuleName write failed; $RollbackGuidance") + } + } + + if ($ReadbackOutcome -eq 'Failed') { + return [pscustomobject]@{ + RecoveryAction = 'ManualVerificationRequired' + Disposition = 'Blocked' + Readback = 'Failed' + RunLogStatus = 'Failed' + ContinuationCommand = (Get-DefenderContinuationCommand ` + -Reason "$ModuleName write succeeded but readback could not confirm the change; $RollbackGuidance") + } + } + + if ($ReadbackOutcome -eq 'NotApplicable') { + return [pscustomobject]@{ + RecoveryAction = 'None' + Disposition = 'WillChange' + Readback = 'NotApplicable' + RunLogStatus = 'Succeeded' + ContinuationCommand = $null + } + } + + return [pscustomobject]@{ + RecoveryAction = 'None' + Disposition = 'WillChange' + Readback = 'Succeeded' + RunLogStatus = 'Succeeded' + ContinuationCommand = $null + } +} + +function Invoke-DefenderManagedWrite { + <# + .SYNOPSIS + Shared batch write contract: plan, ShouldProcess, retry, readback, and + recovery classification for one or more Defender objects. + + .DESCRIPTION + Wires the previously separate building blocks (Resolve-DefenderStatePlan, + Invoke-WithTransientRetry, Resolve-DefenderRecoveryAction) into one + reusable batch operation so a Setup-* module does not have to hand-roll + its own WhatIf/retry/recovery loop. Each item is independent: a failure + on one item is recorded and processing continues to the remaining + items rather than aborting the whole batch (partial-failure isolation). + -WhatIf/-Confirm are honored per item via ShouldProcess before + WriteAction ever runs. + + This function does not itself call any tenant API. WriteAction and + ReadbackAction are supplied scriptblocks so the same contract works + for every workload without hardcoding a specific cmdlet or endpoint. + #> + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] + param( + [Parameter(Mandatory)] [object[]] $Items, + [Parameter(Mandatory)] [hashtable] $Definition, + [Parameter(Mandatory)] [hashtable] $Config, + [Parameter(Mandatory)] [string] $ModuleName, + [Parameter(Mandatory)] [string] $RollbackGuidance, + [Parameter(Mandatory)] [scriptblock] $WriteAction, + [scriptblock] $ReadbackAction, + [switch] $AdoptExisting, + [switch] $BuiltInObject + ) + + $results = foreach ($item in $Items) { + # Identifier resolution and plan resolution are both wrapped by the + # same try/catch so a malformed item (missing identifier properties, + # bad shape, etc.) is recorded as a Failed batch item instead of + # aborting the remaining items in the batch (partial-failure + # isolation applies to every failure mode, not just plan errors). + $identifier = $null + try { + $current = Get-DefenderPropertyValue -InputObject $item -PropertyName 'Current' + $desired = Get-DefenderPropertyValue -InputObject $item -PropertyName 'Desired' + if ($null -eq $desired) { $desired = $item } + $identifier = Get-DefenderStableIdentifier -Object $desired -Definition $Definition + $plan = Resolve-DefenderStatePlan -Current $current -Desired $desired ` + -Definition $Definition -Config $Config -AdoptExisting:$AdoptExisting ` + -BuiltInObject:$BuiltInObject + } + catch { + [pscustomobject]@{ + StableIdentifier = $identifier + Action = 'Error' + WriteOutcome = 'Failed' + Readback = 'NotApplicable' + Recovery = Resolve-DefenderRecoveryAction -WriteOutcome 'Failed' ` + -ModuleName $ModuleName -RollbackGuidance $RollbackGuidance + Detail = $_.Exception.Message + } + continue + } + + if ($plan.Action -eq 'NoOp') { + # Preserve Resolve-DefenderStatePlan's own AlreadyCompliant + # classification instead of reusing Resolve-DefenderRecoveryAction's + # generic Succeeded/NotApplicable mapping (which reports + # Disposition='WillChange' - correct for a real write, wrong for a + # rerun that changed nothing). + [pscustomobject]@{ + StableIdentifier = $plan.StableIdentifier + Action = $plan.Action + WriteOutcome = 'Succeeded' + Readback = 'NotApplicable' + Recovery = [pscustomobject]@{ + RecoveryAction = 'None' + Disposition = 'AlreadyCompliant' + Readback = 'NotApplicable' + RunLogStatus = 'Succeeded' + ContinuationCommand = $null + } + Detail = $plan.Detail + } + continue + } + + $target = "$ModuleName : $($plan.StableIdentifier)" + if (-not $PSCmdlet.ShouldProcess($target, "Apply $($plan.Action)")) { + [pscustomobject]@{ + StableIdentifier = $plan.StableIdentifier + Action = $plan.Action + WriteOutcome = 'Skipped' + Readback = 'NotApplicable' + Recovery = $null + Detail = 'Skipped by -WhatIf; no write was attempted.' + } + continue + } + + $writeOutcome = 'Succeeded' + $writeError = $null + try { + Invoke-WithTransientRetry -Description "$ModuleName write for $($plan.StableIdentifier)" -Action { + & $WriteAction $plan + } | Out-Null + } + catch { + $writeOutcome = 'Failed' + $writeError = $_.Exception.Message + } + + if ($writeOutcome -eq 'Failed') { + [pscustomobject]@{ + StableIdentifier = $plan.StableIdentifier + Action = $plan.Action + WriteOutcome = 'Failed' + Readback = 'NotApplicable' + Recovery = Resolve-DefenderRecoveryAction -WriteOutcome 'Failed' ` + -ModuleName $ModuleName -RollbackGuidance $RollbackGuidance + Detail = $writeError + } + continue + } + + $readbackOutcome = 'NotApplicable' + $readbackDetail = $plan.Detail + if ($ReadbackAction) { + try { + $actual = & $ReadbackAction $plan + Test-DefenderReadback -Expected $plan.State -Actual $actual -Definition $Definition | Out-Null + $readbackOutcome = 'Succeeded' + } + catch { + $readbackOutcome = 'Failed' + $readbackDetail = $_.Exception.Message + } + } + + [pscustomobject]@{ + StableIdentifier = $plan.StableIdentifier + Action = $plan.Action + WriteOutcome = $writeOutcome + Readback = $readbackOutcome + Recovery = Resolve-DefenderRecoveryAction -WriteOutcome $writeOutcome ` + -ReadbackOutcome $readbackOutcome -ModuleName $ModuleName -RollbackGuidance $RollbackGuidance + Detail = $readbackDetail + } + } + + return $results +} diff --git a/Products/Defender/Modules/Get-DefenderPermissionPlan.ps1 b/Products/Defender/Modules/Get-DefenderPermissionPlan.ps1 new file mode 100644 index 0000000..97673b2 --- /dev/null +++ b/Products/Defender/Modules/Get-DefenderPermissionPlan.ps1 @@ -0,0 +1,196 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Resolves and validates the least-privilege permission plan for Defender. +#> + +function Assert-DefenderPermissionManifest { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [hashtable] $Config + ) + + if (-not $Config.PermissionModel) { + throw 'Defender configuration must define PermissionModel.' + } + foreach ($profileName in @('ReadOnlyPreflight', 'WriteApply')) { + $profile = $Config.PermissionModel.Profiles[$profileName] + if (-not $profile) { + throw "Defender PermissionModel is missing profile '$profileName'." + } + foreach ($property in @( + 'MinimumRoles', + 'MinimumGdapRoles', + 'ConsentOwner', + 'Status' + )) { + if ($null -eq $profile[$property]) { + throw "PermissionModel profile '$profileName' is missing '$property'." + } + } + if ($profileName -eq 'ReadOnlyPreflight' -and $profile.Status -ne 'Verified') { + throw "ReadOnlyPreflight profile must have Status='Verified'." + } + if ($profileName -eq 'WriteApply' -and $profile.Status -notin @('GuidedOnly', 'Verified')) { + throw "WriteApply profile must have Status='GuidedOnly' or 'Verified'." + } + } + if (-not $Config.PermissionModel.Operations) { + throw 'Defender PermissionModel must define at least one operation.' + } + $operationKeys = @() + foreach ($operation in @($Config.PermissionModel.Operations)) { + foreach ($property in @( + 'Key', + 'Module', + 'Phase', + 'ReadWrite', + 'Endpoint', + 'License', + 'GraphDelegatedScopes', + 'GraphApplicationPermissions', + 'MinimumRoles', + 'MinimumGdapRoles', + 'VerificationStatus', + 'Readback', + 'Rollback' + )) { + if ($null -eq $operation[$property]) { + throw "Defender permission operation is missing '$property'." + } + } + if ($operation.Key -notmatch '^[a-z0-9]+(?:-[a-z0-9]+)+$') { + throw "Permission operation '$($operation.Key)' must be lowercase kebab-case." + } + if ($operation.Key -in $operationKeys) { + throw "Duplicate Defender permission operation key '$($operation.Key)'." + } + $operationKeys += $operation.Key + if ($operation.Phase -notin @('ReadOnlyPreflight', 'WriteApply', 'GuidedOnly')) { + throw "Permission operation '$($operation.Key)' has an unsupported phase." + } + if ($operation.ReadWrite -notin @('Read', 'Write', 'Readback')) { + throw "Permission operation '$($operation.Key)' has an unsupported read/write classification." + } + if ($operation.Phase -eq 'ReadOnlyPreflight' -and $operation.ReadWrite -ne 'Read') { + throw "Read-only operation '$($operation.Key)' must use ReadWrite='Read'." + } + if ($operation.Phase -in @('WriteApply', 'GuidedOnly') -and $operation.ReadWrite -ne 'Write') { + throw "Write operation '$($operation.Key)' must use ReadWrite='Write'." + } + if ($operation.VerificationStatus -notin @('Verified', 'GuidedOnly', 'Deferred')) { + throw "Permission operation '$($operation.Key)' has an unsupported verification status." + } + if ([string]::IsNullOrWhiteSpace([string] $operation.Readback) -or + [string]::IsNullOrWhiteSpace([string] $operation.Rollback)) { + throw "Permission operation '$($operation.Key)' must declare Readback and Rollback guidance." + } + if ($operation.Phase -eq 'ReadOnlyPreflight') { + $hasDelegatedGraph = @($operation.GraphDelegatedScopes).Count -gt 0 + $hasApplicationGraph = @($operation.GraphApplicationPermissions).Count -gt 0 + $hasWorkloadPermission = @($operation.WorkloadDelegatedPermissions).Count -gt 0 -or + @($operation.WorkloadApplicationPermissions).Count -gt 0 + if ($hasDelegatedGraph -ne $hasApplicationGraph) { + throw "Authorized Graph read-only operation '$($operation.Key)' must declare delegated and application permissions." + } + if (-not $hasDelegatedGraph -and -not $hasWorkloadPermission) { + throw "Authorized read-only operation '$($operation.Key)' must declare Graph or workload permissions." + } + } + } + foreach ($item in @($Config.BestPracticeItems)) { + if ($null -eq $item.PermissionOperations) { + throw "BestPracticeItems entry '$($item.Key)' must declare PermissionOperations, even when it is empty." + } + foreach ($operationKey in @($item.PermissionOperations)) { + if ($operationKey -notin $operationKeys) { + throw "BestPracticeItems entry '$($item.Key)' references unknown permission operation '$operationKey'." + } + $operation = @($Config.PermissionModel.Operations | Where-Object Key -eq $operationKey)[0] + if ($operation.Phase -eq 'GuidedOnly') { + throw "BestPracticeItems entry '$($item.Key)' cannot reference guided-only write operation '$operationKey' until its permission and workload evidence is verified." + } + } + } +} + +function Get-DefenderPermissionPlan { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [hashtable] $Config, + [Parameter(Mandatory)] [string[]] $OperationKeys + ) + + Assert-DefenderPermissionManifest -Config $Config + $operations = @($Config.PermissionModel.Operations | Where-Object { + $_.Key -in $OperationKeys + }) + $missing = @($OperationKeys | Where-Object { + $_ -notin @($operations | ForEach-Object { $_.Key }) + }) + if ($missing.Count -gt 0) { + throw "Defender permission manifest has no operation entries for: $($missing -join ', ')." + } + + $profiles = @($operations | ForEach-Object { $_.Phase } | Sort-Object -Unique) + $guidedWrites = @($operations | Where-Object { + $_.Phase -eq 'GuidedOnly' -and $_.ReadWrite -eq 'Write' + }) + if ($profiles -contains 'WriteApply' -or $guidedWrites.Count -gt 0) { + if ($guidedWrites.Count -gt 0) { + throw "Write-capable Defender operations are guided-only and cannot be authorized: $($guidedWrites.Key -join ', ')." + } + $unverifiedWrites = @($operations | Where-Object { + $_.Phase -eq 'WriteApply' -and $_.VerificationStatus -ne 'Verified' + }) + if ($unverifiedWrites.Count -gt 0) { + throw "Write-capable Defender operations are not verified and cannot be authorized: $($unverifiedWrites.Key -join ', ')." + } + } + + $scopes = @($operations | ForEach-Object { + @($_.GraphDelegatedScopes) + } | Sort-Object -Unique) + [pscustomobject]@{ + Operations = $operations + Profiles = $profiles + GraphDelegatedScopes = $scopes + GraphApplicationPermissions = @($operations | ForEach-Object { + @($_.GraphApplicationPermissions) + } | Sort-Object -Unique) + MinimumRoles = @($operations | ForEach-Object { + @($_.MinimumRoles) + } | Sort-Object -Unique) + MinimumGdapRoles = @($operations | ForEach-Object { + @($_.MinimumGdapRoles) + } | Sort-Object -Unique) + Status = 'Ready' + } +} + +function Test-DefenderGraphConsent { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object] $GraphContext, + [Parameter(Mandatory)] [string[]] $RequiredDelegatedScopes + ) + + if ($GraphContext.AuthType -eq 'Delegated') { + $granted = @($GraphContext.Scopes | ForEach-Object { [string] $_ }) + $missing = @($RequiredDelegatedScopes | Where-Object { $_ -notin $granted }) + if ($missing.Count -gt 0) { + throw "Missing delegated Microsoft Graph consent for: $($missing -join ', ')." + } + return [pscustomobject]@{ + AuthType = 'Delegated' + Missing = @() + Status = 'Verified' + } + } + + if ($GraphContext.AuthType -eq 'AppOnly') { + throw 'App-only Microsoft Graph authorization is not verified for the Defender least-privilege consent model; certificate-based runs are blocked until operation-level application permission evidence is supplied.' + } + + throw "Unsupported Microsoft Graph authentication type '$($GraphContext.AuthType)'." +} diff --git a/Products/Defender/Modules/Invoke-WithTransientRetry.ps1 b/Products/Defender/Modules/Invoke-WithTransientRetry.ps1 new file mode 100644 index 0000000..e3e5537 --- /dev/null +++ b/Products/Defender/Modules/Invoke-WithTransientRetry.ps1 @@ -0,0 +1,198 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Single retry boundary for transient Defender API and cmdlet operations. + +.DESCRIPTION + Uses bounded exponential backoff with small random jitter and records + Started, Retried, Succeeded, and Failed outcomes through the Defender + run-log interface when it is available. +#> + +function Get-DefenderHttpStatusCode { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [System.Management.Automation.ErrorRecord] $ErrorRecord + ) + + $response = $ErrorRecord.Exception.Response + if ($response -and $response.StatusCode) { + return [int] $response.StatusCode + } + return 0 +} + +function Get-DefenderErrorMessage { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [System.Management.Automation.ErrorRecord] $ErrorRecord + ) + + $messages = @( + $ErrorRecord.Exception.Message + $ErrorRecord.Exception.InnerException.Message + $ErrorRecord.ErrorDetails.Message + $ErrorRecord.ToString() + ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + + return ($messages -join ' | ') +} + +# Default transient HTTP status codes. Kept as a named, exported list (rather +# than inlined at the call site) so failure classification is a single +# testable contract instead of duplicated per-workload judgment calls. +$script:DefaultTransientHttpStatusCodes = @(408, 429, 500, 502, 503, 504) +$script:DefenderTransientMessagePatterns = @( + '(?i)\b429\b' + '(?i)\b(?:500|502|503|504)\b' + '(?i)\btoo many requests\b' + '(?i)\bthrottl' + '(?i)\brate limit' + '(?i)\bservice unavailable\b' + '(?i)\bgateway timeout\b' + '(?i)\bbad gateway\b' + '(?i)\binternal server error\b' + '(?i)\bserver[- ]side (?:error|failure)\b' + '(?i)\btemporar(?:y|ily)\b.*\b(?:unavailable|failure|error)\b' + '(?i)\bserver is busy\b' + '(?i)\btime(?:d)? out\b' + '(?i)\btry again later\b' +) +$script:DefenderNonTransientMessagePatterns = @( + '(?i)\bunauthori[sz]ed\b' + '(?i)\bforbidden\b' + '(?i)\baccess denied\b' + '(?i)\b(?:invalid|malformed)\b' + '(?i)\b(?:not found|does not exist)\b' + '(?i)\b(?:already exists|duplicate)\b' + '(?i)\bvalidation\b' + '(?i)\bnot supported\b' + '(?i)\bunsupported\b' +) + +function Get-DefenderTransientFailureReason { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [System.Management.Automation.ErrorRecord] $ErrorRecord, + [int[]] $TransientHttpStatusCodes = $script:DefaultTransientHttpStatusCodes + ) + + $status = Get-DefenderHttpStatusCode -ErrorRecord $ErrorRecord + $message = Get-DefenderErrorMessage -ErrorRecord $ErrorRecord + + if ($message -match ($script:DefenderNonTransientMessagePatterns -join '|')) { + return '' + } + + if ($status -in $TransientHttpStatusCodes) { + return "HTTP status $status" + } + + foreach ($pattern in $script:DefenderTransientMessagePatterns) { + if ($message -match $pattern) { + return 'error message matched transient signature' + } + } + + return '' +} + +function Test-DefenderTransientFailure { + <# + .SYNOPSIS + Classifies an error record as transient (retry-eligible) or permanent + (fail fast). This is the single failure-classification + contract every workload should reuse instead of re-deriving its own + transient/permanent judgment. + + .DESCRIPTION + Response-backed status codes are checked first, while cmdlet-style + errors fall back to narrowly scoped transient message signatures. + Deterministic semantic and authorization messages take precedence over + generic transient status codes so a wrapped 500 does not cause retries. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)] [int] $HttpStatusCode, + [System.Management.Automation.ErrorRecord] $ErrorRecord, + [int[]] $TransientHttpStatusCodes = $script:DefaultTransientHttpStatusCodes + ) + + if ($ErrorRecord) { + return [bool](Get-DefenderTransientFailureReason -ErrorRecord $ErrorRecord ` + -TransientHttpStatusCodes $TransientHttpStatusCodes) + } + + return $HttpStatusCode -in $TransientHttpStatusCodes +} + +function Invoke-WithTransientRetry { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [scriptblock] $Action, + [Parameter(Mandatory)] [string] $Description, + [int] $MaxAttempts = 3, + # Allows a workload to widen/narrow the transient classification + # without editing this shared helper. Defaults to the shared list. + [int[]] $TransientHttpStatusCodes = $script:DefaultTransientHttpStatusCodes, + [int] $BackoffCapSeconds = 30, + [int] $MaxJitterSeconds = 3 + ) + + $logEnabled = [bool](Get-Command 'Add-DefenderRunLogEntry' -ErrorAction SilentlyContinue) + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + if ($logEnabled) { + Add-DefenderRunLogEntry -Module 'Invoke-WithTransientRetry' ` + -Action $Description -Status 'Started' -Attempt 1 ` + -Detail 'Transient operation started.' + } + + for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + try { + $result = & $Action + $stopwatch.Stop() + if ($logEnabled) { + Add-DefenderRunLogEntry -Module 'Invoke-WithTransientRetry' ` + -Action $Description -Status 'Succeeded' -Attempt $attempt ` + -ElapsedMs ([int] $stopwatch.ElapsedMilliseconds) ` + -Detail 'Transient operation completed successfully.' + } + return $result + } + catch { + $errorRecord = $_ + $status = Get-DefenderHttpStatusCode -ErrorRecord $errorRecord + $transientReason = Get-DefenderTransientFailureReason -ErrorRecord $errorRecord ` + -TransientHttpStatusCodes $TransientHttpStatusCodes + $transient = -not [string]::IsNullOrWhiteSpace($transientReason) + if (-not $transient -or $attempt -eq $MaxAttempts) { + $stopwatch.Stop() + if ($logEnabled) { + Add-DefenderRunLogEntry -Module 'Invoke-WithTransientRetry' ` + -Action $Description -Status 'Failed' -Attempt $attempt ` + -HttpStatusCode $status ` + -ElapsedMs ([int] $stopwatch.ElapsedMilliseconds) ` + -Detail $errorRecord.Exception.Message + } + throw + } + + $backoff = [math]::Min($BackoffCapSeconds, [math]::Pow(2, $attempt)) + $jitter = Get-Random -Minimum 0 -Maximum ($MaxJitterSeconds + 1) + $sleepSeconds = $backoff + $jitter + if ($logEnabled) { + Add-DefenderRunLogEntry -Module 'Invoke-WithTransientRetry' ` + -Action $Description -Status 'Retried' -Attempt $attempt ` + -HttpStatusCode $status ` + -Detail ("{0} ({1}). Sleeping {2}s before retry." -f ` + $errorRecord.Exception.Message, $transientReason, $sleepSeconds) + } + Start-Sleep -Seconds $sleepSeconds + } + } +} diff --git a/Products/Defender/Modules/Setup-DefenderForBusiness.ps1 b/Products/Defender/Modules/Setup-DefenderForBusiness.ps1 new file mode 100644 index 0000000..dd6bd07 --- /dev/null +++ b/Products/Defender/Modules/Setup-DefenderForBusiness.ps1 @@ -0,0 +1,381 @@ +#requires -Version 7.0 +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +param( + [Parameter(Mandatory)] [hashtable] $Config, + [hashtable] $Context, + [string] $PilotGroupId, + [string] $RecoveryPolicyId, + [switch] $EnableAsrAuditPolicy, + [switch] $RecoverAsrAuditPolicy, + [switch] $RollbackAcknowledged, + [switch] $EnableAsrBlockMode +) + +. (Join-Path $PSScriptRoot 'DefenderRunLog.ps1') +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') +. (Join-Path $PSScriptRoot 'DefenderStateManagement.ps1') +. (Join-Path $PSScriptRoot 'DefenderAsrConfiguration.ps1') + +$policyConfig = $Config.DefenderForBusiness.AsrAuditPolicy +function Stop-DefenderAsrOperation { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Message, + [string] $Action = 'mde-asr-audit-guard', + [string] $Target, + [switch] $ReadbackFailed + ) + + $entry = @{ + Module = 'Setup-DefenderForBusiness' + Action = $Action + BestPracticeKey = $policyConfig.Key + FriendlyName = $policyConfig.Name + Status = 'Failed' + Disposition = 'Blocked' + Target = $Target + Detail = $Message + } + if ($ReadbackFailed) { $entry.Readback = 'Failed' } + Add-DefenderRunLogEntry @entry + throw $Message +} + +function Assert-DefenderAsrWriteAuthorized { + [CmdletBinding()] + param([Parameter(Mandatory)] [string] $OperationKey) + + $selectedOperations = @($Context.PermissionPlan.Operations | Where-Object { + $_.Key -eq $OperationKey + }) + if ($selectedOperations.Count -ne 1) { + Stop-DefenderAsrOperation ` + -Message "The selected permission plan does not authorize '$OperationKey'." ` + -Action $OperationKey + } + if ($selectedOperations[0].Phase -ne 'WriteApply' -or + $selectedOperations[0].VerificationStatus -ne 'Verified') { + Stop-DefenderAsrOperation ` + -Message "The selected permission plan does not contain a verified WriteApply operation for '$OperationKey'." ` + -Action $OperationKey + } +} + +if ($EnableAsrBlockMode) { + Stop-DefenderAsrOperation ` + -Message 'The managed ASR policy supports Audit mode only; block mode is not implemented.' +} +if ($EnableAsrAuditPolicy -and $RecoverAsrAuditPolicy) { + Stop-DefenderAsrOperation ` + -Message 'EnableAsrAuditPolicy and RecoverAsrAuditPolicy cannot be used together.' +} +if ($RecoverAsrAuditPolicy -and -not $RollbackAcknowledged) { + Stop-DefenderAsrOperation ` + -Message 'RecoverAsrAuditPolicy requires RollbackAcknowledged.' +} +if ($EnableAsrAuditPolicy) { + Assert-DefenderAsrWriteAuthorized -OperationKey 'mde-asr-audit-apply' +} +if ($RecoverAsrAuditPolicy) { + Assert-DefenderAsrWriteAuthorized -OperationKey 'mde-asr-audit-recovery' +} + +$serviceGraphRequest = $Context.ConnectionInfo.GraphRequest +if ($serviceGraphRequest -isnot [scriptblock]) { + Stop-DefenderAsrOperation ` + -Message 'The Defender for Business readback requires an authenticated Graph request context.' +} +$retryingGraphRequest = { + param($Method, $Uri, $Body) + Invoke-WithTransientRetry -Description "Microsoft Graph $Method $Uri" -Action { + & $serviceGraphRequest -Method $Method -Uri $Uri -Body $Body + } +} + +try { + $schema = Get-DefenderAsrSchema -GraphRequest $retryingGraphRequest -PolicyConfig $policyConfig +} +catch { + $httpStatus = Get-DefenderHttpStatusCode -ErrorRecord $_ + if ($httpStatus -in @(401, 403)) { + Add-DefenderRunLogEntry -Module 'Setup-DefenderForBusiness' ` + -Action 'AsrAuditReadback' -BestPracticeKey $policyConfig.Key ` + -FriendlyName $policyConfig.Name -Status 'Skipped' ` + -Disposition 'GuidedOnly' -HttpStatusCode $httpStatus ` + -Detail 'Intune ASR configuration discovery is unavailable with the current license or authorization; no tenant change was attempted.' + return + } + Stop-DefenderAsrOperation -Message $_.Exception.Message ` + -Action 'mde-asr-audit-discovery' +} +$expectedBody = New-DefenderAsrAuditPolicyBody -PolicyConfig $policyConfig ` + -ManagedByTag $Config.ManagedByTag -Template $schema.Template ` + -SettingTemplates $schema.SettingTemplates ` + -ParentDefinitions $schema.ParentDefinitions ` + -ChildDefinitions $schema.ChildDefinitions ` + -ScopeTagIds $schema.ScopeTagIds + +$namedPolicies = @($schema.Policies | Where-Object { $_.name -ceq $policyConfig.Name }) +if ($namedPolicies.Count -gt 1) { + Stop-DefenderAsrOperation ` + -Message "Multiple Intune policies use the managed ASR policy name '$($policyConfig.Name)'." +} +$pilotGroupGuid = [guid]::Empty +if (($EnableAsrAuditPolicy -or $RecoverAsrAuditPolicy) -and + ([string]::IsNullOrWhiteSpace($PilotGroupId) -or + -not [guid]::TryParse($PilotGroupId, [ref] $pilotGroupGuid))) { + Stop-DefenderAsrOperation ` + -Message 'ASR apply and recovery operations require PilotGroupId as a valid group GUID.' +} +if ($EnableAsrAuditPolicy -or $RecoverAsrAuditPolicy) { + $encodedPilotGroupId = [uri]::EscapeDataString($pilotGroupGuid.ToString()) + $pilotGroup = & $retryingGraphRequest -Method GET ` + -Uri "https://graph.microsoft.com/v1.0/groups/$encodedPilotGroupId`?`$select=id,displayName,securityEnabled" + if ([string] $pilotGroup.id -ne $pilotGroupGuid.ToString() -or + $pilotGroup.securityEnabled -ne $true) { + Stop-DefenderAsrOperation ` + -Message 'PilotGroupId must resolve to the exact security-enabled Entra group.' + } +} +if ($RecoverAsrAuditPolicy) { + $recoveryPolicyGuid = [guid]::Empty + if ([string]::IsNullOrWhiteSpace($RecoveryPolicyId) -or + -not [guid]::TryParse($RecoveryPolicyId, [ref] $recoveryPolicyGuid)) { + Stop-DefenderAsrOperation ` + -Message 'RecoverAsrAuditPolicy requires RecoveryPolicyId as the captured policy GUID.' ` + -Action 'mde-asr-audit-recovery' + } + if ($namedPolicies.Count -ne 1 -or + [string] $namedPolicies[0].id -ne $recoveryPolicyGuid.ToString()) { + Stop-DefenderAsrOperation ` + -Message 'The captured ASR recovery policy was not found under the exact managed policy name.' ` + -Action 'mde-asr-audit-recovery' -Target $RecoveryPolicyId + } + if (-not ([string] $namedPolicies[0].description).Contains( + $Config.ManagedByTag, + [StringComparison]::Ordinal)) { + Stop-DefenderAsrOperation ` + -Message 'The captured ASR recovery policy does not contain the toolkit ownership marker.' ` + -Action 'mde-asr-audit-recovery' -Target $RecoveryPolicyId + } + + $encodedRecoveryPolicyId = [uri]::EscapeDataString($recoveryPolicyGuid.ToString()) + try { + $recoveryPolicy = & $retryingGraphRequest -Method GET ` + -Uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies/$encodedRecoveryPolicyId" + $recoverySettings = & $retryingGraphRequest -Method GET ` + -Uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies/$encodedRecoveryPolicyId/settings" + $null = Assert-DefenderAsrPolicyReadback -Policy $recoveryPolicy ` + -Settings @($recoverySettings.value) -ExpectedBody $expectedBody ` + -ManagedByTag $Config.ManagedByTag + $recoveryAssignments = @(Get-DefenderAsrGraphCollection ` + -GraphRequest $retryingGraphRequest ` + -InitialUri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies/$encodedRecoveryPolicyId/assignments") + $null = Assert-DefenderAsrAssignmentReadback ` + -Assignments $recoveryAssignments -PilotGroupId $pilotGroupGuid + } + catch { + Stop-DefenderAsrOperation -Message $_.Exception.Message ` + -Action 'mde-asr-audit-recovery' -Target $recoveryPolicyGuid ` + -ReadbackFailed + } + + if (-not $PSCmdlet.ShouldProcess( + $policyConfig.Name, + "Delete captured pilot-owned ASR policy '$recoveryPolicyGuid'")) { + Add-DefenderRunLogEntry -Module 'Setup-DefenderForBusiness' ` + -Action 'mde-asr-audit-recovery' -BestPracticeKey $policyConfig.Key ` + -FriendlyName $policyConfig.Name -Status 'Skipped' ` + -Disposition 'Skipped' -Target $recoveryPolicyGuid ` + -Detail 'The captured pilot-owned ASR policy would be deleted after exact ownership and assignment-isolation readback.' + return + } + + $null = & $retryingGraphRequest -Method DELETE ` + -Uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies/$encodedRecoveryPolicyId" + $remainingPolicies = @(Get-DefenderAsrGraphCollection ` + -GraphRequest $retryingGraphRequest ` + -InitialUri 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies') + if (@($remainingPolicies | Where-Object { + [string] $_.id -eq $recoveryPolicyGuid.ToString() + }).Count -ne 0) { + Stop-DefenderAsrOperation ` + -Message 'ASR recovery deletion readback still returned the captured policy identifier.' ` + -Action 'mde-asr-audit-recovery' -Target $recoveryPolicyGuid ` + -ReadbackFailed + } + Add-DefenderRunLogEntry -Module 'Setup-DefenderForBusiness' ` + -Action 'mde-asr-audit-recovery' -BestPracticeKey $policyConfig.Key ` + -FriendlyName $policyConfig.Name -Status 'Succeeded' ` + -Disposition 'WillChange' -Target $recoveryPolicyGuid ` + -Readback 'Succeeded' ` + -Detail 'Deleted the captured pilot-owned ASR policy and confirmed that its identifier is absent.' + return +} +if ($namedPolicies.Count -eq 0) { + if ($EnableAsrAuditPolicy) { + if (-not $PSCmdlet.ShouldProcess( + $policyConfig.Name, + "Create the ASR Audit policy and assign it to pilot group '$pilotGroupGuid'")) { + Add-DefenderRunLogEntry -Module 'Setup-DefenderForBusiness' ` + -Action 'mde-asr-audit-apply' -BestPracticeKey $policyConfig.Key ` + -FriendlyName $policyConfig.Name -Status 'Skipped' ` + -Disposition 'Skipped' ` + -Detail 'The ASR Audit policy and isolated pilot-group assignment would be created.' + return + } + + $policyId = $null + try { + $createdPolicy = & $retryingGraphRequest -Method POST ` + -Uri 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies' ` + -Body $expectedBody + $policyId = [string] $createdPolicy.id + if ([string]::IsNullOrWhiteSpace($policyId)) { + throw 'The ASR policy create response did not expose the created policy identifier.' + } + Add-DefenderRunLogEntry -Module 'Setup-DefenderForBusiness' ` + -Action 'mde-asr-audit-create' -BestPracticeKey $policyConfig.Key ` + -FriendlyName $policyConfig.Name -Status 'Created' ` + -Disposition 'WillChange' -Target $policyId ` + -Detail 'Created the managed ASR Audit policy; exact policy readback is required before assignment.' + + $encodedPolicyId = [uri]::EscapeDataString($policyId) + $policyReadback = & $retryingGraphRequest -Method GET ` + -Uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies/$encodedPolicyId" + $settingsReadback = & $retryingGraphRequest -Method GET ` + -Uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies/$encodedPolicyId/settings" + $null = Assert-DefenderAsrPolicyReadback -Policy $policyReadback ` + -Settings @($settingsReadback.value) -ExpectedBody $expectedBody ` + -ManagedByTag $Config.ManagedByTag + + $assignmentBody = New-DefenderAsrAssignmentBody -PilotGroupId $pilotGroupGuid + $null = & $retryingGraphRequest -Method POST ` + -Uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies/$encodedPolicyId/assign" ` + -Body $assignmentBody + $assignmentsReadback = @(Get-DefenderAsrGraphCollection ` + -GraphRequest $retryingGraphRequest ` + -InitialUri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies/$encodedPolicyId/assignments") + $null = Assert-DefenderAsrAssignmentReadback ` + -Assignments $assignmentsReadback -PilotGroupId $pilotGroupGuid + Add-DefenderRunLogEntry -Module 'Setup-DefenderForBusiness' ` + -Action 'mde-asr-audit-apply' -BestPracticeKey $policyConfig.Key ` + -FriendlyName $policyConfig.Name -Status 'Succeeded' ` + -Disposition 'WillChange' -Target $policyId -Readback 'Succeeded' ` + -Detail 'Created the ASR Audit policy and confirmed its isolated direct pilot-group assignment.' + } + catch { + $continuation = Get-DefenderContinuationCommand ` + -Reason 'the ASR apply operation did not complete exact policy and assignment readback' + Add-DefenderRunLogEntry -Module 'Setup-DefenderForBusiness' ` + -Action 'mde-asr-audit-apply' -BestPracticeKey $policyConfig.Key ` + -FriendlyName $policyConfig.Name -Status 'Failed' ` + -Disposition 'Blocked' -Target ([string] $policyId) ` + -Readback 'Failed' ` + -Detail "$($_.Exception.Message) $continuation" + throw + } + return + } + + Add-DefenderRunLogEntry -Module 'Setup-DefenderForBusiness' ` + -Action 'AsrAuditReadback' -BestPracticeKey $policyConfig.Key ` + -FriendlyName $policyConfig.Name -Status 'Skipped' -Disposition 'GuidedOnly' ` + -Detail 'ASR schema validated. No policy with the configured managed name exists; apply remains GuidedOnly.' + return +} +if (-not ([string] $namedPolicies[0].description).Contains( + $Config.ManagedByTag, + [StringComparison]::Ordinal)) { + Stop-DefenderAsrOperation ` + -Message "An unmanaged Intune policy already uses the ASR policy name '$($policyConfig.Name)'; implicit adoption is not allowed." +} + +$policyId = [string] $namedPolicies[0].id +if ([string]::IsNullOrWhiteSpace($policyId)) { + Stop-DefenderAsrOperation ` + -Message 'The managed ASR policy collection result did not expose its policy identifier.' +} +$encodedPolicyId = [uri]::EscapeDataString($policyId) +try { + $policyReadback = & $retryingGraphRequest -Method GET ` + -Uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies/$encodedPolicyId" + $settingsReadback = & $retryingGraphRequest -Method GET ` + -Uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies/$encodedPolicyId/settings" + $null = Assert-DefenderAsrPolicyReadback -Policy $policyReadback ` + -Settings @($settingsReadback.value) -ExpectedBody $expectedBody ` + -ManagedByTag $Config.ManagedByTag +} +catch { + Stop-DefenderAsrOperation -Message $_.Exception.Message ` + -Action 'AsrAuditReadback' -Target $policyId -ReadbackFailed +} + +if ([string]::IsNullOrWhiteSpace($PilotGroupId)) { + Add-DefenderRunLogEntry -Module 'Setup-DefenderForBusiness' ` + -Action 'AsrAuditReadback' -BestPracticeKey $policyConfig.Key ` + -FriendlyName $policyConfig.Name -Status 'Skipped' -Disposition 'GuidedOnly' ` + -Target $policyId ` + -Detail 'Exact managed policy readback passed; assignment readback requires the approved pilot group identifier.' + return +} + +if (-not [guid]::TryParse($PilotGroupId, [ref] $pilotGroupGuid)) { + Stop-DefenderAsrOperation -Message 'PilotGroupId must be a valid group GUID.' +} +$existingAssignments = @(Get-DefenderAsrGraphCollection ` + -GraphRequest $retryingGraphRequest ` + -InitialUri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies/$encodedPolicyId/assignments") +if ($existingAssignments.Count -eq 0 -and $EnableAsrAuditPolicy) { + if (-not $PSCmdlet.ShouldProcess( + $policyConfig.Name, + "Assign the verified ASR Audit policy to pilot group '$pilotGroupGuid'")) { + Add-DefenderRunLogEntry -Module 'Setup-DefenderForBusiness' ` + -Action 'mde-asr-audit-assign' -BestPracticeKey $policyConfig.Key ` + -FriendlyName $policyConfig.Name -Status 'Skipped' ` + -Disposition 'Skipped' -Target $policyId ` + -Detail 'The verified ASR Audit policy would be assigned to the approved pilot group.' + return + } + try { + $assignmentBody = New-DefenderAsrAssignmentBody -PilotGroupId $pilotGroupGuid + $null = & $retryingGraphRequest -Method POST ` + -Uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies/$encodedPolicyId/assign" ` + -Body $assignmentBody + $existingAssignments = @(Get-DefenderAsrGraphCollection ` + -GraphRequest $retryingGraphRequest ` + -InitialUri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies/$encodedPolicyId/assignments") + } + catch { + $continuation = Get-DefenderContinuationCommand ` + -Reason 'the verified ASR policy could not be assigned and confirmed' + Add-DefenderRunLogEntry -Module 'Setup-DefenderForBusiness' ` + -Action 'mde-asr-audit-assign' -BestPracticeKey $policyConfig.Key ` + -FriendlyName $policyConfig.Name -Status 'Failed' ` + -Disposition 'Blocked' -Target $policyId -Readback 'Failed' ` + -Detail "$($_.Exception.Message) $continuation" + throw + } +} +if ($existingAssignments.Count -eq 0) { + Add-DefenderRunLogEntry -Module 'Setup-DefenderForBusiness' ` + -Action 'AsrAuditReadback' -BestPracticeKey $policyConfig.Key ` + -FriendlyName $policyConfig.Name -Status 'Skipped' ` + -Disposition 'GuidedOnly' -Target $policyId -Readback 'Pending' ` + -Detail 'Exact managed policy readback passed, but the policy has no assignment; enable the ASR Audit operation to add the approved pilot-group assignment.' + return +} +try { + $null = Assert-DefenderAsrAssignmentReadback -Assignments $existingAssignments ` + -PilotGroupId $pilotGroupGuid +} +catch { + Stop-DefenderAsrOperation -Message $_.Exception.Message ` + -Action 'AsrAuditReadback' -Target $policyId -ReadbackFailed +} +Add-DefenderRunLogEntry -Module 'Setup-DefenderForBusiness' ` + -Action 'AsrAuditReadback' -BestPracticeKey $policyConfig.Key ` + -FriendlyName $policyConfig.Name -Status 'Succeeded' ` + -Disposition 'AlreadyCompliant' -Target $policyId ` + -Readback 'Succeeded' ` + -Detail 'Exact managed policy and isolated direct-group assignment readback passed.' diff --git a/Products/Defender/Modules/Setup-DefenderForCloudApps.ps1 b/Products/Defender/Modules/Setup-DefenderForCloudApps.ps1 new file mode 100644 index 0000000..d829279 --- /dev/null +++ b/Products/Defender/Modules/Setup-DefenderForCloudApps.ps1 @@ -0,0 +1,18 @@ +#requires -Version 7.0 +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +# smb-quality-gate: read-only +param( + [Parameter(Mandatory)] [hashtable] $Config, + [hashtable] $Context, + [switch] $IncludeHighRisk, + [switch] $EnableMdcaEnforcement, + [switch] $CloudDiscoveryReviewed +) + +. (Join-Path $PSScriptRoot 'DefenderRunLog.ps1') +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') +. (Join-Path $PSScriptRoot 'DefenderStateManagement.ps1') + +Add-DefenderRunLogEntry -Module 'Setup-DefenderForCloudApps' ` + -Action 'Module' -Status 'Info' ` + -Detail 'MDCA operations are reserved for verified supported APIs.' diff --git a/Products/Defender/Modules/Setup-DefenderPreflight.ps1 b/Products/Defender/Modules/Setup-DefenderPreflight.ps1 new file mode 100644 index 0000000..7e1c246 --- /dev/null +++ b/Products/Defender/Modules/Setup-DefenderPreflight.ps1 @@ -0,0 +1,144 @@ +#requires -Version 7.0 +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +# smb-quality-gate: read-only +param( + [Parameter(Mandatory)] [hashtable] $Config, + [hashtable] $Context, + [Parameter(Mandatory)] [string] $TenantAdminUpn +) + +. (Join-Path $PSScriptRoot 'DefenderRunLog.ps1') +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') +. (Join-Path $PSScriptRoot 'Get-DefenderPermissionPlan.ps1') + +function Test-DefenderApiCapability { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [hashtable] $ApiDefinition, + [Parameter(Mandatory)] [string] $TenantAdminUpn + ) + + $missing = @($ApiDefinition.RequiredCommands | Where-Object { + -not (Get-Command -Name $_ -ErrorAction SilentlyContinue) + }) + if ($missing.Count -gt 0) { + throw "Required Defender API commands are unavailable: $($missing -join ', ')" + } + + $graphContext = Get-MgContext + if (-not $graphContext) { + throw 'Microsoft Graph context is not available after connection.' + } + Assert-DefenderGraphAccount -GraphContext $graphContext -TenantAdminUpn $TenantAdminUpn + $account = Protect-DefenderIdentity -Value ([string] $graphContext.Account) + Add-DefenderRunLogEntry -Module 'Setup-DefenderPreflight' ` + -Action 'ApiCapability' -Status 'Succeeded' ` + -Detail "Graph context available. AuthenticationType=$($graphContext.AuthType); Account=$account." +} + +function Write-DefenderGuidedReadiness { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Action, + [Parameter(Mandatory)] [string] $Detail, + [ValidateSet('Applicable','AlreadyCompliant','WillChange','GuidedOnly','Skipped','Blocked')] + [string] $Disposition = 'GuidedOnly' + ) + + Add-DefenderRunLogEntry -Module 'Setup-DefenderPreflight' ` + -Action $Action -Status 'Info' -Disposition $Disposition ` + -Detail $Detail +} + +function Write-DefenderWorkloadCapabilities { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object[]] $Capabilities + ) + + foreach ($capability in @($Capabilities)) { + $status = if ([string] $capability.Status -eq 'Verified') { + 'Succeeded' + } + else { + 'Skipped' + } + $disposition = if ([string] $capability.Status -eq 'Verified') { + 'Applicable' + } + else { + 'GuidedOnly' + } + Add-DefenderRunLogEntry -Module 'Setup-DefenderPreflight' ` + -Action 'WorkloadCapability' -Status $status ` + -Disposition $disposition -Detail ( + "Capability=$($capability.Key); Workload=$($capability.Workload); " + + "Mode=$($capability.Mode); $($capability.Detail)" + ) + } +} + +Add-DefenderRunLogEntry -Module 'Setup-DefenderPreflight' ` + -Action 'Preflight' -Status 'Started' -Detail 'Read-only capability checks started.' + +try { + if (-not $Context) { + throw 'Setup-DefenderPreflight.ps1 requires -Context for Graph authentication. Run it through Deploy-DefenderBestPractice.ps1 (the orchestrator) or provide -Context with the required connection settings.' + } + $connectScript = Join-Path $PSScriptRoot 'Connect-DefenderServices.ps1' + if ($Context.NonInteractive -and + ([string]::IsNullOrWhiteSpace([string] $Context.ClientId) -or + [string]::IsNullOrWhiteSpace([string] $Context.CertificateThumbprint) -or + [string]::IsNullOrWhiteSpace([string] $Context.TenantId))) { + throw 'Setup-DefenderPreflight.ps1 requires TenantId, ClientId, and CertificateThumbprint for noninteractive Graph authentication; delegated sign-in cannot prompt.' + } + $connectArgs = @{ + TenantAdminUpn = $TenantAdminUpn + TenantId = $Context.TenantId + ClientId = $Context.ClientId + CertificateThumbprint = $Context.CertificateThumbprint + DelegatedOrganization = $Context.DelegatedOrganization + NonInteractive = [bool] $Context.NonInteractive + ConnectGraph = $true + GraphScopes = @($Context.PermissionPlan.GraphDelegatedScopes) + } + # Dot-sourcing retains the verified Graph request helper in this module's + # current runspace. The orchestrator invokes this script as a separate + # script invocation, not as a child PowerShell process. + . $connectScript @connectArgs | Out-Null + Test-DefenderApiCapability -ApiDefinition $Config.Api -TenantAdminUpn $TenantAdminUpn + Write-DefenderWorkloadCapabilities -Capabilities $Config.WorkloadCapabilities + + foreach ($item in @($Config.BestPracticeItems)) { + $capabilityKey = [string] $item.LicenseCapability + $definition = $Config.LicenseCapabilities[$capabilityKey] + if (-not $definition) { + Add-DefenderRunLogEntry -Module $item.Module ` + -Action 'LicenseDocumentation' -BestPracticeKey $item.Key ` + -Status 'Info' -Disposition 'GuidedOnly' ` + -Detail 'No licensing documentation mapping is configured.' + continue + } + Add-DefenderRunLogEntry -Module $item.Module ` + -Action 'LicenseDocumentation' -BestPracticeKey $item.Key ` + -Status 'Info' -Disposition 'GuidedOnly' ` + -Detail "Licensing prerequisite is documented only; no SKU entitlement inference is performed. Requirement=$($definition.Requirement); Documentation=$($definition.DocumentationUrl)" + } + Write-DefenderGuidedReadiness -Action 'AuditReadiness' ` + -Disposition $Config.Preflight.AuditReadinessDisposition ` + -Detail 'Audit readiness requires tenant-specific retention and audit policy review; no stable least-privilege unattended check is claimed.' + Write-DefenderGuidedReadiness -Action 'EmergencyAccessSafety' ` + -Disposition $Config.Preflight.EmergencyAccessDisposition ` + -Detail 'Emergency-access account coverage and break-glass exclusions require operator confirmation before enforcement.' + + Add-DefenderRunLogEntry -Module 'Setup-DefenderPreflight' ` + -Action 'Preflight' -Status 'Succeeded' ` + -Detail 'Preflight framework completed without state changes.' +} +catch { + $status = Get-DefenderHttpStatusCode -ErrorRecord $_ + Add-DefenderRunLogEntry -Module 'Setup-DefenderPreflight' ` + -Action 'Preflight' -Status 'Failed' -HttpStatusCode $status ` + -Detail $_.Exception.Message + throw +} diff --git a/Products/Defender/Modules/Setup-MdeAdvanced.ps1 b/Products/Defender/Modules/Setup-MdeAdvanced.ps1 new file mode 100644 index 0000000..784498c --- /dev/null +++ b/Products/Defender/Modules/Setup-MdeAdvanced.ps1 @@ -0,0 +1,17 @@ +#requires -Version 7.0 +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +# smb-quality-gate: read-only +param( + [Parameter(Mandatory)] [hashtable] $Config, + [hashtable] $Context, + [switch] $IncludeHighRisk, + [switch] $EnableAutomatedRemediation +) + +. (Join-Path $PSScriptRoot 'DefenderRunLog.ps1') +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') +. (Join-Path $PSScriptRoot 'DefenderStateManagement.ps1') + +Add-DefenderRunLogEntry -Module 'Setup-MdeAdvanced' ` + -Action 'Module' -Status 'Info' ` + -Detail 'MDE advanced operations are reserved for verified supported APIs.' diff --git a/Products/Defender/Modules/Setup-MdoEopBaseline.ps1 b/Products/Defender/Modules/Setup-MdoEopBaseline.ps1 new file mode 100644 index 0000000..1fc2bd8 --- /dev/null +++ b/Products/Defender/Modules/Setup-MdoEopBaseline.ps1 @@ -0,0 +1,498 @@ +#requires -Version 7.0 +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +param( + [Parameter(Mandatory)] [hashtable] $Config, + [hashtable] $Context, + [switch] $AdoptExisting, + [switch] $IncludeHighRisk, + [switch] $EnableMailFlowImpactingChanges, + [switch] $RollbackAcknowledged +) + +. (Join-Path $PSScriptRoot 'DefenderRunLog.ps1') +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') +. (Join-Path $PSScriptRoot 'DefenderStateManagement.ps1') +. (Join-Path $PSScriptRoot 'Assert-DefenderExchangeParameterRbac.ps1') +. (Join-Path $PSScriptRoot 'Connect-DefenderServices.ps1') ` + -TenantAdminUpn ([string]$Context.TenantAdminUpn) | Out-Null + +$moduleName = 'Setup-MdoEopBaseline' +$safeAttachmentsOperationKey = 'mdo-safe-attachments-apply' +$operationKey = 'mdo-auto-forward-apply' +$quarantineOperationKey = 'mdo-quarantine-policy-apply' +$safeAttachmentsBestPracticeKey = [string]@($Config.BestPracticeItems | Where-Object { + $_.PermissionOperations -contains 'mdo-safe-attachments-read' +})[0].Key +$autoForwardBestPracticeKey = [string]@($Config.BestPracticeItems | Where-Object { + $_.PermissionOperations -contains 'mdo-auto-forward-read' +})[0].Key +$quarantineBestPracticeKey = [string]@($Config.BestPracticeItems | Where-Object { + $_.PermissionOperations -contains 'mdo-quarantine-policy-read' +})[0].Key +$desiredMode = 'Off' +$quarantinePolicyConfig = $Config.DefenderForOffice365.QuarantinePolicy + +function Get-DefenderQuarantinePolicyExact { + [CmdletBinding()] + param([Parameter(Mandatory)] [string] $Identity) + + if ([string]::IsNullOrWhiteSpace($Identity)) { + throw 'The quarantine policy identity must not be empty.' + } + $candidates = @(Get-QuarantinePolicy -ErrorAction Stop | + Where-Object { [string]$_.Name -ieq $Identity }) + if ($candidates.Count -gt 1) { + throw "Multiple quarantine policies matched name '$Identity'." + } + $exactMatches = @($candidates | + Where-Object { [string]$_.Name -ceq $Identity }) + if ($candidates.Count -eq 1 -and $exactMatches.Count -eq 0) { + throw "Quarantine policy '$([string]$candidates[0].Name)' already exists and differs from the configured name '$Identity' only by capitalization. Exchange Online treats these as the same policy, so a create would be rejected. Align the configured name with the existing policy name before continuing." + } + return @($exactMatches)[0] +} + +function Assert-DefenderExchangeCommandParameters { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Command, + [Parameter(Mandatory)] [string[]] $Parameters + ) + + $commandInfo = Get-Command -Name $Command -ErrorAction Stop + foreach ($parameter in $Parameters) { + if (-not $commandInfo.Parameters.ContainsKey($parameter)) { + throw "The connected Exchange command '$Command' does not expose required parameter '-$parameter'." + } + } +} + +Connect-DefenderExchangeOnline -TenantAdminUpn ([string]$Context.TenantAdminUpn) ` + -DelegatedOrganization ([string]$Context.DelegatedOrganization) ` + -NonInteractive:([bool]$Context.NonInteractive) ` + -UseDeviceAuthentication:([bool]$Context.UseDeviceAuthentication) | Out-Null + +$quarantineError = $null +if ($null -eq $quarantinePolicyConfig) { + Add-DefenderRunLogEntry -Module $moduleName -Action $quarantineOperationKey ` + -BestPracticeKey $quarantineBestPracticeKey ` + -Status 'Skipped' -Disposition 'Skipped' ` + -Detail 'The supplied Defender configuration does not define DefenderForOffice365.QuarantinePolicy, so the quarantine policy baseline was skipped and the remaining Defender for Office 365 assessments continued.' +} +else { + $quarantineLogBaseline = if ($null -eq $global:DefenderRunLog) { 0 } else { @($global:DefenderRunLog).Count } + try { + $quarantinePolicyName = [string]$quarantinePolicyConfig.Name + $builtInQuarantinePolicies = @( + 'AdminOnlyAccessPolicy' + 'DefaultFullAccessPolicy' + 'DefaultFullAccessWithNotificationPolicy' + ) + if ([string]::IsNullOrWhiteSpace($quarantinePolicyName)) { + throw 'Defender configuration must define a quarantine policy name.' + } + if ($quarantinePolicyName -in $builtInQuarantinePolicies) { + throw "The configured quarantine policy '$quarantinePolicyName' is built in and cannot be managed." + } + if (@($quarantinePolicyConfig.ProtectionPolicyAssignments).Count -ne 0) { + throw 'The quarantine baseline does not support protection-policy assignment; ProtectionPolicyAssignments must remain empty.' + } + + $currentQuarantinePolicy = Invoke-WithTransientRetry ` + -Description "Read quarantine policy '$quarantinePolicyName'" ` + -Action { Get-DefenderQuarantinePolicyExact -Identity $quarantinePolicyName } + if ($null -ne $currentQuarantinePolicy) { + foreach ($propertyName in @( + 'EndUserQuarantinePermissionsValue' + 'ESNEnabled' + 'IncludeMessagesFromBlockedSenderAddress')) { + if ($null -eq $currentQuarantinePolicy.PSObject.Properties[$propertyName] -or + $null -eq $currentQuarantinePolicy.$propertyName) { + throw "Quarantine policy '$quarantinePolicyName' did not return required property '$propertyName'." + } + } + } + + $quarantineDefinition = $Config.StateManagement.ObjectDefinitions.QuarantinePolicy + $quarantineTagProperty = [string]$quarantineDefinition.ManagedTagProperty + if ($AdoptExisting -and $null -ne $currentQuarantinePolicy -and + [string]$currentQuarantinePolicy.$quarantineTagProperty -cne [string]$Config.ManagedByTag) { + throw "Adoption of the existing quarantine policy '$quarantinePolicyName' is not supported. The toolkit cannot read which anti-spam, anti-phishing, anti-malware, or Safe Attachments policies already reference this policy, so changing its end-user permissions could alter live recipient behaviour. Rename or remove the existing policy, or configure a different quarantine policy name, then re-run without -AdoptExisting." + } + + $quarantineDesired = @{ + Name = $quarantinePolicyName + EndUserQuarantinePermissionsValue = [int]$quarantinePolicyConfig.EndUserQuarantinePermissionsValue + ESNEnabled = [bool]$quarantinePolicyConfig.ESNEnabled + IncludeMessagesFromBlockedSenderAddress = [bool]$quarantinePolicyConfig.IncludeMessagesFromBlockedSenderAddress + } + $quarantineItem = [pscustomobject]@{ + Current = $currentQuarantinePolicy + Desired = $quarantineDesired + } + try { + $quarantinePlan = Resolve-DefenderStatePlan -Current $currentQuarantinePolicy ` + -Desired $quarantineDesired -Definition $quarantineDefinition ` + -Config $Config -AdoptExisting:$AdoptExisting + } + catch { + Add-DefenderRunLogEntry -Module $moduleName -Action $quarantineOperationKey ` + -BestPracticeKey $quarantinePolicyConfig.Key ` + -Status 'Failed' -Disposition 'Blocked' -Detail $_.Exception.Message + throw + } + + if ($quarantinePlan.Action -eq 'Update') { + throw "Quarantine policy '$quarantinePolicyName' differs from the selected state, but updates to existing quarantine policies are not supported. The toolkit cannot verify which protection policies reference it. Restore the selected values manually or use a different policy name." + } + + if ($quarantinePlan.Action -eq 'NoOp') { + Add-DefenderRunLogEntry -Module $moduleName -Action $quarantineOperationKey ` + -BestPracticeKey $quarantinePolicyConfig.Key ` + -Status 'Succeeded' -Disposition 'AlreadyCompliant' ` + -Detail "Quarantine policy '$quarantinePolicyName' already matches the selected configuration; no protection-policy assignments were changed." + } + else { + $quarantineWriteOperation = @($Config.PermissionModel.Operations | Where-Object { + $_.Key -eq $quarantineOperationKey + })[0] + $quarantineWriteVerified = $quarantineWriteOperation.Phase -eq 'WriteApply' -and + $quarantineWriteOperation.VerificationStatus -eq 'Verified' + if (-not $quarantineWriteVerified) { + Add-DefenderRunLogEntry -Module $moduleName -Action $quarantineOperationKey ` + -BestPracticeKey $quarantinePolicyConfig.Key ` + -Status 'Skipped' -Disposition 'GuidedOnly' ` + -Detail "Quarantine policy '$quarantinePolicyName' requires action '$($quarantinePlan.Action)' to apply limited access, notifications enabled, blocked-sender messages excluded, and no protection-policy assignments; the write remains guided-only pending evidence-based approval." + } + else { + $selectedQuarantineWriteOperation = @($Context.PermissionPlan.Operations | + Where-Object { $_.Key -eq $quarantineOperationKey })[0] + if ($null -eq $selectedQuarantineWriteOperation) { + throw "The selected permission plan does not authorize '$quarantineOperationKey'. Add the apply operation to the quarantine best-practice item before enabling this write." + } + if ($selectedQuarantineWriteOperation.Phase -ne 'WriteApply' -or + $selectedQuarantineWriteOperation.VerificationStatus -ne 'Verified') { + throw "The selected permission plan does not contain a verified WriteApply operation for '$quarantineOperationKey'." + } + $quarantineResults = @(Invoke-DefenderManagedWrite ` + -Items @($quarantineItem) -Definition $quarantineDefinition ` + -Config $Config -ModuleName $moduleName ` + -RollbackGuidance 'The created policy remains unassigned. Leave it in place pending an explicit removal decision.' ` + -AdoptExisting:$AdoptExisting -Confirm:$false -WhatIf:$WhatIfPreference ` + -WriteAction { + param($Plan) + if ($Plan.Action -ne 'Create') { + throw "Quarantine policy action '$($Plan.Action)' is not supported; this path is create-only." + } + $command = 'New-QuarantinePolicy' + $parameters = @( + 'Name' + 'AdminDisplayName' + 'EndUserQuarantinePermissionsValue' + 'ESNEnabled' + 'IncludeMessagesFromBlockedSenderAddress' + ) + Assert-DefenderExchangeCommandParameters -Command $command ` + -Parameters $parameters + $roleAssignments = @(Get-ManagementRoleAssignment ` + -RoleAssignee ([string]$Context.TenantAdminUpn) ` + -Delegating:$false -ErrorAction Stop) + foreach ($parameter in $parameters) { + Assert-DefenderExchangeParameterRbac ` + -RoleAssignee ([string]$Context.TenantAdminUpn) ` + -Command $command -Parameter $parameter ` + -RoleAssignments $roleAssignments | Out-Null + } + $writeParameters = @{ + EndUserQuarantinePermissionsValue = [int]$Plan.State.EndUserQuarantinePermissionsValue + ESNEnabled = [bool]$Plan.State.ESNEnabled + IncludeMessagesFromBlockedSenderAddress = [bool]$Plan.State.IncludeMessagesFromBlockedSenderAddress + ErrorAction = 'Stop' + Name = [string]$Plan.State.Name + AdminDisplayName = [string]$Plan.State.AdminDisplayName + } + New-QuarantinePolicy @writeParameters | Out-Null + } ` + -ReadbackAction { + param($Plan) + Get-DefenderQuarantinePolicyExact -Identity ([string]$Plan.State.Name) + }) + foreach ($quarantineResult in $quarantineResults) { + $quarantineStatus = if ($quarantineResult.WriteOutcome -eq 'Skipped') { + 'Skipped' + } + else { + $quarantineResult.Recovery.RunLogStatus + } + $quarantineDisposition = if ($quarantineResult.WriteOutcome -eq 'Skipped') { + 'Skipped' + } + else { + $quarantineResult.Recovery.Disposition + } + $quarantineDetail = $quarantineResult.Detail + if ($quarantineResult.Recovery.ContinuationCommand) { + $quarantineDetail = "$quarantineDetail $($quarantineResult.Recovery.ContinuationCommand)" + } + Add-DefenderRunLogEntry -Module $moduleName -Action $quarantineOperationKey ` + -BestPracticeKey $quarantinePolicyConfig.Key ` + -Status $quarantineStatus -Disposition $quarantineDisposition ` + -Readback $quarantineResult.Readback -Detail $quarantineDetail + } + $quarantineFailures = @($quarantineResults | Where-Object { + $_.WriteOutcome -eq 'Failed' -or + $_.Readback -eq 'Failed' -or + $_.Recovery.Disposition -eq 'Blocked' + }) + if ($quarantineFailures.Count -gt 0) { + throw 'Quarantine policy managed write failed or could not be confirmed by readback.' + } + } + } + } + catch { + $quarantineError = $_ + $loggedFailures = @($global:DefenderRunLog | + Select-Object -Skip $quarantineLogBaseline | + Where-Object { $_.Action -eq $quarantineOperationKey -and $_.Status -eq 'Failed' }) + if ($loggedFailures.Count -eq 0) { + Add-DefenderRunLogEntry -Module $moduleName -Action $quarantineOperationKey ` + -BestPracticeKey $quarantinePolicyConfig.Key ` + -Status 'Failed' -Disposition 'Blocked' -Detail $_.Exception.Message + } + } +} + +$safeAttachmentsPolicy = Invoke-WithTransientRetry ` + -Description 'Read the default ATP policy' ` + -Action { Get-AtpPolicyForO365 -Identity 'Default' -ErrorAction Stop } +$safeAttachmentsEnabled = $safeAttachmentsPolicy.EnableATPForSPOTeamsODB +if ($null -eq $safeAttachmentsEnabled) { + throw 'The default ATP policy did not return EnableATPForSPOTeamsODB.' +} + +if ([bool]$safeAttachmentsEnabled) { + Add-DefenderRunLogEntry -Module $moduleName ` + -Action $safeAttachmentsOperationKey -Status 'Succeeded' ` + -BestPracticeKey $safeAttachmentsBestPracticeKey ` + -Disposition 'AlreadyCompliant' ` + -Detail 'Safe Attachments is already enabled for SharePoint, OneDrive, and Teams.' +} +else { + $safeAttachmentsWriteOperation = @($Config.PermissionModel.Operations | Where-Object { + $_.Key -eq $safeAttachmentsOperationKey + })[0] + $safeAttachmentsWriteVerified = $safeAttachmentsWriteOperation.Phase -eq 'WriteApply' -and + $safeAttachmentsWriteOperation.VerificationStatus -eq 'Verified' + if ($safeAttachmentsWriteVerified) { + if ($PSCmdlet.ShouldProcess( + 'Default ATP policy', + 'Enable Safe Attachments for SharePoint, OneDrive, and Teams')) { + Assert-DefenderExchangeParameterRbac ` + -RoleAssignee ([string]$Context.TenantAdminUpn) ` + -Command 'Set-AtpPolicyForO365' ` + -Parameter 'EnableATPForSPOTeamsODB' | Out-Null + $safeAttachmentsDefinition = @{ + IdentifierProperties = @('Identity') + ManagedProperties = @('EnableATPForSPOTeamsODB') + } + $safeAttachmentsItems = @([pscustomobject]@{ + Current = @{ + Identity = 'Default' + EnableATPForSPOTeamsODB = [bool]$safeAttachmentsEnabled + } + Desired = @{ + Identity = 'Default' + EnableATPForSPOTeamsODB = $true + } + }) + $safeAttachmentsResults = @(Invoke-DefenderManagedWrite ` + -Items $safeAttachmentsItems -Definition $safeAttachmentsDefinition ` + -Config $Config -BuiltInObject -ModuleName $moduleName ` + -RollbackGuidance "allow up to 30 minutes for propagation, then restore EnableATPForSPOTeamsODB to the captured prior value '$safeAttachmentsEnabled' if recovery is required." ` + -Confirm:$false -WhatIf:$WhatIfPreference ` + -WriteAction { + param($Plan) + Set-AtpPolicyForO365 -Identity 'Default' ` + -EnableATPForSPOTeamsODB $Plan.State.EnableATPForSPOTeamsODB ` + -ErrorAction Stop + } ` + -ReadbackAction { + Get-AtpPolicyForO365 -Identity 'Default' -ErrorAction Stop | + Select-Object Identity, EnableATPForSPOTeamsODB + }) + foreach ($safeAttachmentsResult in $safeAttachmentsResults) { + $safeAttachmentsDetail = $safeAttachmentsResult.Detail + if ($safeAttachmentsResult.Recovery.ContinuationCommand) { + $safeAttachmentsDetail = "$safeAttachmentsDetail $($safeAttachmentsResult.Recovery.ContinuationCommand)" + } + Add-DefenderRunLogEntry -Module $moduleName ` + -Action $safeAttachmentsOperationKey ` + -BestPracticeKey $safeAttachmentsBestPracticeKey ` + -Status $safeAttachmentsResult.Recovery.RunLogStatus ` + -Disposition $safeAttachmentsResult.Recovery.Disposition ` + -Readback $safeAttachmentsResult.Readback ` + -Detail $safeAttachmentsDetail + } + $safeAttachmentsFailures = @($safeAttachmentsResults | Where-Object { + $_.WriteOutcome -eq 'Failed' -or + $_.Readback -eq 'Failed' -or + $_.Recovery.Disposition -eq 'Blocked' + }) + if ($safeAttachmentsFailures.Count -gt 0) { + throw 'Safe Attachments managed write failed or could not be confirmed by readback.' + } + } + else { + Add-DefenderRunLogEntry -Module $moduleName ` + -Action $safeAttachmentsOperationKey -Status 'Skipped' ` + -BestPracticeKey $safeAttachmentsBestPracticeKey ` + -Disposition 'Skipped' ` + -Detail 'Safe Attachments for SharePoint, OneDrive, and Teams would be enabled.' + } + } + else { + Add-DefenderRunLogEntry -Module $moduleName ` + -Action $safeAttachmentsOperationKey -Status 'Skipped' ` + -BestPracticeKey $safeAttachmentsBestPracticeKey ` + -Disposition 'GuidedOnly' ` + -Detail 'Safe Attachments for SharePoint, OneDrive, and Teams would be enabled; the production operation remains guided-only pending separate approved pilot evidence.' + } +} + +$currentPolicy = Invoke-WithTransientRetry ` + -Description 'Read the default outbound spam-filter policy' ` + -Action { Get-HostedOutboundSpamFilterPolicy -Identity 'Default' -ErrorAction Stop } +$priorMode = [string]$currentPolicy.AutoForwardingMode +if ([string]::IsNullOrWhiteSpace($priorMode)) { + throw 'The default outbound spam-filter policy did not return AutoForwardingMode.' +} + +if ($priorMode -eq $desiredMode) { + Add-DefenderRunLogEntry -Module $moduleName -Action $operationKey ` + -BestPracticeKey $autoForwardBestPracticeKey ` + -Status 'Succeeded' -Disposition 'AlreadyCompliant' ` + -Detail 'Default outbound auto-forwarding is already Off.' + [pscustomobject]@{ + OperationKey = $operationKey + Identity = 'Default' + PriorMode = $priorMode + DesiredMode = $desiredMode + Disposition = 'AlreadyCompliant' + WriteAttempted = $false + } +} +else { + $safetyGatesReady = $IncludeHighRisk -and + $EnableMailFlowImpactingChanges -and $RollbackAcknowledged + $writeOperation = @($Config.PermissionModel.Operations | Where-Object { + $_.Key -eq $operationKey + })[0] + $writeVerified = $writeOperation.Phase -eq 'WriteApply' -and + $writeOperation.VerificationStatus -eq 'Verified' + if ($safetyGatesReady -and $writeVerified) { + if (-not $PSCmdlet.ShouldProcess( + 'Default outbound spam-filter policy', + "Set AutoForwardingMode from '$priorMode' to '$desiredMode'")) { + Add-DefenderRunLogEntry -Module $moduleName -Action $operationKey ` + -BestPracticeKey $autoForwardBestPracticeKey ` + -Status 'Skipped' -Disposition 'Skipped' ` + -Detail "Default outbound auto-forwarding would change from '$priorMode' to '$desiredMode'." + [pscustomobject]@{ + OperationKey = $operationKey + Identity = 'Default' + PriorMode = $priorMode + DesiredMode = $desiredMode + Disposition = 'Skipped' + WriteAttempted = $false + } + } + else { + Assert-DefenderExchangeParameterRbac ` + -RoleAssignee ([string]$Context.TenantAdminUpn) ` + -Command 'Set-HostedOutboundSpamFilterPolicy' ` + -Parameter 'AutoForwardingMode' | Out-Null + $definition = @{ + IdentifierProperties = @('Identity') + ManagedProperties = @('AutoForwardingMode') + } + $items = @([pscustomobject]@{ + Current = @{ + Identity = 'Default' + AutoForwardingMode = $priorMode + } + Desired = @{ + Identity = 'Default' + AutoForwardingMode = $desiredMode + } + }) + $results = Invoke-DefenderManagedWrite -Items $items ` + -Definition $definition -Config $Config -BuiltInObject ` + -ModuleName $moduleName ` + -RollbackGuidance "restore AutoForwardingMode to the captured prior value '$priorMode'." ` + -Confirm:$false -WhatIf:$WhatIfPreference ` + -WriteAction { + param($Plan) + Set-HostedOutboundSpamFilterPolicy -Identity 'Default' ` + -AutoForwardingMode $Plan.State.AutoForwardingMode ` + -ErrorAction Stop + } ` + -ReadbackAction { + Get-HostedOutboundSpamFilterPolicy -Identity 'Default' ` + -ErrorAction Stop | + Select-Object Identity, AutoForwardingMode + } + foreach ($result in $results) { + $status = if ($result.WriteOutcome -eq 'Skipped') { + 'Skipped' + } + else { + $result.Recovery.RunLogStatus + } + $disposition = if ($result.WriteOutcome -eq 'Skipped') { + 'Skipped' + } + else { + $result.Recovery.Disposition + } + Add-DefenderRunLogEntry -Module $moduleName -Action $operationKey ` + -BestPracticeKey $autoForwardBestPracticeKey ` + -Status $status -Disposition $disposition ` + -Readback $result.Readback -Detail $result.Detail + } + $failures = @($results | Where-Object { + $_.WriteOutcome -eq 'Failed' -or + $_.Readback -eq 'Failed' -or + $_.Recovery.Disposition -eq 'Blocked' + }) + if ($failures.Count -gt 0) { + throw 'Outbound auto-forwarding managed write failed or could not be confirmed by readback.' + } + $results + } + } + else { + $disposition = if ($safetyGatesReady) { 'GuidedOnly' } else { 'Blocked' } + $detail = if ($safetyGatesReady) { + "Default outbound auto-forwarding would change from '$priorMode' to 'Off'; production changes remain guided-only and require an approved deployment process." + } + else { + "Default outbound auto-forwarding would change from '$priorMode' to 'Off'; IncludeHighRisk, EnableMailFlowImpactingChanges, and RollbackAcknowledged are required before the guided write can be considered." + } + Add-DefenderRunLogEntry -Module $moduleName -Action $operationKey ` + -BestPracticeKey $autoForwardBestPracticeKey ` + -Status 'Skipped' -Disposition $disposition -Detail $detail + [pscustomobject]@{ + OperationKey = $operationKey + Identity = 'Default' + PriorMode = $priorMode + DesiredMode = $desiredMode + Disposition = $disposition + WriteAttempted = $false + } + } +} + +if ($null -ne $quarantineError) { + throw $quarantineError +} diff --git a/Products/Defender/Modules/Write-DefenderHtmlReport.ps1 b/Products/Defender/Modules/Write-DefenderHtmlReport.ps1 new file mode 100644 index 0000000..e7fdcf6 --- /dev/null +++ b/Products/Defender/Modules/Write-DefenderHtmlReport.ps1 @@ -0,0 +1,171 @@ +#requires -Version 7.0 +# Get-DefenderModuleVerdict lives in DefenderRunLog.ps1 so the JSON sidecar and +# this report cannot disagree about a module outcome. +function Write-DefenderHtmlReport { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Path, + [Parameter(Mandatory)] [hashtable[]] $Entries, + [guid] $RunId, + [datetime] $StartTime, + [datetime] $EndTime = [datetime]::UtcNow, + [string] $TenantId, + [string] $TenantAdminUpn, + [string] $ScriptVersion = 'unknown' + ) + + function Encode([object] $Value) { + if ($null -eq $Value) { return '' } + return [System.Net.WebUtility]::HtmlEncode([string] $Value) + } + + function Get-StatusClass([string] $Status) { + switch ($Status) { + 'Failed' { return 'failed' } + 'Skipped' { return 'skipped' } + 'Retried' { return 'retried' } + 'Succeeded' { return 'succeeded' } + default { return 'info' } + } + } + + function Protect-Identity([string] $Value) { + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + if ($Value -match '^([^@\s])[^@\s]*(@[^@\s]+)$') { + return "$($Matches[1])***$($Matches[2])" + } + return '[REDACTED]' + } + + try { + $failed = @($Entries | Where-Object Status -eq 'Failed').Count + $skipped = @($Entries | Where-Object Status -eq 'Skipped').Count + $succeeded = @($Entries | Where-Object Status -in @('Succeeded','Created','Updated','Adopted')).Count + $duration = if ($StartTime) { [int] (($EndTime - $StartTime).TotalSeconds) } else { $null } + $startText = if ($StartTime) { $StartTime.ToString('o') } else { $null } + + $moduleRows = foreach ($group in ($Entries | Group-Object Module | Sort-Object Name)) { + $items = @($group.Group) + $verdict = Get-DefenderModuleVerdict -Entries $items + $verdictClass = switch ($verdict) { + 'FAILED' { 'failed' } + 'BLOCKED' { 'skipped' } + 'SKIPPED' { 'skipped' } + default { 'succeeded' } + } + '{0}{1}{2}' -f ` + (Encode $group.Name), + ('{1}' -f $verdictClass, $verdict), + $items.Count + } + + $rows = foreach ($entry in $Entries) { + $statusClass = Get-StatusClass $entry.Status + $friendlyName = if ($entry.FriendlyName) { + [string]$entry.FriendlyName + } elseif ($entry.Detail -match '(^|;\s*)Recommendation=(?[^;]+)') { + $Matches.name.Trim() + } else { + [string]$entry.Action + } + '{0}{1}{2}{3}{4}{5}{6}{7}{8}{9}{10}' -f ` + (Encode $entry.Timestamp), (Encode $entry.Module), (Encode $entry.Action), + (Encode $friendlyName), (Encode $entry.Disposition), (Encode $entry.Target), + ('{1}' -f $statusClass, (Encode $entry.Status)), + (Encode $entry.Detail), (Encode $entry.HttpStatusCode), (Encode $entry.ElapsedMs), + (Encode $entry.Readback) + } + + function ColumnHeader([string] $Label, [string] $Description) { + '{1}' -f (Encode $Description), (Encode $Label) + } + + $moduleHeaders = @( + (ColumnHeader 'Module' 'The deployment module that produced the grouped evidence.') + (ColumnHeader 'Result' 'The final module verdict: OK, FAILED, SKIPPED, or BLOCKED.') + (ColumnHeader 'Entries' 'The number of evidence entries recorded for this module.') + ) -join '' + $decisionHeaders = @( + (ColumnHeader 'Timestamp' 'The UTC time when the evidence entry was recorded.') + (ColumnHeader 'Module' 'The deployment module that recorded this evidence.') + (ColumnHeader 'Action' 'The operation or decision represented by this evidence entry.') + (ColumnHeader 'Recommendation' 'The configured friendly best-practice recommendation name.') + (ColumnHeader 'Disposition' 'The applicability or intended-change outcome for the recommendation.') + (ColumnHeader 'Target' 'The tenant workload, object, or scope affected or inspected.') + (ColumnHeader 'Status' 'The execution status recorded for the operation.') + (ColumnHeader 'Detail / reason' 'Additional context, reason, or operator guidance for the entry.') + (ColumnHeader 'HTTP' 'The HTTP response status code, when an HTTP call produced one.') + (ColumnHeader 'Elapsed ms' 'The elapsed operation time in milliseconds, when measured.') + (ColumnHeader 'Readback' 'Whether post-operation or authorization readback succeeded, failed, or was not applicable.') + ) -join '' + + $html = @" + + + + +Defender deployment report + + +
+

Microsoft Defender Best Practice Deployment Report

Read-only and state-changing evidence for one deployment run.

+

Run metadata

+
Run ID
$(Encode $RunId)
+
Script version
$(Encode $ScriptVersion)
+
Tenant ID
$(Encode (Protect-Identity $TenantId))
+
Tenant administrator
$(Encode (Protect-Identity $TenantAdminUpn))
+
Started
$(Encode $startText)
+
Ended
$(Encode $EndTime.ToString('o'))
+
Duration (seconds)
$(Encode $duration)
+
+

Evidence summary

+
$($Entries.Count)Total entries
+
$succeededSuccessful/change entries
+
$skippedSkipped
+
$failedFailed
+
+

Module summary

+$moduleHeaders +$($moduleRows -join [Environment]::NewLine) +
+

Decision-point evidence

+$decisionHeaders +$($rows -join [Environment]::NewLine) +
+
+"@ + # Evidence output is local, not a tenant change; never suppress it under + # -WhatIf. A dry run must still produce its report. + Set-Content -LiteralPath $Path -Value $html -Encoding utf8 -WhatIf:$false + } + catch { + throw "Unable to write Defender HTML report '$Path': $($_.Exception.Message)" + } +} diff --git a/Products/Defender/README.md b/Products/Defender/README.md new file mode 100644 index 0000000..493d15b --- /dev/null +++ b/Products/Defender/README.md @@ -0,0 +1,151 @@ +--- +title: Microsoft Defender +nav_order: 3 +has_children: true +permalink: /defender/ +--- + +# Microsoft Defender Best Practice Toolkit + +> **Available at the approved release scope.** Default execution is read-only. +> The ASR Audit policy is the only managed tenant configuration path with +> approved pilot evidence and requires explicit switches plus an approved +> pilot security group. Safe Attachments, quarantine, MDE advanced, and MDCA +> writes remain `GuidedOnly`. + +## Scope + +The product boundary is designed for low-touch Microsoft Defender work across: + +- Microsoft Defender for Office 365 and Exchange Online protection. +- Defender for Business and related Intune posture. +- Microsoft Defender for Endpoint advanced settings. +- Microsoft Defender for Cloud Apps discovery and low-touch posture. + +The current release foundation performs tenant identity and API/workload +capability preflight, permission planning, safety-gate validation, structured +evidence, and guided-only classification. It also provides the explicitly +enabled ASR Audit configuration path described under Safety gates. Licensing +prerequisites are documented with Microsoft Learn links; the toolkit does not +infer entitlement from SKU or service-plan names or treat other write +endpoints as supported. + +## Prerequisites + +- PowerShell 7 or later on Windows. +- An approved tenant administrator or delegated administrator. +- Microsoft Graph PowerShell modules providing `Connect-MgGraph`, + `Invoke-MgGraphRequest`, and `Get-MgContext`. +- Delegated consent and workload roles for the selected operations. + +See [Deployment](docs/deployment.md) for authentication, module flow, safety +gates, and recovery. See +[Permissions and supportability](docs/permissions.md) for Graph scopes, +Exchange RBAC, licensing references, and API boundaries. + +## Deployment parameters + +The tables below cover every parameter exposed by +`Deploy-DefenderBestPractice.ps1`. Switches default to off. Optional strings +default to empty unless a default is stated. + +### Tenant, authentication, and configuration + +| Parameter | Required/default | Purpose | +|---|---|---| +| `-TenantAdminUpn` | Required | Sign-in UPN used to derive and verify the expected tenant domain. | +| `-TenantId` | Optional GUID | Targets a tenant explicitly. Required with certificate arguments. | +| `-DelegatedOrganization` | Optional domain | Targets the customer organization for delegated Graph and Exchange Online administration. Graph uses this domain when no explicit `-TenantId` is supplied. | +| `-ConfigPath` | `Config\DefenderConfig.psd1` | Uses an alternate Defender configuration data file. | +| `-UseDeviceAuthentication` | Off | Uses delegated device code instead of the default browser flow for Graph and Exchange Online. | +| `-AutoInstallModules` | Off | Allows the connection helper to install missing supported PowerShell modules. | +| `-ClientId` | Optional GUID | Certificate client ID. Certificate execution is blocked until app-only operation evidence is approved. | +| `-CertificateThumbprint` | Optional 40-character hexadecimal value | Certificate thumbprint. Supply it with `-TenantId` and `-ClientId`; the current preview then fails closed before app-only connection. | +| `-NonInteractive` | Off | Requests noninteractive authentication. The current preview rejects this path because app-only authorization is not approved. | + +### Module selection + +| Parameter | Default | Purpose | +|---|---|---| +| `-SkipPreflight` | Off | Skips Defender preflight capability assessment. Use only when its evidence is not required for the selected run. | +| `-SkipMdoEop` | Off | Skips the Defender for Office 365 and Exchange Online baseline module. | +| `-SkipDefenderForBusiness` | Off | Skips Defender for Business and Intune ASR assessment or configuration. | +| `-SkipMdeAdvanced` | Off | Skips the guided Microsoft Defender for Endpoint advanced-settings module. | +| `-SkipDefenderForCloudApps` | Off | Skips the guided Defender for Cloud Apps module. | + +### State and safety controls + +| Parameter | Default | Purpose and interaction | +|---|---|---| +| `-AdoptExisting` | Off | Allows supported state-management definitions to adopt an unmanaged object after exact checks. It never permits quarantine-policy adoption. | +| `-IncludeHighRisk` | Off | Opens the high-risk category gate. It does not make a GuidedOnly or unimplemented operation writable. | +| `-RollbackAcknowledged` | Off | Confirms recovery planning. Required for mail-flow-impacting selection and irreversible ASR recovery. | +| `-EnableMailFlowImpactingChanges` | Off | Selects the guarded MDO/EOP write category. Current outbound auto-forwarding, Safe Attachments, and quarantine production writes remain `GuidedOnly`. Requires `-IncludeHighRisk` and `-RollbackAcknowledged`. | +| `-EnableAutomatedRemediation` | Off | Selects the high-risk MDE remediation category. Current MDE advanced automation remains guided-only. Requires `-IncludeHighRisk`. | +| `-EnableAsrAuditPolicy` | Off | Creates or reuses the toolkit-owned ASR Audit policy and assigns it only to `-PilotGroupId`. Cannot be combined with recovery. | +| `-RecoverAsrAuditPolicy` | Off | Permanently deletes the captured toolkit-owned ASR Audit policy after exact isolation checks. Requires `-RecoveryPolicyId`, `-PilotGroupId`, and `-RollbackAcknowledged`. | +| `-EnableAsrBlockMode` | Off | Reserved high-risk gate. Block mode is not implemented; even with `-IncludeHighRisk`, the run stops before a tenant change. | +| `-EnableMdcaEnforcement` | Off | Selects the high-risk MDCA enforcement category. Current MDCA automation remains guided-only. Requires `-IncludeHighRisk`. | +| `-CloudDiscoveryReviewed` | Off | Records that cloud-discovery results were reviewed for the guided MDCA workflow. It does not authorize enforcement. | +| `-PilotGroupId` | Optional GUID string | Approved security-group ID required for ASR Audit apply and recovery. The orchestrator validates it before requesting write scopes. | +| `-RecoveryPolicyId` | Optional GUID string | Captured managed ASR policy ID required only for recovery. | + +Because the script uses `SupportsShouldProcess`, it also accepts the common +`-WhatIf` and `-Confirm` parameters. Start with `-WhatIf`. Confirmation does +not bypass operation status, permission, ownership, readback, or recovery +gates. + +## Safe preview + +Graph authentication is bound to the requested operator and tenant. An explicit +`-TenantId` takes precedence; otherwise the target is `-DelegatedOrganization` +or the domain in `-TenantAdminUpn`. Cached sessions must match the operator, +delegated authentication mode, required scopes and verified tenant before +reuse. Selecting another account during sign-in stops the run before deployment. +This does not grant GDAP roles or consent; those remain operator prerequisites. + +```powershell +cd Products\Defender +.\Deploy-DefenderBestPractice.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com ` + -WhatIf +``` + +## Operator guides + +- [Deployment](docs/deployment.md) +- [Permissions and supportability](docs/permissions.md) +- [Scenarios and boundaries](docs/scenarios.md) +- [Best-practice scope and guided references](docs/best-practices.md) +- [Evidence and reports](docs/evidence.md) +- [Troubleshooting](docs/troubleshooting.md) +- [Read-only deployment playbook](docs/Defender-Deployment-Playbook.md) +- [Adoption readiness guide](docs/Defender-End-User-Adoption-Guide.md) + +Every run is intended to produce a redacted JSON sidecar and HTML report under +`Products\Defender\Reports\`. See [Evidence and reports](docs/evidence.md) for +the schema, verdicts, retry records, redaction boundary, and evidence-retention +rules. + +If a module contains both failed and blocked actions, its summary is `FAILED`. +The HTML report and JSON sidecar use the same precedence; a blocked action +does not hide an operation or readback failure. + +## Safety gates + +Default execution is read-only. Unsupported automation and unproven writes +remain blocked or `GuidedOnly`. ASR Audit apply is the only approved managed +configuration path. Recovery is irreversible and separately gated. Review the +parameter table before invocation and follow [Deployment](docs/deployment.md) +for the complete apply, collision, failure, and recovery behavior. + +## Explicit exclusions + +This release does not automate custom KQL, third-party SaaS credentials, +recurring SOC operations, custom OAuth policy authoring, SaaS DLP architecture, +SSPM onboarding, BitLocker enforcement, Conditional Access blocking, or ASR +block mode. See [Best-practice scope and guided references](docs/best-practices.md) +for the canonical coverage matrix and Microsoft deployment-guide links. + +Pilot evidence must remain in the approved private validation location and must +not be committed to this repository. diff --git a/Products/Defender/docs/Defender-Deployment-Playbook.md b/Products/Defender/docs/Defender-Deployment-Playbook.md new file mode 100644 index 0000000..53c2270 --- /dev/null +++ b/Products/Defender/docs/Defender-Deployment-Playbook.md @@ -0,0 +1,64 @@ +--- +title: Defender deployment playbook +parent: Microsoft Defender +nav_order: 7 +--- + +# Defender deployment playbook + +Microsoft Defender is available at its approved release scope. This playbook +describes how to collect a safe read-only report. The separately documented +ASR Audit path requires explicit managed-configuration switches and an +approved pilot group. Use the [deployment reference](deployment.md) for +configuration and recovery; this playbook does not duplicate or authorize +those operations. + +## Before you start + +- Use an approved non-production tenant only. +- Use an interactive delegated sign-in. +- Do not provide tenant identifiers, credentials, tokens, screenshots, or raw + responses in issues or public repository files. +- Confirm that the operator understands that pilot approval remains a manual gate. + +## Run the supported preview + +```powershell +cd Products\Defender +.\Deploy-DefenderBestPractice.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com ` + -WhatIf +``` + +The command above performs tenant-identity and capability preflight, records +guided-only boundaries, and produces evidence without applying policy changes. +Certificate authentication and `-NonInteractive` execution are not supported +by this release. The optional ASR Audit path is not enabled by this command. + +## Review the reports + +The output directory is `Products\Defender\Reports\`. The exact files are: + +- `defender-run-log.json` - structured run entries and dispositions. +- `defender-run-report.html` - operator-readable rendering of the run log. + +Review blocked and guided-only entries. A successful preview report is not +evidence that a workload policy can be changed and is not a release approval. + +## Pilot evidence boundary + +Operation promotion remains manual until an approved non-production record +contains workload-specific authorization, capability, readback, recovery, and +approval evidence. Existing outbound auto-forwarding and ASR evidence is +indexed in the private validation records; Safe Attachments and quarantine +retain separate live-evidence gates. + +Do not use this playbook to claim production readiness. Record any remaining +operation evidence in the approved private validation package instead. + +## Troubleshooting + +If interactive sign-in or a read-only preflight fails, preserve the report and +record the failure without retrying with broader permissions. Check the +permission matrix and the endpoint validation register before proposing any +new operation. Do not add a skip switch as a substitute for missing evidence. diff --git a/Products/Defender/docs/Defender-End-User-Adoption-Guide.md b/Products/Defender/docs/Defender-End-User-Adoption-Guide.md new file mode 100644 index 0000000..40d01de --- /dev/null +++ b/Products/Defender/docs/Defender-End-User-Adoption-Guide.md @@ -0,0 +1,46 @@ +--- +title: Defender adoption readiness guide +parent: Microsoft Defender +nav_order: 8 +--- + +# Defender adoption readiness guide + +Microsoft Defender is available at its approved release scope. Default +execution is read-only, ASR Audit has an explicitly enabled managed pilot path, +and other writes retain their documented boundaries. This guide helps a +deployment team prepare communications without suggesting broader deployment +or protection than the run evidence proves. + +## Current message + +For a read-only preview, tell users that no Defender workload policy is being +changed. For an approved ASR Audit pilot, use a pilot-specific notice that +names the group, observation period, support route, and recovery owner. This +documentation does not authorize a production rollout. + +## Readiness checklist + +- [ ] Confirm the security owner and the tenant administrator. +- [ ] Define the intended pilot population and business-critical exceptions. +- [ ] Inventory existing mail, endpoint, and cloud-app controls. +- [ ] Agree on alert ownership, support contacts, and escalation timing. +- [ ] Document rollback expectations for every future enforcement change. +- [ ] Complete independent authorization and readback evidence for each + workload before requesting approval. +- [ ] Obtain explicit approval before any future tenant-impacting change. + +## Future communication template + +> The deployment team is preparing a Microsoft Defender pilot. No policy +> changes are being made by the current preview. Before any pilot begins, the +> team will publish the scope, expected user impact, support route, rollback +> plan, and approval record. + +## After independent approval + +Only after the repository's implementation and pilot gates are complete should +the deployment team publish a pilot-specific notice. The notice must identify +the exact workload, scope, expected impact, support contact, monitoring +period, and rollback owner. Do not reuse this guide as a production +announcement. diff --git a/Products/Defender/docs/best-practices.md b/Products/Defender/docs/best-practices.md new file mode 100644 index 0000000..9c8866a --- /dev/null +++ b/Products/Defender/docs/best-practices.md @@ -0,0 +1,214 @@ +--- +title: Defender best-practice coverage +layout: default +nav_order: 6 +parent: Microsoft Defender +permalink: /defender/best-practices/ +--- + +# Defender best-practice coverage + +This page is a **coverage and readiness matrix**, not a list of currently +deployed controls. Defender is available at its approved release scope. +Default execution is read-only, MDO/EOP writes remain `GuidedOnly`, and the ASR +Audit policy is the only managed configuration path with approved pilot +evidence. The matrix records which practices can be assessed, guided, or +configured within those boundaries. + +## Current boundary + +The default path does not create, update, adopt, publish, enforce, or roll back +Defender workload policies. `-WhatIf` is the supported starting point. The ASR +Audit path requires the explicit managed-configuration switches and an +approved pilot security group. A practice listed below is not evidence that +its operation is available. + +## Pilot capability status + +The 2026-08-25 delegated `-WhatIf` run initially verified only the Microsoft +Graph tenant identity capability. Subsequent approved staged-tenant work +validated bounded outbound-forwarding configuration and recovery, plus ASR +audit-policy configuration and assignment. Safe Attachments passed tenant-free +configuration validation and was already compliant in the observed tenant, so +no mutation was manufactured. MDE and MDCA API capabilities were unavailable +and skipped; guided portal evidence remains possible where the operator has +access. Production use remains `GuidedOnly` unless the operator explicitly +selects a documented managed configuration path. + +| Recommendation | Required capability | Current status | +| --- | --- | --- | +| Block outbound auto-forwarding | Exchange Online / EOP | GuidedOnly in production / Implemented and pilot-validated | +| Enable Safe Attachments for SPO, OneDrive, and Teams | Exchange Online / EOP | GuidedOnly in production / Implemented with tenant-free validation; pilot pending | +| Configure `SMBTool-Quarantine-LimitedAccess` without assignments | Exchange Online / Defender for Office 365 | GuidedOnly in production / Implemented with tenant-free validation and non-production WhatIf; apply pending | +| Deploy ASR rules in audit mode | Microsoft Intune | Default read-only; managed configuration and assignment pilot-validated; recovery path validated deterministically | + +## Practices in scope + +The coverage review identifies 25 practices for assessment, guided deployment, +or bounded configuration. Inclusion means the toolkit can safely evaluate or +guide the practice; it does not mean the setting has been applied. The current +treatment column distinguishes implemented configuration paths from assessment +and guidance that still require operator action. + +| Best practice | High-level configuration summary | Current toolkit treatment | +| --- | --- | --- | +| Verify audit readiness | Confirm that security activity is being recorded so investigations and reports have the evidence they need. | Assessment and guided remediation | +| Verify required Defender roles | Confirm that the deployment operator has the security roles needed for the selected work without granting broad access automatically. | Readiness assessment | +| Verify emergency-access safety | Confirm that an emergency administrator account can preserve tenant access before any future enforcement change. | Read-only safety assessment | +| Enable Standard preset security policies | Apply Microsoft's recommended baseline email protections to the approved users or pilot group. | GuidedOnly pending complete pilot evidence | +| Block outbound automatic forwarding | Prevent mailboxes from automatically forwarding company email to external recipients. | Implemented configuration path; production remains GuidedOnly | +| Enable Safe Attachments for SharePoint, OneDrive, and Teams | Scan shared files for malicious content across Microsoft 365 collaboration services. | Implemented configuration path; production remains GuidedOnly | +| Block executable email attachments | Prevent commonly abused executable file types from being delivered through email. | GuidedOnly; high-impact approval required | +| Apply recommended quarantine settings | Configure how suspicious messages are held, released, and reported to users and administrators. | GuidedOnly pending approved desired state | +| Enable Teams protection | Turn on available protections that detect and remove malicious links or messages in Teams. | Assessment and guided configuration | +| Configure email investigation and remediation | Review or configure how Defender investigates email threats and whether remediation requires approval. | GuidedOnly; remediation mode requires approval | +| Assess Defender for Business readiness | Confirm licensing, device onboarding, and baseline protection status before endpoint configuration. | Assessment and guided setup | +| Validate Defender and Intune integration | Confirm that Defender device risk can be shared with Intune for compliance decisions. | Assessment and guided configuration | +| Apply malware and firewall defaults | Configure Microsoft's recommended antivirus and Windows Firewall baseline for managed devices. | Candidate configuration; pilot evidence required | +| Validate Automatic Attack Disruption | Confirm that Defender can automatically contain supported attacks and that required devices are onboarded. | Assessment and guided setup | +| Validate endpoint investigation and remediation | Confirm that automated endpoint investigations are available and configured for the intended response model. | Assessment and guided setup | +| Configure Defender notification recipients | Route endpoint security alerts to the approved operational contacts. | GuidedOnly pending supported configuration evidence | +| Deploy ASR rules in audit mode | Apply recommended attack surface reduction rules in observation mode so impact can be reviewed before any blocking. | Implemented and pilot-validated configuration path | +| Configure endpoint attack notifications | Route advanced endpoint attack notifications to the approved responders where the tenant supports them. | GuidedOnly | +| Validate Defender for Endpoint and Defender for Cloud Apps integration | Confirm that endpoint activity contributes to cloud application discovery and risk visibility. | GuidedOnly | +| Create Cloud Discovery template policies | Use built-in policy templates to identify risky or unexpected cloud application use after discovery data is available. | GuidedOnly; discovery data required | +| Validate the default anomaly policy | Confirm that unusual cloud activity detection is enabled and retains appropriate Microsoft defaults. | Assessment and guided configuration | +| Enable App Governance | Turn on visibility and protection for applications that access Microsoft 365 data. | GuidedOnly | +| Enable predefined app policy templates | Use Microsoft's predefined policies to identify risky application behavior without creating custom rules. | GuidedOnly | +| Enable threat-detection policy templates | Use Microsoft templates to detect suspicious activity across connected cloud applications. | GuidedOnly | +| Enable user enrichment | Associate discovered cloud activity with known users when identity mapping can be resolved safely. | GuidedOnly; skipped when identity is ambiguous | + +For each practice, the operator must use the endpoint validation register and +permission matrix rather than inferring support from a product name or license +SKU. No practice should be described as applied until the implementation +contract and approved pilot evidence exist. + +## Practices intentionally deferred + +The approved low-touch plan explicitly identifies the following 33 practices +as outside its apply scope. The earlier category summary did not retain enough +source data to support its aggregate count, so this table uses only traceable +practices from the plan. ASR appears here only for promotion to block mode; its +audit-mode configuration is implemented separately. + +Download Microsoft's [Best Practice Security Deployment Guides for +SMB](https://aka.ms/Security_SMBDeploymentGuides). The mappings below were +verified against these files from the package downloaded on 2026-08-28: + +- **Email guide:** `Email & App + Collaboration Protection Best Practice + Deployment_Final030926.pdf` +- **Device guide:** `Device Security Best Practice + Deployment_Final030926.pdf` +- **SaaS guide:** `SaaS Security Best Practice + Deployment_Final030926.pdf` + +| Best practice outside apply scope | Why it remains outside scope | Microsoft guide section | +| --- | --- | --- | +| Configure SPF | Requires provider-specific DNS access and design. | Email guide, pages 10-11, "Configure email authentication" | +| Configure DKIM | Requires provider-specific DNS access and design. | Email guide, pages 10-11, "Configure email authentication" | +| Configure DMARC | Requires provider-specific DNS access and staged enforcement decisions. | Email guide, pages 10-11, "Configure email authentication" | +| Review built-in alert policies | Review activity, not a one-time configuration baseline. | Email guide, pages 21-24, "Alert Policies" | +| Use Configuration Analyzer | Assessment and review activity, not a configuration deliverable. | Email guide, pages 28-29, "Configuration Analyzer" | +| Configure skip listing for a third-party email service | Requires the customer's mail-routing and third-party filtering architecture. | Email guide, page 41, "Advanced Email and Apps Protection Checklist" | +| Customize anti-phishing impersonation protection | Requires customer-selected users and partner or supplier domains. | Email guide, page 41, "Advanced Email and Apps Protection Checklist" | +| Complete provider-specific DNS prerequisites | Requires control of the authoritative DNS provider. | Email guide, pages 10-11, "Configure email authentication" | +| Configure priority account protection | Requires the customer to identify executives and other priority users. | Email guide, pages 42-43, "Priority Account Protection" | +| Run attack simulation and training | Requires campaign design, user targeting, communications, and recurring ownership. | Email guide, pages 42 and 44, "Attack Simulation Training" | +| Perform discovery, Threat Explorer, and hunting review | Investigation and operational review, not baseline configuration. | Email guide, pages 48-51, "Discovery items," "Threat Explorer," and "Advanced Hunting" | +| Create Cloud Discovery snapshot reports | Requires representative firewall or proxy logs and source-specific parsing. | SaaS guide, pages 9-12, "Set up cloud discovery" | +| Configure automatic Cloud Discovery log uploads | Requires firewall, proxy, or secure web gateway collector design. | SaaS guide, pages 9 and 13, "Automatic Log Uploads" | +| Scope anomaly detection policies | Requires customer-specific user, IP address, report, and threshold choices. | SaaS guide, pages 14-18, "Cloud discovery anomaly detection policy" | +| Review and govern OAuth apps | Recurring governance review requiring app-specific business decisions. | SaaS guide, pages 19-21, "Manage OAuth apps that are authorized by your users" | +| Create custom OAuth app policies | Requires custom conditions, exceptions, actions, and risk thresholds. | SaaS guide, pages 19 and 23-25, "Create OAuth App Policies" | +| Connect SaaS applications | Requires SaaS administrator credentials and provider authorization. | SaaS guide, pages 26-27, "Connect Apps for Visibility and Protection" | +| Enable SaaS Security Posture Management | Requires connected applications and customer-approved recommendations. | SaaS guide, pages 26-28, "Enable SSPM" | +| Design SaaS data protection policies | Requires connectors, sensitive-data design, and enforcement choices. | SaaS guide, pages 26 and 29, "Implement Data Protection / DLP Policies" | +| Establish recurring Defender for Cloud Apps operations | Ongoing operational work, not one-time configuration. | SaaS guide, pages 32-33, "Operations Guide" | +| Enforce BitLocker | High-impact device change requiring readiness, escrow, scope, and recovery planning. | Device guide, pages 24-27, "Configure Disk Encryption Policy" | +| Promote ASR rules from audit to block mode | Requires audit evidence, staged targeting, explicit approval, and recovery planning. | Device guide, pages 33-36, "Configure Attack Surface Reduction rules" and "View Attack Surface Reduction Report" | +| Enforce a device-risk compliance policy | Depends on stable device onboarding and risk signals and can mark devices noncompliant. | Device guide, pages 37-40, "Create Compliance Policy: Devices must have low risk score" | +| Block noncompliant devices with Conditional Access | Can block tenant access and requires emergency-access exclusions and staged rollout. | Device guide, pages 37 and 41-43, "Configure Conditional Access that blocks access for devices marked as noncompliant" | +| Switch the tenant to the MDE Plan 2 experience | Can require licensing decisions and a Microsoft Support workflow. | Device guide, pages 44-47, "Ensure Defender for Endpoint Plan 2 Subscription state" | +| Author custom MDE detection rules | Requires customer-specific KQL, validation, monitoring, and response ownership. | Device guide, page 45, "Defender for Business vs Defender for Endpoint P2" overview; the downloaded guide contains no custom-detection implementation procedure | +| Design MDE device groups and RBAC | Requires customer-specific device grouping and remediation responsibility. | Device guide, pages 46 and 48-50, "Configure Device Groups" | +| Configure ARC trusted sealers | Requires identification of trusted intermediaries that legitimately modify mail. | Email guide, page 41, "Advanced Email and Apps Protection Checklist" | +| Operate a continuous SOC workflow | Ongoing investigation and response process, not one-time configuration. | Email guide, pages 46-47, "Automated Investigation and Response 24/7/365 SOC Unlocked" | +| Perform daily Defender for Cloud Apps operations | Recurring operational ownership. | SaaS guide, pages 32-33, "Operations Guide," Daily activities | +| Perform weekly Defender for Cloud Apps operations | Recurring operational ownership. | SaaS guide, pages 32-33, "Operations Guide," Weekly activities | +| Perform monthly Defender for Cloud Apps operations | Recurring operational ownership. | SaaS guide, pages 32-33, "Operations Guide," Monthly activities | +| Perform ad-hoc Defender for Cloud Apps operations | Operational response driven by tenant events and risk. | SaaS guide, pages 32-33, "Operations Guide," Ad-hoc activities | + +A later package revision can change filenames or pagination. Follow the named +section in the current download when it moves. These references provide manual +guidance only; they do not change an item's automation disposition or authorize +the toolkit to perform it. + +## Practices outside the automated scope of this release + +The following practices remain part of the Defender product coverage, but their +automated API path is outside the current approved pilot scope: + +- **MDE advanced endpoint actions and MDE P2 automation**, including machine + isolation, antivirus scan actions, remediation status, and advanced endpoint + notification routing. Supported API automation requires an + organization-provisioned MDE identity, MDE roles, and device-group scope. + No approved managed identity is available for this pilot, and the toolkit + must not ask an operator to create an app registration. +- **MDCA policy and governance automation**, including cloud discovery, + anomaly-detection, App Governance, and threat-detection templates. MDCA uses + a separate application-context authorization model; no approved + organization-managed MDCA identity or policy-management read/write evidence + is available. +- **Graph Security incident and alert readback as an automated MDE evidence + path**. + Both `GET /security/incidents` with `SecurityIncident.Read.All` and + `GET /security/alerts_v2` with `SecurityAlert.Read.All` returned HTTP 403 + `Account is not provisioned`. This indicates Defender XDR account/service + provisioning or eligibility is unavailable in the pilot tenant, so adding + more Graph scopes is not an appropriate workaround. + +These practices remain available as `GuidedOnly` portal-based readback and +presentation where the operator has access. Portal exports or operator-provided +evidence may be recorded, but browser scraping is not treated as supported +automation. API automation remains planning material for a future release or a +separately approved pilot with the required service provisioning and centrally +managed identities. + +### Guided deployment references + +Download Microsoft's [Best Practice Security Deployment Guides for +SMB](https://aka.ms/Security_SMBDeploymentGuides) and use these files from the +downloaded package: + +- **MDE advanced configuration:** `Device Security Best Practice + Deployment_Final030926.pdf`, pages 28-32 for attack disruption and endpoint + notifications, and pages 44-50 for Defender for Endpoint Plan 2 subscription + state and device-group/RBAC configuration. +- **Defender for Cloud Apps:** `SaaS Security Best Practice + Deployment_Final030926.pdf`, pages 9-18 for Defender for Endpoint integration, + Cloud Discovery, and anomaly policies; pages 19-25 for App Governance; pages + 30-31 for threat-detection policy templates; and pages 32-34 for operational + review and user enrichment. + +These page references were verified against the package downloaded on +2026-08-28. A later package revision can change filenames or pagination. Follow +the equivalent section headings in the current download if they move. These +portal procedures remain `GuidedOnly`; they do not authorize toolkit API calls, +endpoint response actions, connectors, or enforcement changes. + +## Evidence and next steps + +Use the approved private preflight package for the pilot protocol and endpoint +boundaries. Contributor-reported observations are not independent pilot +evidence and do not clear the manual pilot gate. + +For a safe preview, run the documented `-WhatIf` command and review +`defender-run-log.json` and `defender-run-report.html`. Do not interpret those +reports as proof that a policy was applied. + +## Summary + +This matrix supports planning without overstating current capability. The +preview delivers read-only evidence, guided next steps, and the explicitly +enabled ASR Audit configuration path. Other workload automation requires +separate operation-level validation and human approval. diff --git a/Products/Defender/docs/deployment.md b/Products/Defender/docs/deployment.md new file mode 100644 index 0000000..74fd2c6 --- /dev/null +++ b/Products/Defender/docs/deployment.md @@ -0,0 +1,197 @@ +--- +title: Defender deployment +parent: Microsoft Defender +nav_order: 1 +--- + +# Defender deployment + +Microsoft Defender is **Available at the approved release scope**. The +deployment script performs Graph tenant-identity and API/workload capability +preflight, then records licensing documentation links and guided-only +decisions. Default execution is read-only and does not infer licensing from +SKU names. The ASR Audit policy is the only managed tenant configuration path +with approved pilot evidence and requires explicit switches plus an approved +pilot security group. + +## Safe preview + +The [README parameter tables](../README.md#deployment-parameters) list every +orchestrator parameter, default, and interaction. Start with the read-only +preview: + +```powershell +cd Products\Defender +.\Deploy-DefenderBestPractice.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com ` + -WhatIf +``` + +Certificate-based authentication and `-NonInteractive` execution are blocked in +this release. Use delegated sign-in through browser authentication or device +code flow. + +### Browser authentication + +Browser authentication is the default path. Use an explicit tenant and a +process-scoped context: + +```powershell +Connect-MgGraph ` + -TenantId 'contoso.onmicrosoft.com' ` + -Scopes 'User.Read' ` + -ContextScope Process ` + -NoWelcome +``` + +Run it from a standalone PowerShell 7 process. An interactive browser prompt +may appear to succeed even when WAM cannot hand the token back to PowerShell. + +### Device code fallback + +If browser authentication or WAM is unavailable, use: + +```powershell +Connect-MgGraph ` + -TenantId 'contoso.onmicrosoft.com' ` + -Scopes 'User.Read' ` + -ContextScope Process ` + -UseDeviceAuthentication ` + -NoWelcome +``` + +Complete the code at [microsoft.com/devicelogin](https://microsoft.com/devicelogin). +Device code flow does not bypass delegated consent, licensing, workload +availability, or the Defender guided-only safety boundary. To select this path +for the orchestrator, add `-UseDeviceAuthentication` to the deployment command. + +### Operator and delegated-customer verification + +The toolkit passes the explicit `-TenantId` to Graph when supplied. Otherwise +it uses the `-DelegatedOrganization` customer domain, or the administrator UPN +domain for a direct tenant run. For GDAP, supply the customer domain and sign +in with the requested partner operator. An explicit tenant ID must identify +the same customer whose domain is being verified. + +Cache reuse requires the requested account, delegated authentication and all +operation-scoped permissions. The connected organization's ID and verified +domains must also confirm the requested tenant. A different account or tenant +causes a fresh connection; an unreadable tenant check fails closed. +After sign-in, the actual Graph account must match `-TenantAdminUpn`. +Preflight checks that identity again before recording successful capability +evidence. No tenant configuration changes occur during these checks. + +The scopes and GDAP requirements are unchanged. Confirm effective roles and +consent in an approved pilot; offline tests do not establish customer access. +The target is passed through the documented +[`Connect-MgGraph` tenant parameter](https://learn.microsoft.com/powershell/module/microsoft.graph.authentication/connect-mggraph?view=graph-powershell-1.0). + +The default path is read-only. `-IncludeHighRisk`, `-RollbackAcknowledged`, +pilot-group identifiers, and the relevant category switches are required for +future high-impact work; they do not authorize the current guided-only operations. +`-EnableAsrBlockMode` is a reserved safety gate. Block mode is not implemented, +so supplying that switch first requires `-IncludeHighRisk` and then stops the +run before tenant changes. Satisfying the high-risk gate does not enable Block +mode. Audit mode is the only supported managed ASR configuration. + +Before permission planning or Microsoft Graph connection, the orchestrator +rejects conflicting ASR apply and recovery switches, invalid pilot-group or +recovery-policy identifiers, and recovery without explicit rollback +acknowledgment. These deterministic failures record blocked evidence without +starting a write-scope consent request. + +## Exchange Online protection boundary + +The MDO/EOP module uses a separate delegated Exchange Online session because a +Microsoft Graph context does not establish Exchange connectivity. Browser/WAM +is the default. `-UseDeviceAuthentication` selects device code explicitly, and +`-DelegatedOrganization` targets a delegated customer organization. The module +verifies the target through Exchange organization and accepted-domain +readbacks before reading the built-in `Default` outbound spam-filter and ATP +policies. + +The outbound auto-forwarding slice can report `AlreadyCompliant`, `Blocked`, or +`GuidedOnly`. It captures the tenant's actual prior value without requiring +setter permission. Immediately before an approved write, it verifies the +operator with `Get-ManagementRoleAssignment -RoleAssignee`; it never assigns a +role. The managed-write, retry, exact-readback, and recovery path is present +but cannot dispatch while `mdo-auto-forward-apply` remains configured as +`GuidedOnly`. + +The Safe Attachments slice separately reads `EnableATPForSPOTeamsODB` and no-ops +when the value is already `$true`. It verifies effective RBAC for that exact +setter parameter only before an approved write. Its prepared write uses the +same built-in-object, `ShouldProcess`, retry, exact-readback, and recovery +contracts. The production operation remains `GuidedOnly`; a readback +disagreement is blocked with guidance to allow the documented propagation +window before an operator decides whether to restore the captured prior +Boolean. The setting covers SharePoint, OneDrive, and Teams together, so there +is no separate Teams write. + +The quarantine slice enumerates quarantine policies and selects the exact +custom policy name `SMBTool-Quarantine-LimitedAccess`. The configured desired state uses limited +access (`EndUserQuarantinePermissionsValue = 43`), enables end-user spam +notifications, excludes blocked-sender messages from notifications, and makes +no protection-policy assignments. Because the module never creates an +assignment, a policy it creates does not affect any recipient until an +administrator assigns it in an anti-spam, anti-phishing, anti-malware, or Safe +Attachments policy. The module also cannot read existing assignments, so it +cannot tell whether a policy that already exists is in use. Production write +dispatch remains `GuidedOnly`. + +A same-name policy that the toolkit does not own blocks the run. +`-AdoptExisting` does not lift that block for quarantine policies: because the +existing policy's references cannot be read, updating its end-user permissions +could change what recipients can do with quarantined mail. Rename or remove the +existing policy, or configure a different quarantine policy name, then re-run. +A configured name that differs from an existing policy only by capitalization +is also blocked, because Exchange Online treats those as the same policy and +would reject the create. + +The module supports creation and compliant no-op only. If a policy the toolkit +already owns differs from the selected state, the run blocks rather than call +`Set-QuarantinePolicy`. This protects recipients when an administrator has +assigned the policy to anti-spam, anti-phishing, anti-malware, or Safe +Attachments after creation. The toolkit cannot see those references, so it +does not update any existing quarantine policy. Restore the selected values +manually after reviewing assignments, or configure a different policy name. + +Creation remains unavailable unless `mdo-quarantine-policy-apply` is verified +and is also present in the permission plan selected for the run. Promoting the +global operation record alone does not authorize a write. + +If the quarantine slice cannot proceed, the Safe Attachments and outbound +auto-forwarding assessments still run and record their evidence; the module +then reports the quarantine failure and the overall run fails. A configuration +that does not define `DefenderForOffice365.QuarantinePolicy` records a skipped +quarantine entry and the remaining assessments continue. + +Quarantine message release is not baseline configuration and remains outside +the module. Built-in quarantine policies are never changed. AIR pending action +approval remains a manual Defender portal workflow because Microsoft does not +expose a supported approval API or cmdlet. + +## Validation lifecycle + +The cumulative SFI review is reconstructed from the legacy and current Defender +pull-request evidence in the private historical reconciliation record. It +covers source evidence, deterministic tests, review findings, remediation, and +documented operating boundaries without changing original GitHub review +states. Operation-specific tenant evidence and promotion gates remain separate. + +## Module flow + +The orchestrator invokes preflight, MDO/EOP, Defender for Business, MDE +advanced, and Defender for Cloud Apps modules as separate script invocations +in sequence within the current PowerShell runspace. Each module is also +independently runnable on its own. A module that is skipped or unavailable +writes its exact continuation guidance to the run log. + +A standalone ASR write requires a context containing the verified apply or +recovery operation from the selected permission plan. Supplying a Microsoft +Graph request alone does not authorize a write. + +Authorization, validation, ownership-collision, and other deterministic module +failures are fail-fast. The orchestrator records the failed module and does not +dispatch later modules. Its cleanup path still writes the available redacted +JSON and HTML evidence so the deployment team can review the partial run. diff --git a/Products/Defender/docs/evidence.md b/Products/Defender/docs/evidence.md new file mode 100644 index 0000000..592e0a2 --- /dev/null +++ b/Products/Defender/docs/evidence.md @@ -0,0 +1,68 @@ +--- +title: Defender evidence and reports +parent: Microsoft Defender +nav_order: 3 +--- + +# Evidence and reports + +Each run writes a redacted JSON sidecar and HTML report under +`Products\Defender\Reports\`. Entries include module, action, status, +disposition, best-practice key, HTTP status when available, retry metadata, +and readback status. + +Retry entries include the HTTP status when available and identify when a +message-only cmdlet signature classified an EXO, IPPS, or SPO failure as +transient. The bounded retry decision is recorded before the next attempt; +deterministic authorization, validation, duplicate, and semantic failures are +not retried. + +Reports also include a module summary with one verdict per module: `OK`, +`FAILED`, `SKIPPED`, or `BLOCKED`. The JSON sidecar and HTML report use the +same verdict calculation, with failures taking precedence over blocked, +skipped, or successful entries. `GuidedOnly` entries are reported as +`SKIPPED`; an explicit blocked disposition is reported as `BLOCKED`. + +The human-readable report uses the configured friendly recommendation name, +not the internal best-practice key. Internal keys may remain in the JSON +sidecar for machine correlation, but are not presented as operator-facing +labels. Each configured-item summary correlates that key with the terminal +module outcome so compliant, blocked, guided-only, and skipped results are not +replaced by an unattributed fallback. + +The report is an operational record, not proof that a tenant policy was +changed. A `GuidedOnly` or `Skipped` disposition must be followed using the +continuation command recorded in the detail field. + +Quarantine policy evidence records collection discovery and exact-name +selection, the planned or applied disposition, collision or refusal result, and post-create comparison of +the selected permission and notification fields. It also states that no +protection-policy assignments were changed. Adoption and updates of existing +quarantine policies are refused, so evidence for those cases records a blocked +disposition with the reason. Evidence cannot show whether a quarantine policy +has been assigned to a protection policy because the module does not read +assignments. The create-only boundary prevents that missing information from +reaching an update. Evidence also records whether the selected permission plan +authorized the apply operation. When the quarantine slice fails, the +Safe Attachments and outbound auto-forwarding entries are still recorded before +the run reports the quarantine failure. + +Do not commit reports, tenant identifiers, access tokens, customer data, or +pilot evidence. Store pilot evidence in the approved private validation +location. + +## Reviewer access to validation evidence + +Each pilot or retrospective validation record must have a stable evidence ID. +Add the ID, date, scope, classification, and sanitized disposition to the +Defender validation register and reference the protected evidence location from +the pull request or release-readiness record. Reviewers who need to inspect the +record must be granted access to that protected location through the approved +sharing process; do not attach the record to GitHub or copy it into a +non-public repository directory. The PR or release record is the discoverability +index, while the protected location is the evidence source of truth. + +For a retrospective record, label it `Retrospective / contributor-reported` and +state whether the underlying command output or notes are retained. A +repository-level summary is not a substitute for reviewer access to the +evidence source. diff --git a/Products/Defender/docs/permissions.md b/Products/Defender/docs/permissions.md new file mode 100644 index 0000000..2991eed --- /dev/null +++ b/Products/Defender/docs/permissions.md @@ -0,0 +1,81 @@ +--- +title: Defender permissions and supportability +parent: Microsoft Defender +nav_order: 2 +--- + +# Permissions and supportability + +The permission plan is operation-scoped. The verified read-only operation is +Graph tenant identity. API and workload capability checks, plus licensing +documentation links, are recorded in the structured run log. The toolkit does +not infer entitlement from SKU or service-plan names. + +Tenant identity requests only `id` and `verifiedDomains` from `/organization`. +Delegated `User.Read` permits both fields, so this operation does not require +broader organization-read consent. Application access has a separate permission +requirement, `Organization.Read.All`, and remains subject to the product's +app-only gate. See [List organizations](https://learn.microsoft.com/graph/api/organization-list?view=graph-rest-1.0) +(checked September 25, 2026). Missing fields or a tenant mismatch still stop +the run before deployment. + +Delegated Graph sign-in may use browser authentication or device code flow. +Both paths issue delegated tokens and require the same operator consent and +tenant authorization. Device code flow is an authentication fallback; it does +not provide application permissions or make a guided-only operation writable. + +Delegated sessions must belong to the operator supplied as `-TenantAdminUpn`, +not just an account in the same tenant. For GDAP, Graph authentication targets +the explicit customer tenant ID or the `-DelegatedOrganization` domain. +The connected organization must verify that domain. The toolkit does not +grant GDAP access, change roles, or add consent to make a mismatch pass. + +The MDO/EOP module establishes its own delegated Exchange Online session; +Graph authentication does not establish that session. Current-state reads do +not require setter permission. Immediately before an approved write, the +module verifies effective Exchange RBAC for the exact cmdlet parameters with +`Get-ManagementRoleAssignment -RoleAssignee`; it never assigns a role. + +Defender portal and Defender for Cloud Apps operations retain their documented +guided-only or unavailable boundaries. ASR assessment uses +`DeviceManagementConfiguration.Read.All`. Explicit ASR apply and recovery use +`DeviceManagementConfiguration.ReadWrite.All` plus `Group.Read.All` to verify +the supplied pilot security-group identifier before mutation. + +Quarantine policy configuration uses dynamically imported Exchange Online +cmdlets. Before an approved creation, it checks effective RBAC for every +required `New-QuarantinePolicy` parameter and requires the verified +`mdo-quarantine-policy-apply` operation in the selected permission plan. +Production mutation remains `GuidedOnly`. See the +[Exchange Online protection boundary](deployment.md#exchange-online-protection-boundary) +for desired state, collision handling, and the create-only lifecycle. + +Write operations remain guided-only until an authoritative endpoint, exact +delegated/application permissions, role or GDAP mapping, licensing +documentation, retry behavior, readback projection, and rollback procedure are +documented and verified. Actual readiness is established by workload/API +availability and authorized readback; do not treat `-WhatIf` as write +authorization. + +The ASR policy operation uses the Microsoft Graph Intune +`configurationPolicies` **beta/preview** API; there is no `v1.0` equivalent +today. Apply and recovery require +`DeviceManagementConfiguration.ReadWrite.All`; group verification requires +`Group.Read.All`. See [Deployment](deployment.md) for the managed apply, +readback, rerun, and separately acknowledged recovery behavior. + +## Licensing references + +Licensing is documented for operator review and is not inferred from SKU or +service-plan names. Suites and add-ons can satisfy a product prerequisite, so +confirm the applicable entitlement and service availability using the +product's current licensing guidance: + +The controlled probe observed the successfully provisioned `ATP_ENTERPRISE` service plan as +supporting commercial evidence for Defender for Office 365 Plan 1. The toolkit +does not use that observation as a static entitlement gate. Workload +availability and authorized readback remain the runtime authority. + +- [Microsoft Defender for Office 365](https://learn.microsoft.com/en-us/defender-office-365/service-description-mdvo) +- [Microsoft Defender for Endpoint](https://learn.microsoft.com/en-us/defender-endpoint/microsoft-defender-endpoint) +- [Microsoft Defender for Cloud Apps](https://learn.microsoft.com/en-us/defender-cloud-apps/what-is-defender-for-cloud-apps) diff --git a/Products/Defender/docs/scenarios.md b/Products/Defender/docs/scenarios.md new file mode 100644 index 0000000..0f9ef06 --- /dev/null +++ b/Products/Defender/docs/scenarios.md @@ -0,0 +1,21 @@ +--- +title: Defender scenarios and boundaries +parent: Microsoft Defender +nav_order: 4 +--- + +# Scenarios and boundaries + +| Scenario | Current outcome | +|---|---| +| Graph identity and workload/API capability preflight | Read-only; licensing is documented, not inferred from SKU names | +| MDO/EOP baseline | Reads delegated Exchange policy state; quarantine creation remains GuidedOnly, compliant quarantine policies are no-ops, and existing-policy drift is blocked | +| Defender for Business / ASR | Read-only by default; explicit managed Audit apply and separately acknowledged recovery use the Microsoft Graph Intune `/beta` configuration policy API (Preview); configuration and assignment pilot validation complete | +| MDE advanced settings | Guided-only; use the [Microsoft SMB deployment guide references](best-practices.md#guided-deployment-references) | +| Defender for Cloud Apps | Guided-only; use the [Microsoft SMB deployment guide references](best-practices.md#guided-deployment-references); no connectors, credentials, or enforcement | +| Noninteractive execution | Not supported by this release; use interactive delegated sign-in | +| High-risk enforcement | Blocked without approval and recovery controls | + +The product is Available within the documented release scope. Missing live +evidence remains an operation-specific promotion gate and does not broaden any +`GuidedOnly` path. diff --git a/Products/Defender/docs/troubleshooting.md b/Products/Defender/docs/troubleshooting.md new file mode 100644 index 0000000..b3ccaf8 --- /dev/null +++ b/Products/Defender/docs/troubleshooting.md @@ -0,0 +1,66 @@ +--- +title: Defender troubleshooting +parent: Microsoft Defender +nav_order: 5 +--- + +# Troubleshooting + +**Permission plan is not ready:** inspect the operation key in the report. +Unverified write operations are intentionally guided-only; do not add scopes +or bypass the manifest. + +**Noninteractive authentication is unavailable:** certificate-based +authentication and `-NonInteractive` execution are intentionally blocked in the +current preview until operation-level application permission evidence is +approved. Use delegated browser authentication or device code flow instead. + +**The browser prompt succeeds but `Connect-MgGraph` fails:** this indicates a +local WAM/token handoff or module-loading problem, not proof that tenant +authentication succeeded. Close other PowerShell and VS Code terminals, start +`pwsh -NoLogo -NoProfile`, load one Graph Authentication module version, and +target the tenant explicitly with `-TenantId`. If the failure persists, use +`-UseDeviceAuthentication` as the supported fallback. Do not delete broad +credential stores or Windows work-account registrations without first +identifying the owning client and impact. + +If you authenticate with `Connect-MgGraph` in the same process before starting +the orchestrator, the requested operator, delegated authentication mode, +required scopes and verified tenant must all match before that context is +reused. A different account or tenant, or an incomplete scope set, causes the +orchestrator to disconnect that process-wide Graph context before requesting +a new sign-in. A tenant verification read failure stops the run. Run the toolkit in a dedicated +PowerShell process when another task must retain its existing Graph session. + +For GDAP, supply the customer domain with `-DelegatedOrganization`. Graph +uses it as the authentication target unless an explicit customer `-TenantId` +was provided. The live organization must still verify the customer domain. +If sign-in completes with a different account than `-TenantAdminUpn`, the run +stops instead of reporting that operator as authenticated. Select the +requested account in a fresh sign-in; do not broaden permissions to bypass +an account or tenant mismatch. + +**Device code returns `AADSTS70011` (`invalid_scope`):** retry with an explicit +`-TenantId` and pass scopes as an array or comma-separated values. Test with +`User.Read` first, then add the other required scopes one at a time. A +successful browser step does not issue a token if Entra rejects the scope or +audience during the token exchange. + +**A workload is unavailable:** EXO/IPPS, Intune, Defender portal, or MDCA can +be unavailable when the tenant, license, permission, or service provisioning +does not satisfy the selected operation. Follow the exact continuation +guidance in the report. Do not infer ASR write readiness from a successful +read-only Intune check. + +**Licensing is unclear:** review the Microsoft Learn licensing link recorded for +the affected capability. The toolkit does not infer entitlement from SKU or +service-plan names; confirm the service is available and authorized through the +workload capability/readback check. + +**A run stops after a failed read:** preserve the redacted report, correct the +tenant or permission issue, and rerun. Default and GuidedOnly paths do not +dispatch writes. If an explicitly enabled ASR Audit operation began, use the +captured policy ID and the separately gated recovery procedure rather than +deleting tenant state manually. Recovery permanently deletes the captured +policy and cannot be undone; see the Microsoft Graph +[delete configurationPolicy API](https://learn.microsoft.com/graph/api/intune-deviceconfigv2-devicemanagementconfigurationpolicy-delete?view=graph-rest-beta). diff --git a/Products/Entra/Config/EntraConfig.psd1 b/Products/Entra/Config/EntraConfig.psd1 new file mode 100644 index 0000000..67fb8e4 --- /dev/null +++ b/Products/Entra/Config/EntraConfig.psd1 @@ -0,0 +1,226 @@ +@{ + ProductName = 'Microsoft Entra Best Practice Toolkit' + ProductVersion = '0.4.0' + ManagedByTag = '[Managed by SMBTool Entra Toolkit]' + + # Source baseline: Identity Protection Best Practice Deployment guide for + # Small Business (Business Premium / Microsoft Entra ID P1, 6 March 2026). + # GuideTask maps an item back to the source so evidence can be traced. + # The guide places all Conditional Access work under Priority 1 ("Set up + # Conditional Access using built-in templates"), preceded by the Priority 1 + # task "Create emergency access account(s)". + # Additional P1 scenarios follow the Microsoft Learn references in + # docs/Coverage.md and have not inherited the original baseline's pilot evidence. + BestPracticeItems = @( + @{ + Key = 'emergency-access-account' + Name = 'Ensure an emergency access (break-glass) account is excluded from Conditional Access' + Module = 'Setup-EmergencyAccess' + LicenseCapability = 'entraId' + Risk = 'Standard' + RequiresHighRiskGate = $false + Priority = 1 + GuideTask = 1 + } + @{ + Key = 'conditional-access-baseline' + Name = 'Deploy the Conditional Access baseline policies' + Module = 'Setup-ConditionalAccessBaseline' + LicenseCapability = 'entraId' + # High risk: Conditional Access can deny sign-in to every user and + # lock administrators out. Report-only by default; enforcing is a + # deliberate, separately gated promotion. + Risk = 'High' + RequiresHighRiskGate = $true + Priority = 1 + GuideTask = 2 + } + @{ + Key = 'tenant-security-settings' + Name = 'Harden tenant-wide identity settings (Zero Trust)' + Module = 'Setup-TenantSecuritySettings' + LicenseCapability = 'entraId' + Risk = 'Standard' + RequiresHighRiskGate = $false + Priority = 2 + GuideTask = 3 + } + ) + + LicenseCapabilities = @{ + entraId = @{ + SkuPartNumbers = @('SPB', 'AAD_PREMIUM', 'EMS', 'EMSPREMIUM') + ServicePlanNames = @('AAD_PREMIUM', 'AAD_PREMIUM_P2') + } + } + + # Least-privilege delegated scopes. Read scopes cover preflight/readback; + # the ReadWrite scopes are each required by a validated write path. Do not + # place secrets or tokens here. + Api = @{ + GraphBaseUri = 'https://graph.microsoft.com/v1.0' + GraphScopes = @( + 'User.Read' + 'Organization.Read.All' + 'Policy.Read.All' + 'RoleManagement.Read.Directory' + 'Group.Read.All' + 'User.Read.All' + # Write scopes, each tied to a validated apply path: + 'Policy.ReadWrite.ConditionalAccess' # create the CA baseline + 'User.ReadWrite.All' # create a break-glass account when requested + 'Policy.ReadWrite.Authorization' # user-consent and guest tenant settings + 'Policy.ReadWrite.ConsentRequest' # admin consent workflow + 'Domain.ReadWrite.All' # password expiration policy + ) + RequiredCommands = @( + 'Connect-MgGraph' + 'Invoke-MgGraphRequest' + 'Get-MgContext' + ) + } + + # The all-users-style policies (block legacy auth, MFA for all users, + # compliant-or-MFA, no persistent browser, approved client apps, unsupported + # platform) are scoped to this pilot group rather than every user, because a + # Conditional Access policy applied tenant-wide on a first run can deny + # access broadly. Role- and app-scoped policies (admins, admin portals, + # Azure management) keep their template scope. Tenant-wide is an explicit + # opt-in via -AssignTenantWide. + Assignment = @{ + DefaultScope = 'PilotGroup' + RequirePilotGroupForHighRisk = $true + AllowTenantWideAssignmentForHighRisk = $false + } + + # Conditional Access is the highest blast-radius surface in the baseline. + ConditionalAccess = @{ + # Every policy is created in this state by default. The guide's + # recommended target state per policy is recorded below; promoting a + # policy to 'enabled' is a deliberate configuration change once the + # guide's stated preconditions are met. + DefaultState = 'enabledForReportingButNotEnforced' + + # A break-glass exclusion is mandatory: the baseline refuses to create + # any policy until an emergency-access user or group is available, so a + # misconfiguration cannot lock every account out. + RequireBreakGlassExclusion = $true + BreakGlass = @{ + # Operator supplies an existing emergency-access account/group here, + # or sets CreateAccountIfMissing to have Setup-EmergencyAccess create + # a dedicated cloud-only break-glass account (guide Priority 1 task). + ExcludeUserIds = @() + ExcludeGroupIds = @() + CreateAccountIfMissing = $false + AccountDisplayName = 'Emergency Access (break-glass)' + AccountUpnPrefix = 'break-glass-emergency' + # The built-in Emergency Access Account directory role many CA + # templates already exclude by role. + EmergencyAccessRoleId = 'd29b2b05-8046-44ba-8758-1e26182fcf32' + } + + PolicyTemplateDirectory = 'Config/PolicyTemplates' + # Empty preserves existing names. Example: 'SMB-CA-' adds each policy's + # stable tier/sequence/scope NameCode before its purpose. + DisplayNamePrefix = '' + # Retain prior prefixes after changing one so reruns find existing policies. + PreviousDisplayNamePrefixes = @() + + # Original templates retain the guide's target ("On" = enabled). + # Additions stay ReportOnly pending their own pilot. This metadata + # never promotes a policy; DefaultState controls new-policy state. + # Corrected targeting, MFA alternatives and session controls require + # manual migration of existing policies, including with AdoptExisting. + Policies = @( + @{ Key = 'require-mfa-admins'; File = 'require-mfa-admins.json'; RecommendedState = 'On'; Tier = 'P1Baseline'; NameCode = 'P1-01-Admins'; ReviewExistingOnly = $true } + @{ Key = 'block-legacy-authentication'; File = 'block-legacy-authentication.json'; RecommendedState = 'On'; Tier = 'P1Baseline'; NameCode = 'P1-02-Users' } + @{ Key = 'require-mfa-all-users'; File = 'require-mfa-all-users.json'; RecommendedState = 'On'; Tier = 'P1Baseline'; NameCode = 'P1-03-Users' } + @{ Key = 'require-mfa-guests'; File = 'require-mfa-guests.json'; RecommendedState = 'On'; Tier = 'P1Baseline'; NameCode = 'P1-04-Guests'; ReviewExistingOnly = $true } + @{ Key = 'require-mfa-azure-management'; File = 'require-mfa-azure-management.json'; RecommendedState = 'On'; Tier = 'P1Baseline'; NameCode = 'P1-05-AzureManagement'; ReviewExistingOnly = $true } + @{ Key = 'require-mfa-admin-portals'; File = 'require-mfa-admin-portals.json'; RecommendedState = 'On'; Tier = 'P1Baseline'; NameCode = 'P1-06-AdminPortals'; ReviewExistingOnly = $true } + @{ Key = 'block-unsupported-device-platform'; File = 'block-unsupported-device-platform.json'; RecommendedState = 'On'; Tier = 'P1Baseline'; NameCode = 'P1-07-Users' } + @{ Key = 'no-persistent-browser-session'; File = 'no-persistent-browser-session.json'; RecommendedState = 'ReportOnly'; Tier = 'P1Baseline'; NameCode = 'P1-08-Users'; ReviewExistingOnly = $true } + @{ + Key = 'require-approved-client-apps' + File = 'require-approved-client-apps.json' + RecommendedState = 'ReportOnly' + Tier = 'P1Baseline' + NameCode = 'P1-09-Mobile' + LegacyDisplayNames = @('Require approved client apps or app protection policies') + # Correcting existing mobile filters can broaden effective scope. + # Keep this migration manual, including when AdoptExisting is set. + ReviewExistingOnly = $true + } + @{ Key = 'require-compliant-device-or-mfa'; File = 'require-compliant-device-or-mfa.json'; RecommendedState = 'ReportOnly'; Tier = 'P1Baseline'; NameCode = 'P1-10-Users'; ReviewExistingOnly = $true } + @{ + Key = 'block-device-code-flow' + File = 'block-device-code-flow.json' + RecommendedState = 'ReportOnly' + Tier = 'P1Baseline' + NameCode = 'P1-11-Users' + Enabled = $true + ReviewExistingOnly = $true + } + @{ + Key = 'protect-security-info-registration' + File = 'protect-security-info-registration.json' + RecommendedState = 'ReportOnly' + Tier = 'P1Baseline' + NameCode = 'P1-12-Registration' + Enabled = $true + ReviewExistingOnly = $true + } + @{ + Key = 'require-phishing-resistant-mfa-admins' + File = 'require-phishing-resistant-mfa-admins.json' + RecommendedState = 'ReportOnly' + Tier = 'P1Hardened' + NameCode = 'P1H-01-Admins' + # Targets roles, not the pilot group. Verify method readiness + # for all targeted administrators before separately enforcing. + Enabled = $false + ReviewExistingOnly = $true + } + ) + } + + Report = @{ + OutputDirectory = '.\Reports' + JsonLogFileName = 'entra-run-log.json' + HtmlReportFileName = 'entra-run-report.html' + } + + # Tenant-wide identity hardening from the Zero Trust "Configure Microsoft + # Entra for increased security" guidance. Each setting is opt-in via Apply; + # when Apply is $false the module reports current vs recommended state + # (GuidedOnly) and changes nothing. Every write uses a GA v1.0 Graph API. + TenantSecurity = @{ + # Restrict who users can consent to apps for (user-consent to verified + # publishers / low-impact permissions only). + UserConsent = @{ + Apply = $false + PermissionGrantPoliciesAssigned = @('managePermissionGrantsForSelf.microsoft-user-default-low') + } + # Enable the admin consent request workflow so users can request access + # that admins review, instead of consenting themselves. + AdminConsentWorkflow = @{ + Apply = $false + IsEnabled = $true + ReviewerGroupIds = @() + } + # Restrict guest access: only admins/guest-inviters can invite, and + # guests get the restricted directory role. + GuestAccess = @{ + Apply = $false + AllowInvitesFrom = 'adminsAndGuestInviters' + # Restricted guest user role (most limited directory access). + GuestUserRoleId = '2af84b1e-32c8-42b7-82bc-daa82404023b' + } + # Disable password expiration (Microsoft guidance: long-lived passwords + # with MFA beat forced rotation). Applied to the primary verified domain. + PasswordExpiration = @{ + Apply = $false + DisableExpiration = $true + } + } +} diff --git a/Products/Entra/Config/PolicyTemplates/block-device-code-flow.json b/Products/Entra/Config/PolicyTemplates/block-device-code-flow.json new file mode 100644 index 0000000..9b79260 --- /dev/null +++ b/Products/Entra/Config/PolicyTemplates/block-device-code-flow.json @@ -0,0 +1,11 @@ +{ + "displayName": "Block device code flow", + "state": "enabledForReportingButNotEnforced", + "conditions": { + "applications": { "includeApplications": ["All"] }, + "clientAppTypes": ["all"], + "authenticationFlows": { "transferMethods": "deviceCodeFlow" }, + "users": { "includeGroups": ["{ID}"] } + }, + "grantControls": { "operator": "OR", "builtInControls": ["block"] } +} diff --git a/Products/Entra/Config/PolicyTemplates/block-legacy-authentication.json b/Products/Entra/Config/PolicyTemplates/block-legacy-authentication.json new file mode 100644 index 0000000..67fd080 --- /dev/null +++ b/Products/Entra/Config/PolicyTemplates/block-legacy-authentication.json @@ -0,0 +1,22 @@ +{ + "displayName": "Block legacy authentication", + "grantControls": { + "builtInControls": "block", + "operator": "OR" + }, + "state": "enabled", + "conditions": { + "applications": { + "includeApplications": "All" + }, + "clientAppTypes": [ + "exchangeActiveSync", + "other" + ], + "users": { + "includeGroups": [ + "{ID}" + ] + } + } +} diff --git a/Products/Entra/Config/PolicyTemplates/block-unsupported-device-platform.json b/Products/Entra/Config/PolicyTemplates/block-unsupported-device-platform.json new file mode 100644 index 0000000..0f8d5c0 --- /dev/null +++ b/Products/Entra/Config/PolicyTemplates/block-unsupported-device-platform.json @@ -0,0 +1,30 @@ +{ + "displayName": "Block access for unknown or unsupported device platform", + "grantControls": { + "builtInControls": "block", + "operator": "OR" + }, + "state": "enabled", + "conditions": { + "applications": { + "includeApplications": "All" + }, + "clientAppTypes": "all", + "platforms": { + "excludePlatforms": [ + "android", + "iOS", + "windows", + "macOS", + "linux", + "windowsPhone" + ], + "includePlatforms": "all" + }, + "users": { + "includeGroups": [ + "{ID}" + ] + } + } +} diff --git a/Products/Entra/Config/PolicyTemplates/no-persistent-browser-session.json b/Products/Entra/Config/PolicyTemplates/no-persistent-browser-session.json new file mode 100644 index 0000000..1213e08 --- /dev/null +++ b/Products/Entra/Config/PolicyTemplates/no-persistent-browser-session.json @@ -0,0 +1,34 @@ +{ + "displayName": "No persistent browser session", + "sessionControls": { + "signInFrequency": { + "frequencyInterval": "timeBased", + "value": 1, + "isEnabled": true, + "authenticationType": "primaryAndSecondaryAuthentication", + "type": "hours" + }, + "persistentBrowser": { + "mode": "never", + "isEnabled": true + } + }, + "state": "enabled", + "conditions": { + "devices": { + "deviceFilter": { + "mode": "include", + "rule": "device.trustType -ne \"ServerAD\" -or device.isCompliant -ne True" + } + }, + "applications": { + "includeApplications": "All" + }, + "clientAppTypes": "all", + "users": { + "includeGroups": [ + "{ID}" + ] + } + } +} diff --git a/Products/Entra/Config/PolicyTemplates/protect-security-info-registration.json b/Products/Entra/Config/PolicyTemplates/protect-security-info-registration.json new file mode 100644 index 0000000..043f0d1 --- /dev/null +++ b/Products/Entra/Config/PolicyTemplates/protect-security-info-registration.json @@ -0,0 +1,13 @@ +{ + "displayName": "Protect security information registration", + "state": "enabledForReportingButNotEnforced", + "conditions": { + "applications": { "includeUserActions": ["urn:user:registersecurityinfo"] }, + "clientAppTypes": ["all"], + "users": { + "includeGroups": ["{ID}"], + "excludeUsers": ["GuestsOrExternalUsers"] + } + }, + "grantControls": { "operator": "OR", "builtInControls": ["mfa"] } +} diff --git a/Products/Entra/Config/PolicyTemplates/require-approved-client-apps.json b/Products/Entra/Config/PolicyTemplates/require-approved-client-apps.json new file mode 100644 index 0000000..fb91a1f --- /dev/null +++ b/Products/Entra/Config/PolicyTemplates/require-approved-client-apps.json @@ -0,0 +1,28 @@ +{ + "displayName": "Require app protection policy", + "grantControls": { + "builtInControls": [ + "compliantApplication" + ], + "operator": "OR" + }, + "state": "enabledForReportingButNotEnforced", + "conditions": { + "applications": { + "includeApplications": "All" + }, + "clientAppTypes": "all", + "platforms": { + "excludePlatforms": [], + "includePlatforms": [ + "android", + "iOS" + ] + }, + "users": { + "includeGroups": [ + "{ID}" + ] + } + } +} diff --git a/Products/Entra/Config/PolicyTemplates/require-compliant-device-or-mfa.json b/Products/Entra/Config/PolicyTemplates/require-compliant-device-or-mfa.json new file mode 100644 index 0000000..c9ac483 --- /dev/null +++ b/Products/Entra/Config/PolicyTemplates/require-compliant-device-or-mfa.json @@ -0,0 +1,24 @@ +{ + "displayName": "Require compliant or Microsoft Entra hybrid joined device or multifactor authentication for all users", + "grantControls": { + "builtInControls": [ + "compliantDevice", + "domainJoinedDevice", + "mfa" + ], + "operator": "OR" + }, + "state": "enabled", + "conditions": { + "applications": { + "includeApplications": "All" + }, + "clientAppTypes": "all", + "users": { + "excludeRoles": "d29b2b05-8046-44ba-8758-1e26182fcf32", + "includeGroups": [ + "{ID}" + ] + } + } +} diff --git a/Products/Entra/Config/PolicyTemplates/require-mfa-admin-portals.json b/Products/Entra/Config/PolicyTemplates/require-mfa-admin-portals.json new file mode 100644 index 0000000..2fe6ecd --- /dev/null +++ b/Products/Entra/Config/PolicyTemplates/require-mfa-admin-portals.json @@ -0,0 +1,32 @@ +{ + "displayName": "Require multifactor authentication for Microsoft admin portals", + "grantControls": { + "builtInControls": "mfa", + "operator": "OR" + }, + "state": "enabled", + "conditions": { + "applications": { + "includeApplications": "MicrosoftAdminPortals" + }, + "clientAppTypes": "all", + "users": { + "includeRoles": [ + "62e90394-69f5-4237-9190-012177145e10", + "194ae4cb-b126-40b2-bd5b-6091b380977d", + "f28a1f50-f6e7-4571-818b-6a12f2af6b6c", + "29232cdf-9323-42fd-ade2-1d097af3e4de", + "b1be1c3e-b65d-4f19-8427-f6fa0d97feb9", + "729827e3-9c14-49f7-bb1b-9608f156bbb8", + "b0f54661-2d74-4c50-afa3-1ec803f12efe", + "fe930be7-5e62-47db-91af-98c3a49a38b1", + "c4e39bd9-1100-46d3-8c65-fb160da0071f", + "9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3", + "158c047a-c907-4556-b7ef-446551a6b5f7", + "966707d0-3269-4727-9be2-8c3a10f19b9d", + "7be44c8a-adaf-4e2a-84d6-ab2649e08a13", + "e8611ab8-c189-46e8-94e1-60213ab1f814" + ] + } + } +} diff --git a/Products/Entra/Config/PolicyTemplates/require-mfa-admins.json b/Products/Entra/Config/PolicyTemplates/require-mfa-admins.json new file mode 100644 index 0000000..7749f9c --- /dev/null +++ b/Products/Entra/Config/PolicyTemplates/require-mfa-admins.json @@ -0,0 +1,32 @@ +{ + "displayName": "Require multifactor authentication for admins", + "grantControls": { + "builtInControls": "mfa", + "operator": "OR" + }, + "state": "enabled", + "conditions": { + "applications": { + "includeApplications": "All" + }, + "clientAppTypes": "all", + "users": { + "includeRoles": [ + "62e90394-69f5-4237-9190-012177145e10", + "194ae4cb-b126-40b2-bd5b-6091b380977d", + "f28a1f50-f6e7-4571-818b-6a12f2af6b6c", + "29232cdf-9323-42fd-ade2-1d097af3e4de", + "b1be1c3e-b65d-4f19-8427-f6fa0d97feb9", + "729827e3-9c14-49f7-bb1b-9608f156bbb8", + "b0f54661-2d74-4c50-afa3-1ec803f12efe", + "fe930be7-5e62-47db-91af-98c3a49a38b1", + "c4e39bd9-1100-46d3-8c65-fb160da0071f", + "9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3", + "158c047a-c907-4556-b7ef-446551a6b5f7", + "966707d0-3269-4727-9be2-8c3a10f19b9d", + "7be44c8a-adaf-4e2a-84d6-ab2649e08a13", + "e8611ab8-c189-46e8-94e1-60213ab1f814" + ] + } + } +} diff --git a/Products/Entra/Config/PolicyTemplates/require-mfa-all-users.json b/Products/Entra/Config/PolicyTemplates/require-mfa-all-users.json new file mode 100644 index 0000000..255fc86 --- /dev/null +++ b/Products/Entra/Config/PolicyTemplates/require-mfa-all-users.json @@ -0,0 +1,20 @@ +{ + "displayName": "Require multifactor authentication for all users", + "grantControls": { + "builtInControls": "mfa", + "operator": "OR" + }, + "state": "enabled", + "conditions": { + "applications": { + "includeApplications": "All" + }, + "clientAppTypes": "all", + "users": { + "excludeRoles": "d29b2b05-8046-44ba-8758-1e26182fcf32", + "includeGroups": [ + "{ID}" + ] + } + } +} diff --git a/Products/Entra/Config/PolicyTemplates/require-mfa-azure-management.json b/Products/Entra/Config/PolicyTemplates/require-mfa-azure-management.json new file mode 100644 index 0000000..03c87ed --- /dev/null +++ b/Products/Entra/Config/PolicyTemplates/require-mfa-azure-management.json @@ -0,0 +1,19 @@ +{ + "displayName": "Require multifactor authentication for Azure management", + "grantControls": { + "builtInControls": "mfa", + "operator": "OR" + }, + "state": "enabled", + "conditions": { + "applications": { + "includeApplications": "797f4846-ba00-4fd7-ba43-dac1f8f63013" + }, + "clientAppTypes": "all", + "users": { + "includeUsers": [ + "All" + ] + } + } +} diff --git a/Products/Entra/Config/PolicyTemplates/require-mfa-guests.json b/Products/Entra/Config/PolicyTemplates/require-mfa-guests.json new file mode 100644 index 0000000..a44026d --- /dev/null +++ b/Products/Entra/Config/PolicyTemplates/require-mfa-guests.json @@ -0,0 +1,19 @@ +{ + "displayName": "Require multifactor authentication for guest access", + "grantControls": { + "builtInControls": "mfa", + "operator": "OR" + }, + "state": "enabled", + "conditions": { + "applications": { + "includeApplications": "All" + }, + "clientAppTypes": "all", + "users": { + "includeUsers": [ + "GuestsOrExternalUsers" + ] + } + } +} diff --git a/Products/Entra/Config/PolicyTemplates/require-phishing-resistant-mfa-admins.json b/Products/Entra/Config/PolicyTemplates/require-phishing-resistant-mfa-admins.json new file mode 100644 index 0000000..03ca02e --- /dev/null +++ b/Products/Entra/Config/PolicyTemplates/require-phishing-resistant-mfa-admins.json @@ -0,0 +1,30 @@ +{ + "displayName": "Require phishing-resistant MFA for admins", + "state": "enabledForReportingButNotEnforced", + "conditions": { + "applications": { "includeApplications": ["All"] }, + "clientAppTypes": ["all"], + "users": { + "includeRoles": [ + "62e90394-69f5-4237-9190-012177145e10", + "194ae4cb-b126-40b2-bd5b-6091b380977d", + "f28a1f50-f6e7-4571-818b-6a12f2af6b6c", + "29232cdf-9323-42fd-ade2-1d097af3e4de", + "b1be1c3e-b65d-4f19-8427-f6fa0d97feb9", + "729827e3-9c14-49f7-bb1b-9608f156bbb8", + "b0f54661-2d74-4c50-afa3-1ec803f12efe", + "fe930be7-5e62-47db-91af-98c3a49a38b1", + "c4e39bd9-1100-46d3-8c65-fb160da0071f", + "9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3", + "158c047a-c907-4556-b7ef-446551a6b5f7", + "966707d0-3269-4727-9be2-8c3a10f19b9d", + "7be44c8a-adaf-4e2a-84d6-ab2649e08a13", + "e8611ab8-c189-46e8-94e1-60213ab1f814" + ] + } + }, + "grantControls": { + "operator": "OR", + "authenticationStrength": { "id": "00000000-0000-0000-0000-000000000004" } + } +} diff --git a/Products/Entra/Deploy-EntraBestPractice.ps1 b/Products/Entra/Deploy-EntraBestPractice.ps1 new file mode 100644 index 0000000..2d0a302 --- /dev/null +++ b/Products/Entra/Deploy-EntraBestPractice.ps1 @@ -0,0 +1,405 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Entry point for the Microsoft Entra Best Practice Toolkit. + +.DESCRIPTION + Deploys the Conditional Access baseline from the Identity Protection Best + Practice Deployment guide (Business Premium / Microsoft Entra ID P1), after + ensuring an emergency-access (break-glass) exclusion exists. + + Every Conditional Access policy is created report-only by default and + excludes the configured break-glass principals. These controls do not prove + recovery or replacement protection. Enforcing a policy (state 'enabled') is a deliberate, separately gated + promotion. All writes are gated by ShouldProcess, wrapped in the shared + transient-retry boundary, read back, and recorded as structured evidence. + +.PARAMETER TenantAdminUpn + Operator UPN; also used to derive and verify the expected tenant domain. + +.PARAMETER DelegatedOrganization + Customer tenant domain for a GDAP delegated run. + +.PARAMETER AutoInstallModules + Install missing Microsoft Graph modules to CurrentUser without prompting. + +.PARAMETER PilotGroupId + Entra group the all-users-style policies are scoped to. High-risk items + require this unless -AssignTenantWide is supplied. + +.PARAMETER IncludeHighRisk + Required before the Conditional Access baseline is created. Requires + CustomerApprovalId so the authorization is recorded in evidence. + +.PARAMETER BreakGlassExclusionsConfirmed + Operator confirmation that an emergency-access account is excluded from every + policy. Required alongside IncludeHighRisk for the Conditional Access write. + +.PARAMETER AssignTenantWide + Scope the all-users policies to every user instead of the pilot group. This + is the highest blast-radius option and requires RollbackAcknowledged. + +.PARAMETER CustomerApprovalId + Approved change reference required with IncludeHighRisk. Only its hash is + recorded in evidence. + +.PARAMETER RollbackAcknowledged + Confirms that recovery has been reviewed. Required for Conditional Access + writes as well as tenant-wide assignment. + +.PARAMETER ConfigPath + Complete private configuration containing verified break-glass IDs, policy + selection and optional naming prefix. Defaults to Config\EntraConfig.psd1. + +.LINK + https://learn.microsoft.com/entra/identity/conditional-access/ +#> + +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +param( + [Parameter(Mandatory)] [ValidatePattern('^[^@\s]+@[^@\s]+\.[^@\s]+$')] [string] $TenantAdminUpn, + [ValidatePattern('^[^@\s]+\.[^@\s]+$')] [string] $DelegatedOrganization, + [string] $ConfigPath, + [switch] $AutoInstallModules, + [switch] $NonInteractive, + [switch] $AdoptExisting, + [switch] $SkipEmergencyAccess, + [switch] $SkipConditionalAccessBaseline, + [switch] $SkipTenantSecuritySettings, + [switch] $SkipDeploymentHealth, + [switch] $IncludeHighRisk, + [string] $CustomerApprovalId, + [switch] $RollbackAcknowledged, + [switch] $BreakGlassExclusionsConfirmed, + [switch] $AssignTenantWide, + [ValidatePattern('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$')] [string] $PilotGroupId +) + +$ErrorActionPreference = 'Stop' +$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$moduleRoot = Join-Path $scriptRoot 'Modules' +$runId = [guid]::NewGuid() +$startTime = [datetime]::UtcNow +$jsonPath = $null +$htmlPath = $null +$resolvedTenantId = $null + +. (Join-Path $moduleRoot 'EntraRunLog.ps1') +. (Join-Path $moduleRoot 'Invoke-WithTransientRetry.ps1') +. (Join-Path $moduleRoot 'Write-EntraHtmlReport.ps1') +. (Join-Path $moduleRoot 'EntraSecurityDefaults.ps1') + +function Test-EntraConfigContract { + [CmdletBinding()] + param([Parameter(Mandatory)] [hashtable] $Config) + + foreach ($section in @('BestPracticeItems', 'LicenseCapabilities', 'Api', + 'Assignment', 'ConditionalAccess', 'Report')) { + if (-not $Config.ContainsKey($section)) { + throw "Entra configuration must define a '$section' section." + } + } + if (-not $Config.BestPracticeItems) { + throw 'Entra configuration must define at least one BestPracticeItems entry.' + } + + $validRisk = @('Standard', 'High') + $keys = @() + foreach ($item in @($Config.BestPracticeItems)) { + foreach ($property in @('Key', 'Name', 'Module', 'Risk')) { + if ([string]::IsNullOrWhiteSpace([string] $item[$property])) { + throw "BestPracticeItems entries must define '$property'." + } + } + if ($item.Key -notmatch '^[a-z0-9]+(?:-[a-z0-9]+)+$') { + throw "BestPracticeItems key '$($item.Key)' must be lowercase kebab-case." + } + if ($keys -contains $item.Key) { throw "Duplicate BestPracticeItems key '$($item.Key)'." } + if ($item.Risk -notin $validRisk) { + throw "BestPracticeItems entry '$($item.Key)' declares Risk '$($item.Risk)'; valid values are $($validRisk -join ', ')." + } + if ($item.RequiresHighRiskGate -isnot [bool]) { + throw "BestPracticeItems entry '$($item.Key)' must declare RequiresHighRiskGate as a boolean." + } + if ($item.Risk -eq 'High' -and -not $item.RequiresHighRiskGate) { + throw "BestPracticeItems entry '$($item.Key)' is Risk High but does not set RequiresHighRiskGate." + } + $keys += $item.Key + } + + $ca = $Config.ConditionalAccess + $validState = @('enabledForReportingButNotEnforced', 'disabled', 'enabled') + if ($ca.DefaultState -notin $validState) { + throw "ConditionalAccess.DefaultState is '$($ca.DefaultState)'; valid values: $($validState -join ', ')." + } + if ($ca.RequireBreakGlassExclusion -isnot [bool]) { + throw 'ConditionalAccess.RequireBreakGlassExclusion must be a boolean.' + } + if ([string]::IsNullOrWhiteSpace([string] $ca.PolicyTemplateDirectory)) { + throw 'ConditionalAccess.PolicyTemplateDirectory must identify the policy template folder.' + } + if (-not $ca.Policies) { + throw 'ConditionalAccess must define at least one policy.' + } + Assert-EntraPolicyMigrationConfig -ConditionalAccess $ca + foreach ($key in @('OutputDirectory', 'JsonLogFileName', 'HtmlReportFileName')) { + if ([string]::IsNullOrWhiteSpace([string] $Config.Report[$key])) { + throw "Report section must define '$key'." + } + } +} + +function Deny-EntraRun { + [CmdletBinding()] + param([Parameter(Mandatory)] [string] $Gate, [Parameter(Mandatory)] [string] $Reason) + if (Test-EntraRunLogInitialized) { + Add-EntraRunLogEntry -Module 'Deploy-EntraBestPractice' ` + -Action $Gate -Status 'Skipped' -Disposition 'Blocked' -Detail $Reason + } + throw $Reason +} + +function Write-EntraWriteReadiness { + [CmdletBinding()] + param([Parameter(Mandatory)] [hashtable] $Config) + + if ($SkipConditionalAccessBaseline) { + Add-EntraRunLogEntry -Module 'Deploy-EntraBestPractice' -Action 'WriteReadiness' -Status 'Skipped' ` + -Detail 'Conditional Access was not selected. Other selected modules retain their own write gates.' + return + } + $missing = @( + if (-not $IncludeHighRisk) { '-IncludeHighRisk' } + if ([string]::IsNullOrWhiteSpace($CustomerApprovalId)) { '-CustomerApprovalId ' } + if (-not $BreakGlassExclusionsConfirmed) { '-BreakGlassExclusionsConfirmed' } + if (-not $RollbackAcknowledged) { '-RollbackAcknowledged' } + if (-not $AssignTenantWide -and [string]::IsNullOrWhiteSpace($PilotGroupId)) { '-PilotGroupId ' } + ) + $parameterState = if ($missing.Count) { 'Missing apply parameters: ' + ($missing -join ', ') + '.' } + else { 'Apply parameters supplied; tenant readiness, recovery, permissions and selected policy prerequisites still require verification.' } + Add-EntraRunLogEntry -Module 'Deploy-EntraBestPractice' -Action 'WriteReadiness' -Status 'Info' ` + -Disposition 'GuidedOnly' -Detail ( + "$parameterState Configure ConditionalAccess.BreakGlass.ExcludeUserIds or ExcludeGroupIds with verified emergency-access principals in your private ConfigPath. " + + 'Security Defaults must be verified disabled after an approved transition with active replacement protection; this toolkit never disables it automatically. ' + + 'First rerun the same approved configuration and scope with -WhatIf and all required parameters. Review the report before a separately approved run without -WhatIf. ' + + 'New policies remain report-only by default; enforcement is a separate change. AssignTenantWide additionally requires Assignment.AllowTenantWideAssignmentForHighRisk=true; it is not the default.' + ) +} + +$tasks = @() +try { + if (-not $ConfigPath) { $ConfigPath = Join-Path $scriptRoot 'Config\EntraConfig.psd1' } + if (-not (Test-Path -LiteralPath $ConfigPath)) { throw "Config file not found: $ConfigPath" } + + $config = Import-PowerShellDataFile -Path $ConfigPath + Test-EntraConfigContract -Config $config + + $reportDirectory = Join-Path $scriptRoot $config.Report.OutputDirectory + $jsonPath = Join-Path $reportDirectory $config.Report.JsonLogFileName + $htmlPath = Join-Path $reportDirectory $config.Report.HtmlReportFileName + Initialize-EntraRunLog -JsonPath $jsonPath -RunId $runId ` + -StartTime $startTime -ScriptVersion $config.ProductVersion ` + -TenantAdminUpn $TenantAdminUpn + + $tasks = @( + @{ Name = 'Setup-EmergencyAccess'; Stage = '[3/7] Emergency-access verification'; Script = 'Setup-EmergencyAccess.ps1'; Skip = $SkipEmergencyAccess; Args = @{ Config = $config } } + @{ Name = 'Setup-ConditionalAccessBaseline'; Stage = '[4/7] Conditional Access assessment and gated deployment'; Script = 'Setup-ConditionalAccessBaseline.ps1'; Skip = $SkipConditionalAccessBaseline; Args = @{ Config = $config; AdoptExisting = $AdoptExisting } } + @{ Name = 'Setup-TenantSecuritySettings'; Stage = '[5/7] Optional tenant security settings'; Script = 'Setup-TenantSecuritySettings.ps1'; Skip = $SkipTenantSecuritySettings; Args = @{ Config = $config; AdoptExisting = $AdoptExisting } } + @{ Name = 'Get-EntraDeploymentHealth'; Stage = '[6/7] Read-only deployment validation'; Script = 'Get-EntraDeploymentHealth.ps1'; Skip = $SkipDeploymentHealth; Args = @{ Config = $config } } + ) + + Add-EntraRunLogEntry -Module 'Deploy-EntraBestPractice' ` + -Action 'Run' -Status 'Started' -Detail 'Entra baseline run started.' + Add-EntraRunLogEntry -Module 'Deploy-EntraBestPractice' -Action 'Stage' -Status 'Info' ` + -Detail '[1/7] Prerequisites and write readiness' + Write-EntraWriteReadiness -Config $config + $baselineCount = @($config.ConditionalAccess.Policies | Where-Object { + (Get-EntraPolicyTier $_) -eq 'P1Baseline' -and (Test-EntraPolicySelected $_) + }).Count + $hardenedCount = @($config.ConditionalAccess.Policies | Where-Object { + (Get-EntraPolicyTier $_) -eq 'P1Hardened' -and (Test-EntraPolicySelected $_) + }).Count + Add-EntraRunLogEntry -Module 'Deploy-EntraBestPractice' -Action 'PolicyTier' -Status 'Info' ` + -Detail "Business Premium / P1 baseline selected=$baselineCount; P1 optional/hardened selected=$hardenedCount. Configuration selection is not proof of license entitlement." + Add-EntraRunLogEntry -Module 'Deploy-EntraBestPractice' -Action 'PolicyTier' -Status 'Info' -Disposition 'GuidedOnly' ` + -Detail 'P2 / Identity Protection: sign-in risk, user risk and risk-triggered stronger authentication are not deployed. These require separate entitlement, design and approval; authentication strength alone is a P1 capability.' + + if ($IncludeHighRisk -and [string]::IsNullOrWhiteSpace($CustomerApprovalId)) { + Deny-EntraRun -Gate 'IncludeHighRisk' -Reason 'IncludeHighRisk requires CustomerApprovalId.' + } + if ($AssignTenantWide -and -not $RollbackAcknowledged) { + Deny-EntraRun -Gate 'AssignTenantWide' -Reason 'AssignTenantWide requires RollbackAcknowledged.' + } + if ($AssignTenantWide -and -not $config.Assignment.AllowTenantWideAssignmentForHighRisk) { + Deny-EntraRun -Gate 'AssignTenantWide' ` + -Reason 'AssignTenantWide is disabled by Assignment.AllowTenantWideAssignmentForHighRisk in configuration.' + } + + if ($IncludeHighRisk) { + $approvalHash = [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($CustomerApprovalId))) + Add-EntraRunLogEntry -Module 'Deploy-EntraBestPractice' ` + -Action 'HighRiskApproval' -Status 'Info' -Disposition 'Applicable' ` + -Detail "High-risk operations authorised. CustomerApprovalReferenceSha256=$approvalHash." + } + + if ($WhatIfPreference) { + Add-EntraRunLogEntry -Module 'Deploy-EntraBestPractice' ` + -Action 'WhatIf' -Status 'Info' ` + -Detail 'WhatIf preview enabled; child modules will not change tenant state.' + } + + $assignmentScope = if ($AssignTenantWide) { 'TenantWide' } else { 'PilotGroup' } + + # The Conditional Access baseline is High risk. Withhold its write unless the + # operator supplied every gate; the item is still assessed and reported. + $writeBlockedItemKeys = @() + $caAuthorized = $IncludeHighRisk -and $BreakGlassExclusionsConfirmed -and $RollbackAcknowledged + if (-not $caAuthorized) { + $writeBlockedItemKeys += 'conditional-access-baseline' + $reason = 'Write authorization withheld: the Conditional Access baseline requires IncludeHighRisk with CustomerApprovalId, BreakGlassExclusionsConfirmed, and RollbackAcknowledged.' + Add-EntraRunLogEntry -Module 'Setup-ConditionalAccessBaseline' -Action 'WriteRiskGate' ` + -BestPracticeKey 'conditional-access-baseline' -Status 'Info' -Detail $reason + } + if ($caAuthorized -and $config.Assignment.RequirePilotGroupForHighRisk -and + -not $AssignTenantWide -and [string]::IsNullOrWhiteSpace($PilotGroupId)) { + Deny-EntraRun -Gate 'PilotGroup' ` + -Reason 'The Conditional Access baseline requires PilotGroupId, or AssignTenantWide with RollbackAcknowledged.' + } + + Add-EntraRunLogEntry -Module 'Deploy-EntraBestPractice' -Action 'Stage' -Status 'Info' ` + -Detail '[2/7] Tenant discovery and Security Defaults readiness' + $connectScript = Join-Path $moduleRoot 'Connect-EntraServices.ps1' + $connectArgs = @{ + TenantAdminUpn = $TenantAdminUpn + DelegatedOrganization = $DelegatedOrganization + Scopes = $config.Api.GraphScopes + GraphBaseUri = $config.Api.GraphBaseUri + NonInteractive = $NonInteractive + ConnectGraph = $true + } + if ($AutoInstallModules) { $connectArgs.AutoInstallModules = $true } + $connectionInfo = & $connectScript @connectArgs + $resolvedTenantId = [string]$connectionInfo.TenantIdentity.TenantId + if ([string]::IsNullOrWhiteSpace($resolvedTenantId)) { + throw 'The verified Microsoft Graph connection did not return a tenant ID.' + } + Set-EntraRunTenantId -TenantId $resolvedTenantId + $securityDefaultsState = Get-EntraSecurityDefaultsState ` + -BaseUri $config.Api.GraphBaseUri -Module 'Deploy-EntraBestPractice' + + $commonContext = @{ + TenantAdminUpn = $TenantAdminUpn + TenantId = $resolvedTenantId + ConnectionInfo = $connectionInfo + DelegatedOrganization = $DelegatedOrganization + IncludeHighRisk = [bool] $IncludeHighRisk + RollbackAcknowledged = [bool] $RollbackAcknowledged + BreakGlassExclusionsConfirmed = [bool] $BreakGlassExclusionsConfirmed + PilotGroupId = $PilotGroupId + AssignmentScope = $assignmentScope + WriteBlockedItemKeys = $writeBlockedItemKeys + BlockedItemKeys = @() + BreakGlassOutputPath = (Join-Path $reportDirectory 'entra-breakglass.json') + BreakGlassUserIds = @($config.ConditionalAccess.BreakGlass.ExcludeUserIds) + BreakGlassGroupIds = @($config.ConditionalAccess.BreakGlass.ExcludeGroupIds) + WhatIf = [bool] $WhatIfPreference + NonInteractive = [bool] $NonInteractive + SecurityDefaultsState = $securityDefaultsState + } + + foreach ($task in $tasks) { + Add-EntraRunLogEntry -Module $task.Name -Action 'Stage' -Status 'Info' -Detail $task.Stage + if ($task.Skip) { + Add-EntraRunLogEntry -Module $task.Name -Action 'Module' -Status 'Skipped' -Detail 'Skip switch was supplied.' + continue + } + $taskPath = Join-Path $moduleRoot $task.Script + if (-not (Test-Path -LiteralPath $taskPath -PathType Leaf)) { + Add-EntraRunLogEntry -Module $task.Name -Action 'Module' -Status 'Failed' -Detail "Module script not found: $taskPath" + throw "Module script not found: $taskPath" + } + Add-EntraRunLogEntry -Module $task.Name -Action 'Module' -Status 'Started' -Detail 'Module process started.' + $taskArgs = $task.Args + $taskArgs.Context = $commonContext + if ($WhatIfPreference) { $taskArgs['WhatIf'] = $true } + try { + & $taskPath @taskArgs + Add-EntraRunLogEntry -Module $task.Name -Action 'Module' -Status 'Succeeded' -Detail 'Module process completed.' + + # Fold the emergency-access module's resolved break-glass principals + # into the shared context so the Conditional Access baseline can + # exclude them. Later tasks receive this updated context at dispatch. + if ($task.Name -eq 'Setup-EmergencyAccess' -and + (Test-Path -LiteralPath $commonContext.BreakGlassOutputPath -PathType Leaf)) { + $bg = Get-Content -LiteralPath $commonContext.BreakGlassOutputPath -Raw | ConvertFrom-Json + $commonContext.BreakGlassUserIds = @(@($commonContext.BreakGlassUserIds) + @($bg.userIds) | Where-Object { $_ } | Select-Object -Unique) + $commonContext.BreakGlassGroupIds = @(@($commonContext.BreakGlassGroupIds) + @($bg.groupIds) | Where-Object { $_ } | Select-Object -Unique) + Add-EntraRunLogEntry -Module 'Deploy-EntraBestPractice' -Action 'BreakGlass' -Status 'Info' ` + -Detail "Break-glass exclusions available: $((@($commonContext.BreakGlassUserIds)+@($commonContext.BreakGlassGroupIds)).Count)." + } + } + catch { + $status = Get-EntraHttpStatusCode -ErrorRecord $_ + Add-EntraRunLogEntry -Module $task.Name -Action 'Module' -Status 'Failed' -HttpStatusCode $status -Detail $_.Exception.Message + throw + } + } + + Add-EntraRunLogEntry -Module 'Deploy-EntraBestPractice' -Action 'Run' -Status 'Info' ` + -Detail 'Execution completed. Read the module summary for failed, blocked or skipped work; completion does not prove deployment or protection.' +} +catch { + if (Test-EntraRunLogInitialized) { + Add-EntraRunLogEntry -Module 'Deploy-EntraBestPractice' -Action 'Run' -Status 'Failed' -Detail $_.Exception.Message + $entries = Get-EntraRunLog + $recordedModules = @($entries | Where-Object Action -eq 'Module' | Select-Object -ExpandProperty Module -Unique) + foreach ($task in $tasks) { + if ($task.Name -in $recordedModules) { continue } + $disposition = if ($task.Skip) { 'Skipped' } else { 'Blocked' } + $detail = if ($task.Skip) { 'Skip switch was supplied.' } else { + 'Not started because an earlier prerequisite or module failed. No assessment or deployment was performed by this module.' + } + Add-EntraRunLogEntry -Module $task.Name -Action 'Module' -Status 'Skipped' ` + -Disposition $disposition -Detail $detail + } + } + throw +} +finally { + if (Test-EntraRunLogInitialized) { + Add-EntraRunLogEntry -Module 'Deploy-EntraBestPractice' -Action 'Stage' -Status 'Info' ` + -Detail '[7/7] Summary and local evidence' + $moduleGroups = @((Get-EntraRunLog) | Group-Object Module | Sort-Object Name) + foreach ($group in $moduleGroups) { + $verdict = Get-EntraModuleVerdict -Entries @($group.Group) + $summaryDisposition = switch ($verdict) { 'BLOCKED' { 'Blocked' } 'FAILED' { 'Blocked' } 'SKIPPED' { 'Skipped' } default { 'Applicable' } } + $summaryStatus = if ($verdict -eq 'FAILED') { 'Failed' } else { 'Info' } + Add-EntraRunLogEntry -Module 'Deploy-EntraBestPractice' -Action 'ModuleSummary' -Status $summaryStatus ` + -Disposition $summaryDisposition -Detail "$($group.Name): $verdict" + } + $endTime = [datetime]::UtcNow + try { + $writtenJsonPath = Save-EntraRunLogJson -EndTime $endTime -PassThru + if ($writtenJsonPath) { + Write-Host ("`nJSON run log written: {0}" -f $writtenJsonPath) -ForegroundColor Cyan + } + try { + Write-EntraHtmlReport -Path $htmlPath -Entries (Get-EntraRunLog) ` + -RunId $runId -StartTime $startTime -EndTime $endTime ` + -TenantId $resolvedTenantId -TenantAdminUpn $TenantAdminUpn ` + -ScriptVersion $config.ProductVersion ` + -WhatIfRun:([bool] $WhatIfPreference) + $writtenHtmlPath = (Resolve-Path -LiteralPath $htmlPath -ErrorAction Stop).ProviderPath + Write-Host ("HTML report written: {0}" -f $writtenHtmlPath) -ForegroundColor Cyan + Write-Host ("Open report: Invoke-Item -LiteralPath '{0}'" -f $writtenHtmlPath.Replace("'", "''")) -ForegroundColor Cyan + } + catch { + Write-Warning ("HTML report could not be written: {0}" -f $_.Exception.Message) -WarningAction Continue + } + } + finally { + Clear-EntraRunLog + } + } +} diff --git a/Products/Entra/Modules/Connect-EntraServices.ps1 b/Products/Entra/Modules/Connect-EntraServices.ps1 new file mode 100644 index 0000000..1da9ac2 --- /dev/null +++ b/Products/Entra/Modules/Connect-EntraServices.ps1 @@ -0,0 +1,389 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Establishes the delegated Microsoft Graph connection required by Entra. + +.DESCRIPTION + Uses the same delegated UPN/GDAP session-guard behavior as Purview. A cached + context is reused only when its account, scopes, and target tenant are + trustworthy. Stale contexts are disconnected before a new interactive + connection is opened, and tenant identity is verified before setup begins. + +.PARAMETER TenantAdminUpn + Administrator UPN used for delegated sign-in. + +.PARAMETER DelegatedOrganization + Customer tenant domain for a GDAP run. Graph authenticates directly to this + tenant rather than the administrator's home tenant. + +.PARAMETER Scopes + Product-specific delegated Graph scopes supplied from EntraConfig.psd1. + +.PARAMETER AutoInstallModules + Installs missing Microsoft Graph modules to CurrentUser without prompting. + +.PARAMETER NonInteractive + Suppresses toolkit prompts. Delegated Graph authentication can still require + an existing usable token or interactive browser/WAM sign-in. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $TenantAdminUpn, + [string] $DelegatedOrganization, + [string[]] $Scopes = @('User.Read'), + [Parameter(Mandatory)] [string] $GraphBaseUri, + [switch] $AutoInstallModules, + [switch] $NonInteractive, + [switch] $ConnectGraph +) + +$ErrorActionPreference = 'Stop' +$WhatIfPreference = $false +$ConfirmPreference = 'None' + +. (Join-Path $PSScriptRoot 'EntraRunLog.ps1') +. (Join-Path $PSScriptRoot 'EntraGraphClient.ps1') + +function Add-EntraConnectGuardLog { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Service, + [Parameter(Mandatory)] + [ValidateSet('Info', 'Started', 'Retried', 'Failed')] + [string] $Status, + [Parameter(Mandatory)] [string] $ReasonKey, + [hashtable] $Detail + ) + + if (-not (Get-Command Add-EntraRunLogEntry -ErrorAction SilentlyContinue)) { + return + } + + $parts = @("reason=$ReasonKey") + if ($Detail) { + foreach ($key in ($Detail.Keys | Sort-Object)) { + $value = $Detail[$key] + if ($null -eq $value) { $value = '' } + $parts += ('{0}={1}' -f $key, $value) + } + } + + Add-EntraRunLogEntry -Module 'Connect-EntraServices' ` + -Action "SessionGuard:$Service" -Status $Status ` + -Detail ($parts -join '; ') +} + +function Test-EntraIsAdmin { + try { + $current = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() + return $current.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } + catch { + return $false + } +} + +function Ensure-EntraRequiredModule { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Name, + [string] $RequiredCmdlet, + [switch] $AutoInstall, + [switch] $NonInteractive + ) + + $available = Get-Module -ListAvailable -Name $Name -ErrorAction SilentlyContinue + if ($available) { + try { + Import-Module $Name -DisableNameChecking -ErrorAction Stop | Out-Null + } + catch { + Write-Verbose "Import-Module '$Name' failed: $($_.Exception.Message)" + } + } + + $cmdletOk = if ($RequiredCmdlet) { + [bool](Get-Command $RequiredCmdlet -ErrorAction SilentlyContinue) + } + else { + [bool]$available + } + if ($cmdletOk) { return } + + Write-Warning "Required module '$Name' is missing or its cmdlets cannot load$(if ($RequiredCmdlet) { " ($RequiredCmdlet not found)" })." + $shouldInstall = $AutoInstall.IsPresent + if (-not $shouldInstall -and $NonInteractive.IsPresent) { + $manual = "Install-Module $Name -Scope CurrentUser -Force -AllowClobber" + throw "Module '$Name' is required but is not installed and -NonInteractive is set (cannot prompt). Either pre-install the module or re-run with -AutoInstallModules:`n $manual" + } + if (-not $shouldInstall) { + $response = Read-Host "Install '$Name' from PSGallery now to the current user scope? [Y/n]" + if ([string]::IsNullOrWhiteSpace($response) -or $response -match '^(y|yes)$') { + $shouldInstall = $true + } + } + if (-not $shouldInstall) { + $manual = "Install-Module $Name -Scope CurrentUser -Force -AllowClobber" + throw "Module '$Name' is required but was not installed. Install manually and re-run:`n $manual" + } + + $isAdmin = Test-EntraIsAdmin + if (-not $isAdmin) { + Write-Host ' Installing to CurrentUser; elevation is not required.' -ForegroundColor DarkGray + } + try { + Install-Module -Name $Name -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop + } + catch { + $hint = if ($isAdmin) { + "Try installing for all users:`n Install-Module $Name -Scope AllUsers -Force -AllowClobber" + } + else { + "Install manually in an elevated PowerShell session, or retry with CurrentUser scope." + } + throw "Failed to install module '$Name': $($_.Exception.Message)`n$hint" + } + + try { + Import-Module $Name -DisableNameChecking -ErrorAction Stop | Out-Null + } + catch { + throw "Module '$Name' was installed but failed to import: $($_.Exception.Message)" + } + if ($RequiredCmdlet -and -not (Get-Command $RequiredCmdlet -ErrorAction SilentlyContinue)) { + throw "Module '$Name' was installed but cmdlet '$RequiredCmdlet' is still not available. Restart PowerShell and re-run." + } +} + +function Test-EntraWamBrokerReadiness { + [CmdletBinding()] + param() + + $issues = @() + $info = [ordered]@{ + wamEligible = $false + psEdition = $PSVersionTable.PSEdition + psVersion = $PSVersionTable.PSVersion.ToString() + isWindows = [bool]$IsWindows + windowsBuild = $null + productType = $null + userInteractive = [Environment]::UserInteractive + runningAsSystem = $false + graphAuthModuleVersion = 'not-installed' + } + + if (-not $IsWindows) { + $issues += 'WAM is available only on Windows; Graph will use its browser flow.' + } + else { + try { + $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop + $build = [int]$os.BuildNumber + $productType = [int]$os.ProductType + $info.windowsBuild = $build + $info.productType = $productType + if ($productType -eq 1 -and $build -lt 10240) { + $issues += "Windows build $build is older than Windows 10 1507." + } + elseif ($productType -ge 2 -and $build -lt 17763) { + $issues += "Windows Server build $build is older than Server 2019." + } + } + catch { + Write-Verbose "Could not detect Windows build via CIM: $($_.Exception.Message)" + } + + try { + $current = [Security.Principal.WindowsIdentity]::GetCurrent() + if ($current -and $current.IsSystem) { + $info.runningAsSystem = $true + $issues += 'WAM requires a user desktop session and cannot run as SYSTEM.' + } + } + catch { + Write-Verbose "Could not inspect the Windows identity: $($_.Exception.Message)" + } + } + + if (-not [Environment]::UserInteractive) { + $issues += 'WAM requires an interactive desktop session.' + } + + $graphModule = Get-Module Microsoft.Graph.Authentication -ListAvailable -ErrorAction SilentlyContinue | + Sort-Object Version -Descending | + Select-Object -First 1 + if ($graphModule) { + $info.graphAuthModuleVersion = $graphModule.Version.ToString() + if ($graphModule.Version.Major -lt 2) { + $issues += "Microsoft.Graph.Authentication v$($graphModule.Version) predates built-in WAM support." + } + } + + $info.wamEligible = ($issues.Count -eq 0) + return [pscustomobject]@{ + Eligible = $info.wamEligible + Issues = $issues + Info = $info + } +} + +function Get-EntraTenantIdentity { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $ExpectedDomain, + [Parameter(Mandatory)] [string] $TenantAdminUpn, + [Parameter(Mandatory)] [string] $GraphBaseUri + ) + + try { + $organizationUri = Resolve-EntraGraphUri -BaseUri $GraphBaseUri ` + -RelativePath 'organization?$select=id,verifiedDomains' + $response = Invoke-EntraGraphRequest -Method 'GET' -Uri $organizationUri ` + -EvidenceTarget 'tenant organization' + $organization = @($response.value)[0] + if (-not $organization -or [string]::IsNullOrWhiteSpace([string]$organization.id)) { + throw 'Microsoft Graph returned no tenant organization identity.' + } + $domains = @($organization.verifiedDomains | ForEach-Object { $_.name.ToLowerInvariant() }) + Register-EntraSensitiveValue -Value $domains + if ($domains -notcontains $ExpectedDomain.ToLowerInvariant()) { + throw 'Connected tenant does not contain the expected verified domain.' + } + + Add-EntraRunLogEntry -Module 'Connect-EntraServices' ` + -Action 'TenantIdentity' -Status 'Succeeded' ` + -Detail 'Tenant identity verified against the expected domain.' + return [pscustomobject]@{ + TenantId = $organization.id + ExpectedDomain = $ExpectedDomain + VerifiedDomains = $domains + TenantAdminUpn = $TenantAdminUpn + } + } + catch { + $status = Get-EntraHttpStatusCode -ErrorRecord $_ + Add-EntraRunLogEntry -Module 'Connect-EntraServices' ` + -Action 'TenantIdentity' -Status 'Failed' ` + -HttpStatusCode $status -Detail $_.Exception.Message + throw + } +} + +$tenantIdentity = $null +if ($ConnectGraph) { + Register-EntraSensitiveValue -Value $DelegatedOrganization + if ($TenantAdminUpn -notmatch '@(?[^@\s]+)$') { + throw 'Unable to derive an expected tenant domain from TenantAdminUpn.' + } + $upnDomain = $Matches.domain + Register-EntraSensitiveValue -Value $upnDomain + $targetTenantDomain = if ($DelegatedOrganization) { $DelegatedOrganization } else { $upnDomain } + + $wamReadiness = Test-EntraWamBrokerReadiness + if ($wamReadiness.Eligible) { + Write-Host "WAM broker available: Graph sign-in should usually use a one-click 'Continue' prompt." -ForegroundColor DarkGray + } + else { + Write-Warning 'Windows Authentication Manager (WAM) is not fully usable; expect a browser sign-in.' + foreach ($issue in $wamReadiness.Issues) { Write-Warning " * $issue" } + } + Add-EntraConnectGuardLog -Service 'Startup' -Status 'Info' ` + -ReasonKey $(if ($wamReadiness.Eligible) { 'wam-ready' } else { 'wam-not-ready' }) ` + -Detail $wamReadiness.Info + + Ensure-EntraRequiredModule -Name 'Microsoft.Graph.Authentication' ` + -RequiredCmdlet 'Connect-MgGraph' -AutoInstall:$AutoInstallModules ` + -NonInteractive:$NonInteractive + + $requiredScopes = @($Scopes | Where-Object { $_ } | Select-Object -Unique) + if ($requiredScopes.Count -eq 0) { $requiredScopes = @('User.Read') } + $guardDetail = @{ + expectedUpn = $TenantAdminUpn + expectedTenantDomain = $targetTenantDomain + expectedScopes = ($requiredScopes -join ',') + } + $graphConnected = $false + $guardReason = 'no-context' + + try { + $context = Get-MgContext -ErrorAction Stop + if ($context) { + $cachedScopes = @($context.Scopes) + $missingScopes = @($requiredScopes | Where-Object { $cachedScopes -notcontains $_ }) + if ($context.Account -and + $context.Account -ieq $TenantAdminUpn -and + $missingScopes.Count -eq 0) { + $tenantMatches = $false + try { + $organizationUri = Resolve-EntraGraphUri -BaseUri $GraphBaseUri ` + -RelativePath 'organization?$select=verifiedDomains' + $response = Invoke-EntraGraphRequest -Method 'GET' -Uri $organizationUri ` + -EvidenceTarget 'cached tenant organization' + $domains = @($response.value.verifiedDomains | ForEach-Object { $_.name.ToLowerInvariant() }) + Register-EntraSensitiveValue -Value $domains + $tenantMatches = $domains -contains $targetTenantDomain.ToLowerInvariant() + if (-not $tenantMatches) { $guardReason = 'tenant-domain-mismatch' } + } + catch { + $guardReason = 'tenant-verification-failed' + } + if ($tenantMatches) { + $graphConnected = $true + $guardReason = 'reused' + } + } + elseif ($missingScopes.Count -gt 0 -and $context.Account -ieq $TenantAdminUpn) { + $guardReason = 'missing-scopes' + $guardDetail.missingScopes = ($missingScopes -join ',') + } + else { + $guardReason = 'account-mismatch' + } + + $guardDetail.cachedAccount = [string]$context.Account + $guardDetail.cachedTenantId = [string]$context.TenantId + if (-not $graphConnected) { + Add-EntraConnectGuardLog -Service 'Graph' -Status 'Retried' ` + -ReasonKey $guardReason -Detail $guardDetail + try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch { } + } + } + } + catch { + $guardReason = 'get-mgcontext-failed' + $guardDetail.error = $_.Exception.Message + } + + if ($graphConnected) { + Add-EntraConnectGuardLog -Service 'Graph' -Status 'Info' ` + -ReasonKey 'reused' -Detail $guardDetail + } + else { + Add-EntraConnectGuardLog -Service 'Graph' -Status 'Started' ` + -ReasonKey "reconnect-$guardReason" -Detail $guardDetail + try { + Connect-MgGraph -TenantId $targetTenantDomain -Scopes $requiredScopes ` + -NoWelcome -ErrorAction Stop + Add-EntraConnectGuardLog -Service 'Graph' -Status 'Info' ` + -ReasonKey 'connected' -Detail $guardDetail + } + catch { + Add-EntraConnectGuardLog -Service 'Graph' -Status 'Failed' ` + -ReasonKey 'connect-failed' -Detail @{ error = $_.Exception.Message } + throw + } + } + + $tenantIdentity = Get-EntraTenantIdentity -ExpectedDomain $targetTenantDomain ` + -TenantAdminUpn $TenantAdminUpn -GraphBaseUri $GraphBaseUri +} + +[pscustomobject]@{ + TenantAdminUpn = $TenantAdminUpn + TenantId = if ($tenantIdentity) { $tenantIdentity.TenantId } else { $null } + DelegatedOrganization = $DelegatedOrganization + GraphConnected = [bool]$ConnectGraph + GraphBaseUri = $GraphBaseUri + TenantIdentity = $tenantIdentity +} diff --git a/Products/Entra/Modules/EntraDirectoryReads.ps1 b/Products/Entra/Modules/EntraDirectoryReads.ps1 new file mode 100644 index 0000000..00b4283 --- /dev/null +++ b/Products/Entra/Modules/EntraDirectoryReads.ps1 @@ -0,0 +1,240 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Shared read-only directory reads used by more than one Entra module. + +.DESCRIPTION + Holds Graph GET helpers that both the emergency-access setup and the + deployment health check need. They live here so the two cannot disagree + about whether an emergency-access principal can actually recover the tenant. + + Read-only. Every function here issues GET requests only. +#> + +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') +. (Join-Path $PSScriptRoot 'EntraPolicyComparison.ps1') + +# The Global Administrator role definition id. Microsoft's emergency-access +# guidance requires this role assigned PERMANENTLY, not PIM-eligible, so a PIM +# outage cannot lock the tenant out. +$script:EntraGlobalAdminRoleId = '62e90394-69f5-4237-9190-012177145e10' + +function Get-EntraGlobalAdminRoleId { + [CmdletBinding()] + [OutputType([string])] + param() + return $script:EntraGlobalAdminRoleId +} + +function Test-EntraDirectoryPropertyPresent { + [CmdletBinding()] + [OutputType([bool])] + param( + [AllowNull()] $InputObject, + [Parameter(Mandatory)] [string] $Name + ) + + if ($null -eq $InputObject) { return $false } + if ($InputObject -is [System.Collections.IDictionary]) { + return $InputObject.Contains($Name) + } + return $null -ne $InputObject.PSObject.Properties[$Name] +} + +function Get-EntraGlobalAdminPrincipalId { + <# + Principals (users, or role-assignable groups) holding a permanently + assigned Global Administrator role. Read once and reused, because every + break-glass principal is checked against the same list. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory)] [string] $BaseUri, + [string] $RoleId = $script:EntraGlobalAdminRoleId + ) + + $assignments = Get-EntraGraphCollection -BaseUri $BaseUri ` + -Uri "$BaseUri/roleManagement/directory/roleAssignmentScheduleInstances?`$filter=roleDefinitionId eq '$RoleId'&`$select=principalId,assignmentType,endDateTime,directoryScopeId" ` + -Description 'Read Global Administrator assignments' + return @(@($assignments) | ForEach-Object { + $principalId = [string] (Get-EntraPolicyProperty -InputObject $_ -Name 'principalId') + $assignmentType = [string] (Get-EntraPolicyProperty -InputObject $_ -Name 'assignmentType') + $endDateTime = Get-EntraPolicyProperty -InputObject $_ -Name 'endDateTime' + $directoryScopeId = [string] (Get-EntraPolicyProperty -InputObject $_ -Name 'directoryScopeId') + if ($assignmentType -ceq 'Assigned' -and + (Test-EntraDirectoryPropertyPresent -InputObject $_ -Name 'endDateTime') -and + $null -eq $endDateTime -and + $directoryScopeId -eq '/' -and + -not [string]::IsNullOrWhiteSpace($principalId)) { + $principalId + } + }) +} + +function Test-EntraGraphContinuationUri { + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)] [string] $BaseUri, + [Parameter(Mandatory)] [string] $ContinuationUri + ) + + [uri] $parsedBaseUri = $null + [uri] $parsedContinuationUri = $null + if (-not [uri]::TryCreate($BaseUri, [UriKind]::Absolute, [ref] $parsedBaseUri) -or + -not [uri]::TryCreate($ContinuationUri, [UriKind]::Absolute, [ref] $parsedContinuationUri)) { + return $false + } + if ($parsedBaseUri.Scheme -ne [uri]::UriSchemeHttps -or + $parsedContinuationUri.Scheme -ne [uri]::UriSchemeHttps) { + return $false + } + if (-not [string]::IsNullOrEmpty($parsedBaseUri.UserInfo) -or + -not [string]::IsNullOrEmpty($parsedContinuationUri.UserInfo)) { + return $false + } + if ($parsedBaseUri.Port -ne $parsedContinuationUri.Port) { + return $false + } + if ($parsedBaseUri.Port -ne 443) { + return $false + } + if (-not $parsedBaseUri.IdnHost.Equals( + $parsedContinuationUri.IdnHost, + [StringComparison]::OrdinalIgnoreCase + )) { + return $false + } + + $basePath = $parsedBaseUri.AbsolutePath.TrimEnd('/') + if ([string]::IsNullOrEmpty($basePath)) { $basePath = '/' } + $continuationPath = $parsedContinuationUri.AbsolutePath.TrimEnd('/') + if ([string]::IsNullOrEmpty($continuationPath)) { $continuationPath = '/' } + if ($continuationPath.Equals($basePath, [StringComparison]::Ordinal)) { + return $true + } + if ($basePath -eq '/') { + # Every absolute URI path is at or below root. The authority checks + # above are therefore sufficient when the configured base path is root. + return $true + } + return $continuationPath.StartsWith("$basePath/", [StringComparison]::Ordinal) +} + +function Get-EntraGraphCollection { + <# + Follow every @odata.nextLink page of a Graph collection. + + Pagination matters for any safety check: a single unread page can hide + the one policy or the one enabled member that changes the verdict. A + continuation link is service-supplied input, so a link that leaves the + configured Graph host is refused rather than followed. + #> + [CmdletBinding()] + [OutputType([object[]])] + param( + [Parameter(Mandatory)] [string] $Uri, + [Parameter(Mandatory)] [string] $BaseUri, + [Parameter(Mandatory)] [string] $Description, + [int] $PageLimit = 200 + ) + + $items = [System.Collections.Generic.List[object]]::new() + $nextUri = $Uri + $pageGuard = 0 + + while (-not [string]::IsNullOrWhiteSpace($nextUri)) { + if (++$pageGuard -gt $PageLimit) { + throw "$Description exceeded $PageLimit pages; refusing to continue rather than loop indefinitely." + } + $requestUri = $nextUri + $response = Invoke-WithTransientRetry -Description $Description -Action { + Invoke-MgGraphRequest -Method GET -Uri $requestUri + } + foreach ($item in @(Get-EntraPolicyProperty -InputObject $response -Name 'value')) { + if ($null -ne $item) { $items.Add($item) } + } + + $link = [string] (Get-EntraPolicyProperty -InputObject $response -Name '@odata.nextLink') + if ([string]::IsNullOrWhiteSpace($link)) { break } + if (-not (Test-EntraGraphContinuationUri -BaseUri $BaseUri -ContinuationUri $link)) { + throw "$Description returned an unsafe pagination link. Continuation links must use absolute HTTPS without user information, default port 443, the configured host, and the configured base path." + } + $nextUri = $link + } + + return , @($items) +} + +function Get-EntraGlobalAdminRecoveryState { + <# + .SYNOPSIS + Determines whether an emergency principal can recover the tenant. + + .DESCRIPTION + Uses one implementation for setup-time verification and post-deployment + health. A user is valid when Global Administrator is assigned directly + or through a role-assignable group. A group is valid when the role is + assigned to the group or to one of its enabled transitive user members. + + GlobalAdminPrincipalIds must come from + Get-EntraGlobalAdminPrincipalId, which reads permanent active role + assignments rather than PIM eligibility. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)] [string] $BaseUri, + [Parameter(Mandatory)] [string] $PrincipalId, + [Parameter(Mandatory)] [ValidateSet('User', 'Group')] [string] $PrincipalType, + [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]] $GlobalAdminPrincipalIds, + [AllowEmptyCollection()] [string[]] $EnabledMemberIds = @() + ) + + if ($PrincipalId -in $GlobalAdminPrincipalIds) { + $roleVia = if ($PrincipalType -eq 'User') { + 'directly' + } + else { + 'assigned to the group' + } + return [pscustomobject] @{ + HasGlobalAdmin = $true + RoleVia = $roleVia + } + } + + if ($PrincipalType -eq 'User' -and $GlobalAdminPrincipalIds.Count -gt 0) { + $encodedPrincipalId = [uri]::EscapeDataString($PrincipalId) + $memberships = Get-EntraGraphCollection -BaseUri $BaseUri ` + -Uri "$BaseUri/users/$encodedPrincipalId/transitiveMemberOf/microsoft.graph.group?`$select=id" ` + -Description 'Read break-glass group memberships' + $membershipIds = @(@($memberships) | ForEach-Object { + [string] (Get-EntraPolicyProperty -InputObject $_ -Name 'id') + } | Where-Object { $_ }) + if (@($membershipIds | Where-Object { + $_ -in $GlobalAdminPrincipalIds + }).Count -gt 0) { + return [pscustomobject] @{ + HasGlobalAdmin = $true + RoleVia = 'through a role-assignable group' + } + } + } + + if ($PrincipalType -eq 'Group' -and + @($EnabledMemberIds | Where-Object { + $_ -in $GlobalAdminPrincipalIds + }).Count -gt 0) { + return [pscustomobject] @{ + HasGlobalAdmin = $true + RoleVia = 'held by an enabled group member' + } + } + + return [pscustomobject] @{ + HasGlobalAdmin = $false + RoleVia = 'none' + } +} diff --git a/Products/Entra/Modules/EntraGraphClient.ps1 b/Products/Entra/Modules/EntraGraphClient.ps1 new file mode 100644 index 0000000..8e3cc11 --- /dev/null +++ b/Products/Entra/Modules/EntraGraphClient.ps1 @@ -0,0 +1,83 @@ +#requires -Version 7.0 + +. (Join-Path $PSScriptRoot 'EntraRunLog.ps1') +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') + +function Resolve-EntraGraphUri { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] [string] $BaseUri, + [Parameter(Mandatory)] [string] $RelativePath + ) + + $approvedHosts = @( + 'graph.microsoft.com', + 'graph.microsoft.us', + 'dod-graph.microsoft.us', + 'microsoftgraph.chinacloudapi.cn' + ) + + $parsed = $null + if (-not [uri]::TryCreate($BaseUri, [UriKind]::Absolute, [ref] $parsed)) { + throw "Api.GraphBaseUri is not an absolute URI: $BaseUri" + } + if ($parsed.Scheme -ne 'https') { + throw "Api.GraphBaseUri must use HTTPS: $BaseUri" + } + if ($parsed.Host -notin $approvedHosts) { + throw "Api.GraphBaseUri host '$($parsed.Host)' is not an approved Microsoft Graph endpoint." + } + + return ('{0}/{1}' -f $BaseUri.TrimEnd('/'), $RelativePath.TrimStart('/')) +} + +function Invoke-EntraGraphRequest { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [ValidateSet('GET')] + [string] $Method, + [Parameter(Mandatory)] [string] $Uri, + [string] $EvidenceTarget, + [int[]] $ExpectedStatusCodes = @(), + [switch] $DeferFailureEvidence + ) + + try { + $request = @{ + Method = $Method + Uri = $Uri + ErrorAction = 'Stop' + } + return Invoke-MgGraphRequest @request + } + catch { + $status = Get-EntraHttpStatusCode -ErrorRecord $_ + $target = if ([string]::IsNullOrWhiteSpace($EvidenceTarget)) { + $Uri + } + else { + $EvidenceTarget + } + $detail = if ([string]::IsNullOrWhiteSpace($EvidenceTarget)) { + $_.Exception.Message + } + else { + "Microsoft Graph request failed for $EvidenceTarget." + } + $safeException = [Exception]::new($detail) + if ($status) { + $safeException | Add-Member -NotePropertyName Response ` + -NotePropertyValue ([pscustomobject] @{ StatusCode = $status }) + } + if ($status -in $ExpectedStatusCodes) { + throw $safeException + } + if (-not $DeferFailureEvidence) { + Add-EntraRunLogEntry -Module 'Connect-EntraServices' ` + -Action 'Invoke-MgGraphRequest' -Status 'Failed' ` + -Target $target -HttpStatusCode $status -Detail $detail + } + throw $safeException + } +} diff --git a/Products/Entra/Modules/EntraPolicyComparison.ps1 b/Products/Entra/Modules/EntraPolicyComparison.ps1 new file mode 100644 index 0000000..67ca9d0 --- /dev/null +++ b/Products/Entra/Modules/EntraPolicyComparison.ps1 @@ -0,0 +1,227 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Shared Conditional Access policy comparison helpers. + +.DESCRIPTION + Used by both the Conditional Access baseline, which decides whether an + existing policy already carries the required protections, and the deployment + health check, which reports drift after a deployment. + + These live in one place on purpose. The same comparison written twice drifts, + and for a break-glass exclusion check a drifted copy could report a tenant as + safe when it is one policy promotion away from locking every administrator + out. + + Read-only. Nothing here issues a Graph request or changes tenant state. +#> + +function Get-EntraPolicyProperty { + <# + Safe traversal for Graph responses, which arrive as hashtables from + Invoke-MgGraphRequest but as PSCustomObject from a deserialized fixture. + Returns $null for a missing node instead of throwing under StrictMode, + so a policy that omits an optional branch is treated as "not present" + rather than failing the whole health check. + #> + [CmdletBinding()] + param( + [AllowNull()] $InputObject, + [Parameter(Mandatory)] [string] $Name + ) + + if ($null -eq $InputObject) { return $null } + if ($InputObject -is [System.Collections.IDictionary]) { + if ($InputObject.Contains($Name)) { return $InputObject[$Name] } + return $null + } + $property = $InputObject.PSObject.Properties[$Name] + if ($null -eq $property) { return $null } + return $property.Value +} + +function Test-EntraManagedPropertiesMatch { + param([AllowNull()] $Expected, [AllowNull()] $Actual) + + if ($null -eq $Expected) { return $null -eq $Actual } + if ($null -eq $Actual) { return $false } + if ($Expected -is [System.Collections.IDictionary]) { + foreach ($key in $Expected.Keys) { + if (-not (Test-EntraManagedPropertiesMatch -Expected $Expected[$key] ` + -Actual (Get-EntraPolicyProperty $Actual $key))) { return $false } + } + return $true + } + if ($Expected -is [bool]) { + return $Actual -is [bool] -and $Expected -eq $Actual + } + if ($Expected -is [string]) { + return $Actual -is [string] -and $Expected -ceq $Actual + } + return ($Expected | ConvertTo-Json -Compress) -ceq ($Actual | ConvertTo-Json -Compress) +} + +function Get-EntraNormalizedSet { + <# + Order-independent, duplicate-free, string-typed view of a Graph + collection so two lists can be compared for equality. + #> + [CmdletBinding()] + [OutputType([string[]])] + param([AllowNull()] $Value) + return @(@($Value) | Where-Object { $_ } | ForEach-Object { [string] $_ } | Sort-Object -Unique) +} + +function Get-EntraPolicyDisplayNames { + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory)] [string] $DisplayName, + [Parameter(Mandatory)] [hashtable] $PolicyReference, + [hashtable] $ConditionalAccess = @{} + ) + $names = @( + Get-EntraPolicyDisplayName -DisplayName $DisplayName -PolicyReference $PolicyReference -ConditionalAccess $ConditionalAccess + $DisplayName + foreach ($prefix in @(Get-EntraPolicyProperty $ConditionalAccess 'PreviousDisplayNamePrefixes')) { + if (-not [string]::IsNullOrWhiteSpace([string] $prefix)) { + Get-EntraPolicyDisplayName -DisplayName $DisplayName -PolicyReference $PolicyReference ` + -ConditionalAccess @{ DisplayNamePrefix = $prefix } + } + } + Get-EntraPolicyProperty -InputObject $PolicyReference -Name 'LegacyDisplayNames' + ) + $uniqueNames = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + return @($names | Where-Object { + -not [string]::IsNullOrWhiteSpace($_) -and $uniqueNames.Add([string] $_) + }) +} + +function Get-EntraPolicyDisplayName { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] [string] $DisplayName, + [Parameter(Mandatory)] [hashtable] $PolicyReference, + [hashtable] $ConditionalAccess = @{} + ) + $prefix = [string] (Get-EntraPolicyProperty $ConditionalAccess 'DisplayNamePrefix') + if ([string]::IsNullOrWhiteSpace($prefix)) { return $DisplayName } + $code = [string] (Get-EntraPolicyProperty $PolicyReference 'NameCode') + if ([string]::IsNullOrWhiteSpace($code)) { + throw "Policy '$($PolicyReference.Key)' requires NameCode when DisplayNamePrefix is configured." + } + return "$prefix$code-$DisplayName" +} + +function Test-EntraPolicySelected { + [CmdletBinding()] + [OutputType([bool])] + param([Parameter(Mandatory)] [hashtable] $PolicyReference) + if (-not $PolicyReference.ContainsKey('Enabled')) { return $true } + if ($PolicyReference.Enabled -isnot [bool]) { + throw "Policy '$($PolicyReference.Key)' Enabled must be a Boolean." + } + return $PolicyReference.Enabled +} + +function Get-EntraPolicyTier { + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)] [hashtable] $PolicyReference) + $tier = Get-EntraPolicyProperty $PolicyReference 'Tier' + if ($null -eq $tier) { return 'P1Baseline' } + if ($tier -notin @('P1Baseline', 'P1Hardened')) { + throw "Policy '$($PolicyReference.Key)' has an unsupported tier. P2 risk policies are not deployed by this toolkit." + } + return $tier +} + +function Assert-EntraPolicyMigrationConfig { + [CmdletBinding()] + param([Parameter(Mandatory)] [hashtable] $ConditionalAccess) + foreach ($reference in @($ConditionalAccess.Policies)) { + $null = Test-EntraPolicySelected -PolicyReference $reference + $null = Get-EntraPolicyTier -PolicyReference $reference + if ($reference.Key -eq 'require-phishing-resistant-mfa-admins' -and + -not $reference.ContainsKey('Enabled')) { + throw 'The hardened admin policy requires an explicit Boolean Enabled opt-in.' + } + if ($reference.Key -in @( + 'block-device-code-flow', 'protect-security-info-registration', 'require-phishing-resistant-mfa-admins', + 'no-persistent-browser-session', 'require-mfa-admins', 'require-mfa-admin-portals', + 'require-mfa-azure-management', 'require-compliant-device-or-mfa', 'require-mfa-guests')) { + $reviewOnly = Get-EntraPolicyProperty $reference 'ReviewExistingOnly' + if ($reviewOnly -isnot [bool] -or -not $reviewOnly) { + throw "Policy '$($reference.Key)' requires ReviewExistingOnly=true. Existing targeting, grant, session, authentication-flow, registration and strength changes must be reviewed rather than automatically migrated." + } + } + if ($reference.Key -ne 'require-approved-client-apps') { continue } + $reviewOnly = Get-EntraPolicyProperty $reference 'ReviewExistingOnly' + $legacy = @(Get-EntraPolicyProperty $reference 'LegacyDisplayNames') + if ($reviewOnly -isnot [bool] -or -not $reviewOnly -or + $legacy.Count -eq 0 -or @($legacy | Where-Object { + $_ -isnot [string] -or [string]::IsNullOrWhiteSpace($_) + }).Count -gt 0) { + throw 'The app-protection policy reference requires ReviewExistingOnly=true and nonempty LegacyDisplayNames. Update the supplied configuration using the current EntraConfig.psd1 before running.' + } + } +} + +function Get-EntraBreakGlassExclusionState { + <# + .SYNOPSIS + Report whether a policy excludes every configured break-glass principal. + + .DESCRIPTION + This is the lockout control. A Conditional Access policy that enforces + without excluding the emergency-access principals can lock every + administrator out of the tenant, and the only safe reading of a policy + whose exclusions cannot be read is that the exclusion is missing. + + Returns counts rather than identifiers so a caller can log the finding + without writing directory object IDs into evidence. + + .OUTPUTS + PSCustomObject with Excluded, MissingUserCount, and MissingGroupCount. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [AllowNull()] $Policy, + [string[]] $BreakGlassUsers = @(), + [string[]] $BreakGlassGroups = @() + ) + + $conditions = Get-EntraPolicyProperty -InputObject $Policy -Name 'conditions' + $users = Get-EntraPolicyProperty -InputObject $conditions -Name 'users' + + $excludedUsers = Get-EntraNormalizedSet (Get-EntraPolicyProperty -InputObject $users -Name 'excludeUsers') + $excludedGroups = Get-EntraNormalizedSet (Get-EntraPolicyProperty -InputObject $users -Name 'excludeGroups') + + $missingUsers = @(@($BreakGlassUsers) | Where-Object { $_ -and ($_ -notin $excludedUsers) }) + $missingGroups = @(@($BreakGlassGroups) | Where-Object { $_ -and ($_ -notin $excludedGroups) }) + + return [pscustomobject] @{ + Excluded = (($missingUsers.Count -eq 0) -and ($missingGroups.Count -eq 0)) + MissingUserCount = $missingUsers.Count + MissingGroupCount = $missingGroups.Count + } +} + +function Test-EntraBreakGlassExcluded { + <# + Boolean form of Get-EntraBreakGlassExclusionState, for callers that only + need a pass or fail. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [AllowNull()] $Policy, + [string[]] $BreakGlassUsers = @(), + [string[]] $BreakGlassGroups = @() + ) + + return (Get-EntraBreakGlassExclusionState -Policy $Policy ` + -BreakGlassUsers $BreakGlassUsers -BreakGlassGroups $BreakGlassGroups).Excluded +} diff --git a/Products/Entra/Modules/EntraRunLog.ps1 b/Products/Entra/Modules/EntraRunLog.ps1 new file mode 100644 index 0000000..e229103 --- /dev/null +++ b/Products/Entra/Modules/EntraRunLog.ps1 @@ -0,0 +1,369 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Structured console and local JSON logging for Entra deployments. + +.DESCRIPTION + Mirrors the Purview and Defender run-log contract while adding Entra + best-practice keys and HTTP status evidence. JSON persistence failures are + surfaced through warnings but do not interrupt the deployment. + + The status and disposition vocabulary is a compatibility contract for + the product's structured evidence. Preserve existing values when + changing the logger or report renderer. + + Persistence is deliberately durability-first: every entry rewrites the whole + JSON sidecar, so a run killed at any point still leaves a complete and valid + evidence file. The cost is that serialisation work grows quadratically with + entry count. That is an accepted trade for the current baseline, which + produces on the order of one hundred entries per run. + + If a run is ever expected to exceed roughly one thousand entries, revisit + the model rather than the constant: append events to a JSON Lines file for + durability and build the sidecar once at completion. Measure first, because + the current behaviour is the reason a crashed run is still diagnosable. +#> + +function Initialize-EntraRunLog { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $JsonPath, + [guid] $RunId = [guid]::NewGuid(), + [datetime] $StartTime = [datetime]::UtcNow, + [string] $ScriptVersion = 'unknown', + [string] $TenantId, + [string] $TenantAdminUpn + ) + + $directory = Split-Path -Parent $JsonPath + if ($directory -and -not (Test-Path -LiteralPath $directory)) { + # Evidence output is local, not a tenant change. It must be written even + # during -WhatIf, otherwise a dry run produces no report at all, which + # is precisely when the operator most needs one. + New-Item -ItemType Directory -Path $directory -Force -WhatIf:$false | Out-Null + } + + $global:EntraRunLog = [System.Collections.Generic.List[hashtable]]::new() + $global:EntraRunLogPath = $JsonPath + if ($TenantAdminUpn -match '@(?[^@\s]+)$') { + Register-EntraSensitiveValue -Value $Matches.domain + } + $global:EntraRunMetadata = @{ + RunId = $RunId + StartTime = $StartTime + ScriptVersion = $ScriptVersion + TenantId = Protect-EntraTenantId -Value $TenantId + TenantAdminUpn = Protect-EntraIdentity -Value $TenantAdminUpn + } + Save-EntraRunLogJson +} + +function Set-EntraRunTenantId { + [CmdletBinding()] + param([Parameter(Mandatory)] [string] $TenantId) + + if (-not $global:EntraRunMetadata) { + throw 'Entra run log metadata is not initialized.' + } + Register-EntraSensitiveValue -Value $TenantId + $global:EntraRunMetadata.TenantId = Protect-EntraTenantId -Value $TenantId + Save-EntraRunLogJson +} + +function Save-EntraRunLogJson { + [CmdletBinding()] + param( + [string] $Path = $global:EntraRunLogPath, + [datetime] $EndTime = [datetime]::UtcNow, + [switch] $PassThru + ) + + try { + if (-not $Path) { return } + $entries = if ($global:EntraRunLog) { @($global:EntraRunLog) } else { @() } + $metadata = if ($global:EntraRunMetadata) { $global:EntraRunMetadata } else { @{} } + # Per-module verdicts, so the JSON sidecar carries the same summary the + # HTML report shows and a downstream consumer does not have to + # re-derive it from the raw entries. + $moduleSummary = @($entries | Group-Object Module | Sort-Object Name | ForEach-Object { + $items = @($_.Group) + [ordered]@{ + module = $_.Name + result = Get-EntraModuleVerdict -Entries $items + entryCount = $items.Count + } + }) + $payload = [ordered]@{ + runId = if ($metadata.RunId) { ([guid] $metadata.RunId).ToString() } else { $null } + scriptVersion = $metadata.ScriptVersion + startTime = if ($metadata.StartTime) { ([datetime] $metadata.StartTime).ToString('o') } else { $null } + endTime = $EndTime.ToString('o') + durationSec = if ($metadata.StartTime) { [int] (($EndTime - [datetime] $metadata.StartTime).TotalSeconds) } else { $null } + tenantId = $metadata.TenantId + tenantAdmin = $metadata.TenantAdminUpn + entryCount = $entries.Count + moduleSummary = $moduleSummary + entries = @($entries | ForEach-Object { + [ordered]@{ + timestamp = $_.Timestamp + module = $_.Module + action = $_.Action + bestPracticeKey = $_.BestPracticeKey + disposition = $_.Disposition + target = $_.Target + status = $_.Status + detail = $_.Detail + httpStatusCode = $_.HttpStatusCode + attempt = $_.Attempt + elapsedMs = $_.ElapsedMs + readback = $_.Readback + } + }) + } + # Evidence output is local, not a tenant change; never suppress it under + # -WhatIf. + $payload | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $Path -Encoding utf8 -WhatIf:$false -ErrorAction Stop + if ($PassThru) { (Resolve-Path -LiteralPath $Path -ErrorAction Stop).ProviderPath } + } + catch { + Write-Warning "JSON run log could not be written: $($_.Exception.Message)" -WarningAction Continue + } +} + +function Add-EntraRunLogEntry { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Module, + [Parameter(Mandatory)] [string] $Action, + [ValidateSet('Started','Succeeded','Created','Updated','Adopted','Skipped','Failed','Retried','Info')] + [string] $Status = 'Info', + [string] $BestPracticeKey, + [ValidateSet('Applicable','AlreadyCompliant','WillChange','GuidedOnly','Skipped','Blocked')] + [string] $Disposition, + [string] $Target, + [string] $Detail, + [int] $HttpStatusCode, + [int] $Attempt, + [int] $ElapsedMs, + [ValidateSet('Verified','Mismatch','NotAttempted')] + [string] $Readback + ) + + try { + if (-not $global:EntraRunLog) { + $global:EntraRunLog = [System.Collections.Generic.List[hashtable]]::new() + } + + $entry = [ordered]@{ + Timestamp = [datetime]::UtcNow.ToString('o') + Module = $Module + Action = $Action + Status = $Status + BestPracticeKey = $BestPracticeKey + Disposition = $Disposition + Target = Protect-EntraLogText -Value $Target + Detail = Protect-EntraLogText -Value $Detail + HttpStatusCode = if ($PSBoundParameters.ContainsKey('HttpStatusCode')) { $HttpStatusCode } else { $null } + Attempt = if ($PSBoundParameters.ContainsKey('Attempt')) { $Attempt } else { $null } + ElapsedMs = if ($PSBoundParameters.ContainsKey('ElapsedMs')) { $ElapsedMs } else { $null } + Readback = if ($PSBoundParameters.ContainsKey('Readback')) { $Readback } else { $null } + } + + $global:EntraRunLog.Add($entry) + Write-Verbose ("[{0}] {1}/{2}: {3}" -f $Status, $Module, $Action, $entry.Detail) + $color = 'Cyan' + $label = $Status + if ($Disposition) { $label += "/$Disposition" } + if ($Status -eq 'Failed') { $color = 'Red' } + elseif ($Disposition -eq 'Blocked' -or $Status -eq 'Retried' -or $entry.Detail -match '^WARNING:') { + $color = 'Yellow' + } + elseif ($Readback -eq 'Verified') { + $color = 'Green' + $label += '/VALIDATED' + } + elseif ($Disposition -eq 'GuidedOnly' -or $Readback -eq 'Mismatch') { $color = 'Yellow' } + elseif ($Disposition -eq 'AlreadyCompliant' -or $Status -in @('Succeeded', 'Created', 'Updated', 'Adopted')) { + $color = 'Green' + } + elseif ($Status -eq 'Skipped' -or $Disposition -eq 'Skipped') { $color = 'DarkGray' } + + if ($Action -eq 'Stage') { + Write-Host ("`n=== {0} ===" -f $entry.Detail) -ForegroundColor White + } + else { + Write-Host ("[{0}] {1}: {2}" -f $label, $Action, $entry.Detail) -ForegroundColor $color + } + Save-EntraRunLogJson + } + catch { + Write-Verbose "Add-EntraRunLogEntry failed: $($_.Exception.Message)" + } +} + +function Protect-EntraLogText { + [CmdletBinding()] + param([AllowNull()] [object] $Value) + + if ($null -eq $Value) { return $null } + $text = [string] $Value + $text = $text -replace '(?i)(authorization\s*:\s*bearer\s+)[^\s,;]+', '$1[REDACTED]' + # Key=value and key: value forms. + $text = $text -replace '(?i)(access[_-]?token|refresh[_-]?token|id[_-]?token|client[_-]?secret|password|api[_-]?key)\s*[=:]\s*[^&\s,;]+', '$1=[REDACTED]' + # JSON forms, where the separator sits between quotes and the plain + # key=value pattern above does not match. + $text = $text -replace '(?i)("(?:access|refresh|id)[_-]?token"|"client[_-]?secret"|"password"|"api[_-]?key")\s*:\s*"[^"]*"', '$1:"[REDACTED]"' + # Object and tenant identifiers are useful for correlation, but the complete + # GUID is tenant evidence. Preserve only the first segment. + $text = [regex]::Replace( + $text, + '(?i)\b([0-9a-f]{8})-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b', + '$1-****-****-****-************' + ) + # Mask both the local part and tenant domain in UPNs and email addresses. + $text = [regex]::Replace( + $text, + '(?i)\b([A-Z0-9])[A-Z0-9._%+-]*@[A-Z0-9.-]+\.[A-Z]{2,}\b', + '$1***@[REDACTED]' + ) + # Tenant-scoped hostnames name the customer directly. Entra and Graph + # failures quote them in plain text, and that exception reaches evidence + # verbatim, so the domain has to be removed here rather than at the caller. + $text = [regex]::Replace( + $text, + '(?i)\b[A-Z0-9][A-Z0-9-]*\.(?:onmicrosoft\.com|sharepoint\.com)\b', + '[REDACTED]' + ) + # Domains resolved for this run, which covers a verified vanity domain that + # no fixed pattern can predict. Microsoft service endpoints are deliberately + # not registered, because they are diagnostic rather than tenant evidence. + $registered = Get-Variable -Name 'EntraSensitiveValues' -Scope Global -ErrorAction SilentlyContinue + if ($registered -and $registered.Value) { + foreach ($sensitive in @($registered.Value)) { + if ([string]::IsNullOrWhiteSpace($sensitive)) { continue } + $text = [regex]::Replace($text, [regex]::Escape($sensitive), '[REDACTED]', 'IgnoreCase') + } + } + return $text +} + +function Register-EntraSensitiveValue { + <# + Records a tenant-identifying value so later evidence can remove it. + Values shorter than five characters are ignored, because redacting a + very short string would corrupt unrelated text. + #> + [CmdletBinding()] + param([AllowNull()] [string[]] $Value) + + if (-not (Get-Variable -Name 'EntraSensitiveValues' -Scope Global -ErrorAction SilentlyContinue)) { + $global:EntraSensitiveValues = [System.Collections.Generic.List[string]]::new() + } + + foreach ($item in @($Value)) { + if ([string]::IsNullOrWhiteSpace($item)) { continue } + $normalized = $item.Trim() + if ($normalized.Length -lt 5) { continue } + if ($global:EntraSensitiveValues -notcontains $normalized) { + $global:EntraSensitiveValues.Add($normalized) + } + } +} + +function Protect-EntraIdentity { + [CmdletBinding()] + param([AllowNull()] [string] $Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { return $null } + if ($Value -match '^([^@\s])[^@\s]*@[^@\s]+$') { + return "$($Matches[1])***@[REDACTED]" + } + return '[REDACTED]' +} + +function Protect-EntraTenantId { + [CmdletBinding()] + param([AllowNull()] [string] $Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { return $null } + if ($Value -match '^(?[0-9a-fA-F]{8})-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { + return "$($Matches.prefix)-****-****-****-************" + } + return '[REDACTED]' +} + +function Get-EntraRunLog { + [CmdletBinding()] + [OutputType([hashtable[]])] + param() + + if (-not (Test-EntraRunLogInitialized)) { return @() } + return ,@($global:EntraRunLog.ToArray()) +} + +function Get-EntraEntryField { + <# + Reads a field from a run-log entry regardless of whether it arrives as a + hashtable or an object, and returns null for an absent field rather than + throwing under Set-StrictMode. + #> + [CmdletBinding()] + param( + [AllowNull()] [object] $Entry, + [Parameter(Mandatory)] [string] $Name + ) + + if ($null -eq $Entry) { return $null } + if ($Entry -is [System.Collections.IDictionary]) { + if ($Entry.Contains($Name)) { return $Entry[$Name] } + return $null + } + + $property = $Entry.PSObject.Properties[$Name] + if ($property) { return $property.Value } + return $null +} + +function Get-EntraModuleVerdict { + <# + Single source of truth for the per-module verdict shown in both the JSON + sidecar and the HTML report. Precedence is FAILED, then BLOCKED, then + SKIPPED, then OK. Deriving it twice let the two reports disagree after a + status or disposition change, which would give an operator contradictory + evidence for the same run. + #> + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Entries) + + # Derived summaries must not change the module results they describe. + $items = @($Entries | Where-Object { (Get-EntraEntryField -Entry $_ -Name 'Action') -ne 'ModuleSummary' }) + if (@($items | Where-Object { (Get-EntraEntryField -Entry $_ -Name 'Status') -eq 'Failed' }).Count -gt 0) { return 'FAILED' } + if (@($items | Where-Object { (Get-EntraEntryField -Entry $_ -Name 'Disposition') -eq 'Blocked' }).Count -gt 0) { return 'BLOCKED' } + if (@($items | Where-Object { (Get-EntraEntryField -Entry $_ -Name 'Status') -eq 'Skipped' }).Count -gt 0) { return 'SKIPPED' } + return 'OK' +} + +function Test-EntraRunLogInitialized { <# + Strict-mode safe check for run-log availability. A configuration failure + can terminate before Initialize-EntraRunLog runs, and reading an unset + global under Set-StrictMode throws an error that masks the real reason + the run stopped. + #> + [CmdletBinding()] + [OutputType([bool])] + param() + + $variable = Get-Variable -Name 'EntraRunLog' -Scope Global -ErrorAction SilentlyContinue + return [bool] ($variable -and $variable.Value) +} + +function Clear-EntraRunLog { + [CmdletBinding()] + param() + + Remove-Variable -Name 'EntraRunLog' -Scope Global -ErrorAction SilentlyContinue -WhatIf:$false + Remove-Variable -Name 'EntraRunLogPath' -Scope Global -ErrorAction SilentlyContinue -WhatIf:$false + Remove-Variable -Name 'EntraRunMetadata' -Scope Global -ErrorAction SilentlyContinue -WhatIf:$false + Remove-Variable -Name 'EntraSensitiveValues' -Scope Global -ErrorAction SilentlyContinue -WhatIf:$false +} diff --git a/Products/Entra/Modules/EntraSecurityDefaults.ps1 b/Products/Entra/Modules/EntraSecurityDefaults.ps1 new file mode 100644 index 0000000..54ffc66 --- /dev/null +++ b/Products/Entra/Modules/EntraSecurityDefaults.ps1 @@ -0,0 +1,61 @@ +#requires -Version 7.0 + +. (Join-Path $PSScriptRoot 'EntraGraphClient.ps1') +. (Join-Path $PSScriptRoot 'EntraPolicyComparison.ps1') +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') + +function Get-EntraSecurityDefaultsState { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] [string] $BaseUri, + [Parameter(Mandatory)] [string] $Module + ) + + try { + $uri = Resolve-EntraGraphUri -BaseUri $BaseUri ` + -RelativePath 'policies/identitySecurityDefaultsEnforcementPolicy' + $policy = Invoke-WithTransientRetry -Description 'Read Security Defaults state' -Action { + Invoke-EntraGraphRequest -Method GET -Uri $uri ` + -EvidenceTarget 'Security Defaults' -DeferFailureEvidence + } + $enabled = Get-EntraPolicyProperty -InputObject $policy -Name 'isEnabled' + if ($enabled -isnot [bool]) { + throw 'Security Defaults response must contain a Boolean isEnabled.' + } + $state = if ($enabled) { 'Enabled' } else { 'Disabled' } + Add-EntraRunLogEntry -Module $Module -Action 'SecurityDefaults' ` + -Status 'Info' -Disposition $(if ($enabled) { 'Blocked' } else { 'Applicable' }) ` + -Readback 'Verified' -Detail ( + "SecurityDefaultsState=$state; TenantWritesAllowed=$(-not $enabled). " + + 'Security Defaults is never changed by this toolkit. Disabled does not prove replacement protection.' + ) + return $state + } + catch { + Add-EntraRunLogEntry -Module $Module -Action 'SecurityDefaults' ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'NotAttempted' ` + -HttpStatusCode (Get-EntraHttpStatusCode -ErrorRecord $_) ` + -Detail 'SecurityDefaultsState=Unknown; TenantWritesAllowed=False. The read failed or isEnabled was not Boolean. Verify Policy.Read.All, effective access and the service response; assessment may continue, but no tenant write is authorized.' + return 'Unknown' + } +} + +function Test-EntraSecurityDefaultsWriteAllowed { + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)] [string] $BaseUri, + [Parameter(Mandatory)] [string] $Module, + [Parameter(Mandatory)] [string] $BestPracticeKey + ) + + # Re-read at the write boundary, including standalone modules. A caller's + # cached context or a successful earlier read cannot authorize a later write. + $state = Get-EntraSecurityDefaultsState -BaseUri $BaseUri -Module $Module + if ($state -eq 'Disabled') { return $true } + Add-EntraRunLogEntry -Module $Module -Action 'SecurityDefaultsWriteGate' ` + -BestPracticeKey $BestPracticeKey -Status 'Skipped' -Disposition 'Blocked' ` + -Detail "Tenant write withheld because Security Defaults is $state. Keep existing protection enabled until an approved transition has active replacement coverage; report-only policies are not a replacement. See the Security Defaults transition guide." + return $false +} diff --git a/Products/Entra/Modules/Get-EntraDeploymentHealth.ps1 b/Products/Entra/Modules/Get-EntraDeploymentHealth.ps1 new file mode 100644 index 0000000..57c5ae4 --- /dev/null +++ b/Products/Entra/Modules/Get-EntraDeploymentHealth.ps1 @@ -0,0 +1,455 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Read-only post-deployment health check for the Entra Conditional Access + baseline. + +.DESCRIPTION + Answers one question an operator cannot answer by re-running the deployment: + has anything drifted since the baseline was deployed, in a way that could + lock administrators out of the tenant? + + The check that matters most is break-glass exclusion drift. Every policy the + toolkit creates excludes the emergency-access principals. If someone later + edits a policy and drops that exclusion, nothing breaks while the policy is + report-only, and then the tenant locks every administrator out the moment + that policy is promoted to enforcing. A report-only policy missing its + exclusion is a warning; an enforcing policy missing its exclusion is an + active lockout risk. + + Read-only by construction. It issues GET requests only, changes no tenant + state, and is therefore safe to run on a schedule. It reports findings + rather than throwing on them, because an operator needs the whole picture, + not the first problem. It does throw when it cannot read the policy list at + all, since a partial answer about lockout risk is worse than no answer. + + Break-glass ROLE verification (permanently assigned Global Administrator) + runs both before deployment and again here, so losing the role after + deployment cannot leave an enabled but unusable emergency account looking + healthy. Both modules use the same shared recovery check. + + Uses only Graph scopes the product already requests. It adds no permission. +#> + +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +param( + [Parameter(Mandatory)] [hashtable] $Config, + [hashtable] $Context +) + +$ErrorActionPreference = 'Stop' + +. (Join-Path $PSScriptRoot 'EntraRunLog.ps1') +. (Join-Path $PSScriptRoot 'EntraGraphClient.ps1') +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') +. (Join-Path $PSScriptRoot 'EntraPolicyComparison.ps1') +. (Join-Path $PSScriptRoot 'EntraDirectoryReads.ps1') +. (Join-Path $PSScriptRoot 'EntraSecurityDefaults.ps1') + +$module = 'Get-EntraDeploymentHealth' +$bestPracticeKey = 'deployment-health' +$ca = $Config.ConditionalAccess +Assert-EntraPolicyMigrationConfig -ConditionalAccess $ca + +$v1 = $Config.Api.GraphBaseUri +$securityDefaultsState = Get-EntraSecurityDefaultsState -BaseUri $v1 -Module $module +$breakGlassUsers = @(@($Context.BreakGlassUserIds) | Where-Object { $_ }) +$breakGlassGroups = @(@($Context.BreakGlassGroupIds) | Where-Object { $_ }) + +Add-EntraRunLogEntry -Module $module -Action 'HealthCheck' -BestPracticeKey $bestPracticeKey ` + -Status 'Started' -Detail 'Read-only deployment health check started. No tenant state is changed.' + +# Fail closed on the read. Reporting "no lockout risk found" after a failed or +# partial enumeration would be actively misleading, so stop instead. Every page +# is followed: a tenant with more policies than fit in one response could +# otherwise hide an enforcing policy with a missing exclusion on page two. +try { + $allPolicies = Get-EntraGraphCollection -BaseUri $v1 ` + -Uri "$v1/identity/conditionalAccess/policies" ` + -Description 'Read Conditional Access policies for health check' +} +catch { + Add-EntraRunLogEntry -Module $module -Action 'ReadPolicies' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' ` + -HttpStatusCode (Get-EntraHttpStatusCode -ErrorRecord $_) ` + -Detail "Could not enumerate Conditional Access policies, so no health verdict can be given: $($_.Exception.Message)" + throw +} + +# Index by display name, keeping every match so a duplicate name is visible +# rather than silently collapsing to one policy. +$byName = @{} +foreach ($p in $allPolicies) { + $name = [string] (Get-EntraPolicyProperty -InputObject $p -Name 'displayName') + if ([string]::IsNullOrWhiteSpace($name)) { continue } + if (-not $byName.ContainsKey($name)) { $byName[$name] = [System.Collections.Generic.List[object]]::new() } + $byName[$name].Add($p) +} + +$templateDir = Join-Path (Split-Path -Parent $PSScriptRoot) $ca.PolicyTemplateDirectory + +$presentCount = 0 +$missingCount = 0 +$duplicateCount = 0 +$legacyPolicyCount = 0 +$enforcingCount = 0 +$disabledCount = 0 +$unknownStateCount = 0 +$exclusionGapCount = 0 +$lockoutRiskCount = 0 +$unevaluableTemplateCount = 0 +# Managed display names seen, so unmanaged policies can be distinguished below. +$managedDisplayNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + +$canEvaluateExclusions = ($breakGlassUsers.Count + $breakGlassGroups.Count) -gt 0 +if (-not $canEvaluateExclusions) { + Add-EntraRunLogEntry -Module $module -Action 'BreakGlassExclusion' -BestPracticeKey $bestPracticeKey ` + -Status 'Skipped' -Disposition 'Blocked' ` + -Detail 'No break-glass principals are configured or resolvable, so exclusion drift cannot be evaluated. Configure ConditionalAccess.BreakGlass, or run Setup-EmergencyAccess first, then rerun this check.' +} + +$selectedPolicies = @($ca.Policies | Where-Object { Test-EntraPolicySelected -PolicyReference $_ }) +foreach ($policyRef in $selectedPolicies) { + $templatePath = Join-Path $templateDir $policyRef.File + if (-not (Test-Path -LiteralPath $templatePath -PathType Leaf)) { + # Cannot resolve the managed policy name, so this policy's health is + # unknown. Count it, so the run cannot report Healthy while silently + # having skipped one of the policies it was supposed to check. + $unevaluableTemplateCount++ + Add-EntraRunLogEntry -Module $module -Action 'PolicyHealth' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'NotAttempted' -Target $policyRef.Key ` + -Detail "Policy template not found, so this policy could not be evaluated: $($policyRef.File)" + continue + } + + $template = Get-Content -Raw -LiteralPath $templatePath | ConvertFrom-Json -AsHashtable + $templateName = [string] $template.displayName + $displayName = Get-EntraPolicyDisplayName -DisplayName $templateName -PolicyReference $policyRef -ConditionalAccess $ca + $candidateNames = Get-EntraPolicyDisplayNames -DisplayName $templateName -PolicyReference $policyRef -ConditionalAccess $ca + foreach ($name in $candidateNames) { $null = $managedDisplayNames.Add($name) } + # Deliberately not named $matches; that is a PowerShell automatic variable + # and assigning it silently corrupts regex state for the rest of the scope. + $policyMatches = @() + foreach ($name in $candidateNames) { + if ($byName.ContainsKey($name)) { $policyMatches += @($byName[$name]) } + } + $migrationNames = @(Get-EntraPolicyProperty $policyRef 'LegacyDisplayNames') + if ($policyRef.Key -eq 'require-approved-client-apps' -and @($policyMatches | Where-Object { + (Get-EntraPolicyProperty $_ 'displayName') -in $migrationNames + }).Count -gt 0) { + $legacyPolicyCount++ + Add-EntraRunLogEntry -Module $module -Action 'LegacyPolicy' -BestPracticeKey $bestPracticeKey ` + -Status 'Info' -Disposition 'GuidedOnly' -Target $policyRef.Key ` + -Detail 'A legacy-named policy is present. Review the app-protection migration guide; presence is not proof that the mobile platform filters or grant controls are correct.' + } + + if ($policyMatches.Count -eq 0) { + $missingCount++ + Add-EntraRunLogEntry -Module $module -Action 'PolicyHealth' -BestPracticeKey $bestPracticeKey ` + -Status 'Info' -Disposition 'WillChange' -Readback 'Mismatch' -Target $policyRef.Key ` + -Detail 'Managed policy is not present in the tenant. Either the baseline has not been deployed, or the policy was removed after deployment.' + continue + } + + $presentCount++ + if ($policyMatches.Count -gt 1) { + $duplicateCount++ + Add-EntraRunLogEntry -Module $module -Action 'PolicyHealth' -BestPracticeKey $bestPracticeKey ` + -Status 'Info' -Disposition 'WillChange' -Target $policyRef.Key ` + -Detail "$($policyMatches.Count) policies share this managed display name. Reconcile the duplicates in the portal, because the toolkit cannot tell which one it owns." + } + + foreach ($policy in $policyMatches) { + $state = [string] (Get-EntraPolicyProperty -InputObject $policy -Name 'state') + $isEnforcing = $state -eq 'enabled' + $isDisabled = $state -eq 'disabled' + $isReportOnly = $state -eq 'enabledForReportingButNotEnforced' + # Anything outside the three documented states is not understood. Treat + # it as potentially enforcing rather than quietly describing it as + # report-only, because assuming the safe case is exactly how a real + # lockout ends up reported as healthy. + $isUnknownState = -not ($isEnforcing -or $isDisabled -or $isReportOnly) + if ($isEnforcing) { $enforcingCount++ } + if ($isDisabled) { $disabledCount++ } + if ($isUnknownState) { + $unknownStateCount++ + $shownState = if ([string]::IsNullOrWhiteSpace($state)) { '' } else { $state } + Add-EntraRunLogEntry -Module $module -Action 'PolicyHealth' -BestPracticeKey $bestPracticeKey ` + -Status 'Info' -Disposition 'Blocked' -Readback 'Mismatch' -Target $policyRef.Key ` + -Detail "Policy state '$shownState' is not one of the documented Conditional Access states. It is treated as potentially enforcing until reviewed in the portal." + } + + if (-not $canEvaluateExclusions) { + Add-EntraRunLogEntry -Module $module -Action 'PolicyHealth' -BestPracticeKey $bestPracticeKey ` + -Status 'Info' -Disposition 'GuidedOnly' -Readback 'NotAttempted' -Target $policyRef.Key ` + -Detail "Policy is present in state '$state'. Break-glass exclusion was not evaluated because no emergency-access principal is available." + continue + } + + $exclusion = Get-EntraBreakGlassExclusionState -Policy $policy ` + -BreakGlassUsers $breakGlassUsers -BreakGlassGroups $breakGlassGroups + + if ($exclusion.Excluded) { + Add-EntraRunLogEntry -Module $module -Action 'PolicyHealth' -BestPracticeKey $bestPracticeKey ` + -Status 'Succeeded' -Disposition 'AlreadyCompliant' -Readback 'Verified' -Target $policyRef.Key ` + -Detail "Policy is present in state '$state' and excludes every configured break-glass principal." + continue + } + + $exclusionGapCount++ + $missingDetail = "MissingBreakGlassUsers=$($exclusion.MissingUserCount); MissingBreakGlassGroups=$($exclusion.MissingGroupCount)" + + if ($isEnforcing -or $isUnknownState) { + # The dangerous combination: enforcing right now (or in a state we + # cannot rule out as enforcing), with no emergency way back in. + $lockoutRiskCount++ + $riskPrefix = if ($isEnforcing) { 'policy is enforcing' } else { "policy is in unrecognized state '$state'" } + Add-EntraRunLogEntry -Module $module -Action 'PolicyHealth' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $policyRef.Key ` + -Detail "LOCKOUT RISK: $riskPrefix and does not exclude every break-glass principal. $missingDetail. Restore the exclusion in the portal before relying on emergency access." + } + elseif ($isDisabled) { + Add-EntraRunLogEntry -Module $module -Action 'PolicyHealth' -BestPracticeKey $bestPracticeKey ` + -Status 'Info' -Disposition 'WillChange' -Readback 'Mismatch' -Target $policyRef.Key ` + -Detail "Policy is disabled and does not exclude every break-glass principal. $missingDetail. Restore the exclusion before re-enabling it." + } + else { + Add-EntraRunLogEntry -Module $module -Action 'PolicyHealth' -BestPracticeKey $bestPracticeKey ` + -Status 'Info' -Disposition 'WillChange' -Readback 'Mismatch' -Target $policyRef.Key ` + -Detail "Policy is report-only and does not exclude every break-glass principal. $missingDetail. This is not blocking access today, but promoting this policy to enforcing would risk locking administrators out." + } + } +} + +# An UNMANAGED enforcing policy that does not exclude the break-glass principals +# can lock the tenant out exactly as easily as a managed one. The toolkit does +# not own these policies and will not touch them, but a health check that only +# looked at its own policies would be answering the wrong question. +# +# The policy display name is customer data, so it is not written to evidence. +# The operator is pointed at the portal instead. +$unmanagedRiskCount = 0 +$enforcingInTenantCount = 0 +$unmanagedPoliciesEvaluated = $canEvaluateExclusions -and + $unevaluableTemplateCount -eq 0 +if ($canEvaluateExclusions -and -not $unmanagedPoliciesEvaluated) { + Add-EntraRunLogEntry -Module $module -Action 'UnmanagedPolicyHealth' -BestPracticeKey $bestPracticeKey ` + -Status 'Skipped' -Disposition 'Blocked' -Readback 'NotAttempted' ` + -Detail 'Unmanaged-policy classification was skipped because at least one managed policy template could not be resolved. Without the complete managed-name set, a toolkit policy could be mislabeled as customer-authored.' +} +foreach ($policy in $allPolicies) { + if ([string] (Get-EntraPolicyProperty -InputObject $policy -Name 'state') -ne 'enabled') { continue } + # Counted for every tenant, evaluated only when there is something to + # evaluate against. An operator needs to know how much of the tenant is + # enforcing even when emergency access cannot be checked. + $enforcingInTenantCount++ + + if (-not $unmanagedPoliciesEvaluated) { continue } + + $name = [string] (Get-EntraPolicyProperty -InputObject $policy -Name 'displayName') + if (-not [string]::IsNullOrWhiteSpace($name) -and $managedDisplayNames.Contains($name)) { continue } + + $unmanagedExclusion = Get-EntraBreakGlassExclusionState -Policy $policy ` + -BreakGlassUsers $breakGlassUsers -BreakGlassGroups $breakGlassGroups + if ($unmanagedExclusion.Excluded) { continue } + + $unmanagedRiskCount++ + Add-EntraRunLogEntry -Module $module -Action 'UnmanagedPolicyHealth' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' ` + -Detail "LOCKOUT RISK: an enforcing Conditional Access policy this toolkit does not manage does not exclude every break-glass principal. MissingBreakGlassUsers=$($unmanagedExclusion.MissingUserCount); MissingBreakGlassGroups=$($unmanagedExclusion.MissingGroupCount). Review enforcing policies in Entra admin center > Protection > Conditional Access and restore the emergency-access exclusion." +} + +# Say plainly when a tenant is enforcing access with no verifiable way back in. +# Reporting only "exclusions not evaluated" would understate this. +if (-not $canEvaluateExclusions -and $enforcingInTenantCount -gt 0) { + Add-EntraRunLogEntry -Module $module -Action 'UnmanagedPolicyHealth' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'NotAttempted' ` + -Detail "The tenant has $enforcingInTenantCount enforcing Conditional Access policy(ies) and no configured break-glass principal, so emergency access could not be verified against any of them. Configure ConditionalAccess.BreakGlass and rerun before relying on emergency access." +} + +# Break-glass drift. Setup-EmergencyAccess establishes recoverability before a +# deployment; this reports whether it still holds afterwards. An account that is +# enabled but has lost Global Administrator is not a recovery path, so the role +# is verified here too, using the same shared read the setup module uses. +$breakGlassDisabledCount = 0 +$breakGlassUnreadableCount = 0 +$breakGlassNoRoleCount = 0 +$gaPrincipalIds = $null + +foreach ($userId in $breakGlassUsers) { + try { + $account = Invoke-WithTransientRetry -Description 'Read break-glass account state' -Action { + Invoke-MgGraphRequest -Method GET -Uri "$v1/users/$userId`?`$select=id,accountEnabled" + } + } + catch { + $breakGlassUnreadableCount++ + Add-EntraRunLogEntry -Module $module -Action 'BreakGlassHealth' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $userId ` + -HttpStatusCode (Get-EntraHttpStatusCode -ErrorRecord $_) ` + -Detail "Configured break-glass account could not be read, so its availability for an emergency cannot be confirmed: $($_.Exception.Message)" + continue + } + + if (-not [bool] (Get-EntraPolicyProperty -InputObject $account -Name 'accountEnabled')) { + $breakGlassDisabledCount++ + Add-EntraRunLogEntry -Module $module -Action 'BreakGlassHealth' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $userId ` + -Detail 'Break-glass account is disabled. A disabled account cannot recover the tenant in an emergency. Enable it before enforcing any Conditional Access policy.' + continue + } + + # Enabled is not the same as usable. Confirm the account still holds a + # permanently assigned Global Administrator role, directly or through a + # role-assignable group. If the role cannot be read, treat it as unproven + # rather than assuming it is intact. + try { + if ($null -eq $gaPrincipalIds) { $gaPrincipalIds = Get-EntraGlobalAdminPrincipalId -BaseUri $v1 } + $roleState = Get-EntraGlobalAdminRecoveryState -BaseUri $v1 ` + -PrincipalId ([string] $userId) -PrincipalType User ` + -GlobalAdminPrincipalIds @($gaPrincipalIds) + } + catch { + $breakGlassUnreadableCount++ + Add-EntraRunLogEntry -Module $module -Action 'BreakGlassHealth' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $userId ` + -HttpStatusCode (Get-EntraHttpStatusCode -ErrorRecord $_) ` + -Detail "Could not confirm the break-glass account's Global Administrator role, so its ability to recover the tenant is unproven: $($_.Exception.Message)" + continue + } + + if ($roleState.HasGlobalAdmin) { + Add-EntraRunLogEntry -Module $module -Action 'BreakGlassHealth' -BestPracticeKey $bestPracticeKey ` + -Status 'Succeeded' -Disposition 'AlreadyCompliant' -Readback 'Verified' -Target $userId ` + -Detail "Break-glass account is enabled and still holds a permanently assigned Global Administrator role ($($roleState.RoleVia))." + } + else { + $breakGlassNoRoleCount++ + Add-EntraRunLogEntry -Module $module -Action 'BreakGlassHealth' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $userId ` + -Detail 'Break-glass account is enabled but no longer holds a permanently assigned Global Administrator role. It is excluded from Conditional Access but cannot actually recover the tenant. Reassign the role.' + } +} + +# A break-glass GROUP is only a recovery path if it still contains at least one +# enabled account. An empty or deleted group excludes nobody, so a tenant that +# relies solely on a group exclusion would otherwise be reported healthy while +# having no way back in. Membership is paginated: the first enabled member could +# be on any page, and stopping early would report a false lockout risk. +$breakGlassGroupUnusableCount = 0 +$breakGlassGroupNoRoleCount = 0 +foreach ($groupId in $breakGlassGroups) { + try { + $members = Get-EntraGraphCollection -BaseUri $v1 ` + -Uri "$v1/groups/$groupId/transitiveMembers/microsoft.graph.user`?`$select=id,accountEnabled" ` + -Description 'Read break-glass group members' + } + catch { + $breakGlassUnreadableCount++ + Add-EntraRunLogEntry -Module $module -Action 'BreakGlassHealth' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $groupId ` + -HttpStatusCode (Get-EntraHttpStatusCode -ErrorRecord $_) ` + -Detail "Configured break-glass group could not be read, so its availability for an emergency cannot be confirmed: $($_.Exception.Message)" + continue + } + + $enabledMembers = @(@($members) | + Where-Object { $_ -and [bool] (Get-EntraPolicyProperty -InputObject $_ -Name 'accountEnabled') }) + + if ($enabledMembers.Count -gt 0) { + try { + if ($null -eq $gaPrincipalIds) { + $gaPrincipalIds = Get-EntraGlobalAdminPrincipalId -BaseUri $v1 + } + $enabledMemberIds = @($enabledMembers | ForEach-Object { + [string] (Get-EntraPolicyProperty -InputObject $_ -Name 'id') + } | Where-Object { $_ }) + $roleState = Get-EntraGlobalAdminRecoveryState -BaseUri $v1 ` + -PrincipalId ([string] $groupId) -PrincipalType Group ` + -GlobalAdminPrincipalIds @($gaPrincipalIds) ` + -EnabledMemberIds $enabledMemberIds + } + catch { + $breakGlassUnreadableCount++ + Add-EntraRunLogEntry -Module $module -Action 'BreakGlassHealth' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $groupId ` + -HttpStatusCode (Get-EntraHttpStatusCode -ErrorRecord $_) ` + -Detail "Could not confirm the break-glass group's Global Administrator role, so its ability to recover the tenant is unproven: $($_.Exception.Message)" + continue + } + + if (-not $roleState.HasGlobalAdmin) { + $breakGlassGroupNoRoleCount++ + Add-EntraRunLogEntry -Module $module -Action 'BreakGlassHealth' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $groupId ` + -Detail 'Break-glass group has enabled members but neither the group nor an enabled member holds a permanently assigned Global Administrator role. It cannot recover the tenant. Reassign the role.' + continue + } + + Add-EntraRunLogEntry -Module $module -Action 'BreakGlassHealth' -BestPracticeKey $bestPracticeKey ` + -Status 'Succeeded' -Disposition 'AlreadyCompliant' -Readback 'Verified' -Target $groupId ` + -Detail "Break-glass group contains $($enabledMembers.Count) enabled member(s) and still provides a permanently assigned Global Administrator role ($($roleState.RoleVia))." + } + else { + $breakGlassGroupUnusableCount++ + Add-EntraRunLogEntry -Module $module -Action 'BreakGlassHealth' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $groupId ` + -Detail 'Break-glass group contains no enabled member. Excluding an empty group from Conditional Access provides no emergency access. Add an enabled emergency-access account to the group.' + } +} + +$verdict = if (($lockoutRiskCount + $unmanagedRiskCount + $breakGlassDisabledCount + + $breakGlassNoRoleCount + $breakGlassUnreadableCount + + $breakGlassGroupUnusableCount + $breakGlassGroupNoRoleCount) -gt 0) { + 'LockoutRisk' +} +elseif ((-not $canEvaluateExclusions) -or $unevaluableTemplateCount -gt 0 -or $securityDefaultsState -eq 'Unknown') { + # Never report Healthy when the lockout control could not be evaluated, in + # whole or in part. A clean-looking verdict here would be the most dangerous + # output this check could produce. + 'Indeterminate' +} +elseif (($missingCount + $duplicateCount + $exclusionGapCount + $unknownStateCount + $legacyPolicyCount) -gt 0 -or + $securityDefaultsState -eq 'Enabled') { 'DriftDetected' } +else { 'Healthy' } + +$summary = 'Verdict={0}; ExclusionsEvaluated={1}; UnmanagedPoliciesEvaluated={2}; ManagedPoliciesConfigured={3}; Present={4}; Missing={5}; Unevaluable={6}; DuplicateNames={7}; Enforcing={8}; Disabled={9}; UnknownState={10}; EnforcingPoliciesInTenant={11}; BreakGlassExclusionGaps={12}; LockoutRisks={13}; UnmanagedEnforcingRisks={14}; BreakGlassAccountsDisabled={15}; BreakGlassAccountsWithoutRole={16}; BreakGlassGroupsUnusable={17}; BreakGlassGroupsWithoutRole={18}; BreakGlassPrincipalsUnreadable={19}; Assessment=ReadOnly.' -f ` + $verdict, + $canEvaluateExclusions, + $unmanagedPoliciesEvaluated, + $selectedPolicies.Count, + $presentCount, + $missingCount, + $unevaluableTemplateCount, + $duplicateCount, + $enforcingCount, + $disabledCount, + $unknownStateCount, + $enforcingInTenantCount, + $exclusionGapCount, + $lockoutRiskCount, + $unmanagedRiskCount, + $breakGlassDisabledCount, + $breakGlassNoRoleCount, + $breakGlassGroupUnusableCount, + $breakGlassGroupNoRoleCount, + $breakGlassUnreadableCount +$summary += " SecurityDefaultsState=$securityDefaultsState; TenantWritesAllowed=$($securityDefaultsState -eq 'Disabled'); LegacyPolicies=$legacyPolicyCount." + +$summaryStatus = if ($verdict -eq 'LockoutRisk' -or + ($verdict -eq 'Indeterminate' -and $enforcingInTenantCount -gt 0)) { + 'Failed' +} +else { + 'Succeeded' +} +$summaryDisposition = switch ($verdict) { + 'Healthy' { 'AlreadyCompliant' } + 'Indeterminate' { 'GuidedOnly' } + 'LockoutRisk' { 'Blocked' } + default { 'WillChange' } +} +if ($securityDefaultsState -ne 'Disabled') { $summaryDisposition = 'Blocked' } + +Add-EntraRunLogEntry -Module $module -Action 'HealthSummary' -BestPracticeKey $bestPracticeKey ` + -Status $summaryStatus -Disposition $summaryDisposition -Detail $summary diff --git a/Products/Entra/Modules/Invoke-WithTransientRetry.ps1 b/Products/Entra/Modules/Invoke-WithTransientRetry.ps1 new file mode 100644 index 0000000..b1d7054 --- /dev/null +++ b/Products/Entra/Modules/Invoke-WithTransientRetry.ps1 @@ -0,0 +1,164 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Single retry boundary for transient Entra API and cmdlet operations. + +.DESCRIPTION + Uses bounded exponential backoff with small random jitter and records + Started, Retried, Succeeded, and Failed outcomes through the Entra + run-log interface when it is available. + + This is the only retry mechanism in the product. Do not hand-roll retry + loops in task modules. +#> + +function Invoke-WithTransientRetry { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [scriptblock] $Action, + [Parameter(Mandatory)] [string] $Description, + [int] $MaxAttempts = 3, + [int[]] $ExpectedStatusCodes = @(), + # Extra status codes to treat as transient for this call only. Used for + # read-after-write verification, where a freshly created object can + # briefly return 404 (Not Found) until directory replication catches up. + [int[]] $RetryStatusCodes = @(), + [int] $BackoffCapSeconds = 30, + [int] $MaxJitterSeconds = 3 + ) + + $transientStatusCodes = @(408, 429, 500, 502, 503, 504) + @($RetryStatusCodes) | Select-Object -Unique + $overlap = @($ExpectedStatusCodes | Where-Object { $_ -in $transientStatusCodes }) + if ($overlap.Count -gt 0) { + throw "ExpectedStatusCodes cannot include transient HTTP status codes: $($overlap -join ', ')." + } + + $logEnabled = [bool](Get-Command 'Add-EntraRunLogEntry' -ErrorAction SilentlyContinue) + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + if ($logEnabled) { + Add-EntraRunLogEntry -Module 'Invoke-WithTransientRetry' ` + -Action $Description -Status 'Started' -Attempt 1 ` + -Detail 'Transient operation started.' + } + + for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + try { + $result = & $Action + $stopwatch.Stop() + if ($logEnabled) { + Add-EntraRunLogEntry -Module 'Invoke-WithTransientRetry' ` + -Action $Description -Status 'Succeeded' -Attempt $attempt ` + -ElapsedMs ([int] $stopwatch.ElapsedMilliseconds) ` + -Detail 'Transient operation completed successfully.' + } + return $result + } + catch { + $errorRecord = $_ + $status = Get-EntraHttpStatusCode -ErrorRecord $errorRecord + if ($status -in $ExpectedStatusCodes) { + $stopwatch.Stop() + if ($logEnabled) { + Add-EntraRunLogEntry -Module 'Invoke-WithTransientRetry' ` + -Action $Description -Status 'Info' -Attempt $attempt ` + -HttpStatusCode $status ` + -ElapsedMs ([int] $stopwatch.ElapsedMilliseconds) ` + -Detail ("{0} returned an expected unsupported response." -f $Description) + } + throw + } + + $transient = Test-EntraTransientFailure -ErrorRecord $errorRecord ` + -HttpStatusCode $status -TransientHttpStatusCodes $transientStatusCodes + if (-not $transient -or $attempt -eq $MaxAttempts) { + $stopwatch.Stop() + if ($logEnabled) { + Add-EntraRunLogEntry -Module 'Invoke-WithTransientRetry' ` + -Action $Description -Status 'Failed' -Attempt $attempt ` + -HttpStatusCode $status ` + -ElapsedMs ([int] $stopwatch.ElapsedMilliseconds) ` + -Detail $errorRecord.Exception.Message + } + throw + } + + $backoff = [math]::Min($BackoffCapSeconds, [math]::Pow(2, $attempt)) + $jitter = Get-Random -Minimum 0 -Maximum ($MaxJitterSeconds + 1) + $sleepSeconds = $backoff + $jitter + if ($logEnabled) { + Add-EntraRunLogEntry -Module 'Invoke-WithTransientRetry' ` + -Action $Description -Status 'Retried' -Attempt $attempt ` + -HttpStatusCode $status ` + -Detail ("{0}. Sleeping {1}s before retry." -f $errorRecord.Exception.Message, $sleepSeconds) + } + Start-Sleep -Seconds $sleepSeconds + } + } +} + +# Response-less transient failures (cmdlet-style throttling or a transport +# reset) expose no HTTP status, so classification falls back to the exception +# type/message. Only consulted when no HTTP status code is available. +$script:EntraTransientExceptionTypeNames = @( + 'HttpRequestException', 'TaskCanceledException', 'TimeoutException', + 'WebException', 'SocketException', 'IOException' +) +$script:EntraTransientMessagePattern = + 'throttl|too many requests|\b429\b|\b502\b|\b503\b|\b504\b|service unavailable|temporarily unavailable|timed? ?out|connection (?:reset|closed|refused|aborted)|(?:operation|task) was canceled' + +function Test-EntraTransientFailure { + <# + Single failure-classification contract. A transient failure is any + configured transient HTTP status, or — when no HTTP status is available + (a response-less cmdlet/transport error) — a recognized throttling or + transient-transport exception type/message. A real permanent status + (400/401/403/404/409...) always fails fast. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)] [System.Management.Automation.ErrorRecord] $ErrorRecord, + [int] $HttpStatusCode = 0, + [int[]] $TransientHttpStatusCodes = @(408, 429, 500, 502, 503, 504) + ) + + if ($HttpStatusCode -in $TransientHttpStatusCodes) { return $true } + if ($HttpStatusCode -ne 0) { return $false } + + $exception = $ErrorRecord.Exception + while ($exception) { + if ($exception.GetType().Name -in $script:EntraTransientExceptionTypeNames) { return $true } + $exception = $exception.InnerException + } + return ([string] $ErrorRecord.Exception.Message) -match $script:EntraTransientMessagePattern +} + +function Get-EntraHttpStatusCode { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [System.Management.Automation.ErrorRecord] $ErrorRecord + ) + + $responseProperty = $ErrorRecord.Exception.PSObject.Properties['Response'] + if (-not $responseProperty) { + return 0 + } + + $response = $responseProperty.Value + $statusCodeProperty = if ($response) { + $response.PSObject.Properties['StatusCode'] + } + if ($statusCodeProperty -and $null -ne $statusCodeProperty.Value) { + $value = $statusCodeProperty.Value + if ($value -is [enum]) { + return [int] $value + } + + $parsedStatusCode = 0 + if ([int]::TryParse([string] $value, [ref] $parsedStatusCode)) { + return $parsedStatusCode + } + } + return 0 +} diff --git a/Products/Entra/Modules/Setup-ConditionalAccessBaseline.ps1 b/Products/Entra/Modules/Setup-ConditionalAccessBaseline.ps1 new file mode 100644 index 0000000..454c567 --- /dev/null +++ b/Products/Entra/Modules/Setup-ConditionalAccessBaseline.ps1 @@ -0,0 +1,484 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Conditional Access baseline (Identity Protection guide, Priority 1). + +.DESCRIPTION + Creates the Conditional Access policies listed in ConditionalAccess.Policies + from the templates in ConditionalAccess.PolicyTemplateDirectory. Every policy + is created in ConditionalAccess.DefaultState (report-only by default), scoped + to the pilot group for all-users-style policies, and always excludes the + break-glass principals resolved by Setup-EmergencyAccess. + + High risk: Conditional Access can deny sign-in tenant-wide. The write only + runs when the orchestrator has cleared the item; otherwise the module reports + what it would create. Idempotent on display name, gated by ShouldProcess, + wrapped in the shared retry boundary, and read back. The original ten-policy + baseline has historical Business Premium pilot evidence; the newer P1 + additions require separate pilot validation. +#> + +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +param( + [Parameter(Mandatory)] [hashtable] $Config, + [hashtable] $Context, + [switch] $AdoptExisting +) + +$ErrorActionPreference = 'Stop' +$ConfirmPreference = 'None' + +. (Join-Path $PSScriptRoot 'EntraRunLog.ps1') +. (Join-Path $PSScriptRoot 'EntraGraphClient.ps1') +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') +. (Join-Path $PSScriptRoot 'EntraPolicyComparison.ps1') +. (Join-Path $PSScriptRoot 'EntraDirectoryReads.ps1') +. (Join-Path $PSScriptRoot 'EntraSecurityDefaults.ps1') + +$module = 'Setup-ConditionalAccessBaseline' +$bestPracticeKey = 'conditional-access-baseline' +$ca = $Config.ConditionalAccess +Assert-EntraPolicyMigrationConfig -ConditionalAccess $ca + +function ConvertTo-EntraArray { + param([AllowNull()] $Value) + # The unary comma keeps a single-element array from unrolling to a scalar on + # return, which would make Graph reject the collection-typed member. + if ($null -eq $Value) { return , @() } + return , @($Value) +} + +# Graph requires collection-typed members; the exported templates carry some of +# them as scalars. Normalize the known array fields so a POST is accepted. +function Set-EntraArrayFields { + param([Parameter(Mandatory)] [hashtable] $Policy) + if ($Policy.grantControls -is [hashtable] -and $Policy.grantControls.ContainsKey('builtInControls')) { + $Policy.grantControls.builtInControls = ConvertTo-EntraArray $Policy.grantControls.builtInControls + } + $conditions = $Policy.conditions + if ($conditions -is [hashtable]) { + if ($conditions.ContainsKey('clientAppTypes')) { $conditions.clientAppTypes = ConvertTo-EntraArray $conditions.clientAppTypes } + foreach ($node in @('applications', 'users', 'platforms')) { + if ($conditions[$node] -is [hashtable]) { + foreach ($field in @($conditions[$node].Keys | ForEach-Object { $_ })) { + if ($field -match '^(include|exclude)(Applications|Users|Groups|Roles|Platforms|UserActions)$') { + $conditions[$node][$field] = ConvertTo-EntraArray $conditions[$node][$field] + } + } + } + } + } +} + +# A name match alone does not prove an existing policy provides the required +# protections. Compare the security-relevant fields (state, grant controls, +# targeted apps/user-actions/principals, client-app types) and confirm the +# break-glass principals are excluded before treating a policy as compliant. +# The break-glass half is shared with the deployment health check so the two +# can never disagree about whether a tenant is safe. +function Test-EntraPolicyCompliant { + param( + [Parameter(Mandatory)] [hashtable] $Desired, + [Parameter(Mandatory)] $Existing, + [string[]] $BreakGlassUsers = @(), + [string[]] $BreakGlassGroups = @() + ) + + $existingState = Get-EntraPolicyProperty $Existing 'state' + if ($existingState -notin @('enabled', 'enabledForReportingButNotEnforced', 'disabled')) { return $false } + if ($existingState -eq 'disabled' -and (Get-EntraPolicyProperty $Desired 'state') -ne 'disabled') { return $false } + + $dg = Get-EntraPolicyProperty $Desired 'grantControls' + $eg = Get-EntraPolicyProperty $Existing 'grantControls' + if (((Get-EntraNormalizedSet (Get-EntraPolicyProperty $dg 'builtInControls')) -join ',') -ne + ((Get-EntraNormalizedSet (Get-EntraPolicyProperty $eg 'builtInControls')) -join ',')) { return $false } + if ((Get-EntraPolicyProperty $dg 'operator') -ne (Get-EntraPolicyProperty $eg 'operator')) { return $false } + $desiredStrength = Get-EntraPolicyProperty $dg 'authenticationStrength' + $existingStrength = Get-EntraPolicyProperty $eg 'authenticationStrength' + if ((Get-EntraPolicyProperty $desiredStrength 'id') -ne (Get-EntraPolicyProperty $existingStrength 'id')) { return $false } + + $dc = Get-EntraPolicyProperty $Desired 'conditions' + $ec = Get-EntraPolicyProperty $Existing 'conditions' + if (((Get-EntraNormalizedSet (Get-EntraPolicyProperty $dc 'clientAppTypes')) -join ',') -ne + ((Get-EntraNormalizedSet (Get-EntraPolicyProperty $ec 'clientAppTypes')) -join ',')) { return $false } + $desiredApps = Get-EntraPolicyProperty $dc 'applications' + $existingApps = Get-EntraPolicyProperty $ec 'applications' + foreach ($field in 'includeApplications', 'excludeApplications', 'includeUserActions') { + if (((Get-EntraNormalizedSet (Get-EntraPolicyProperty $desiredApps $field)) -join ',') -ne + ((Get-EntraNormalizedSet (Get-EntraPolicyProperty $existingApps $field)) -join ',')) { return $false } + } + $desiredUsers = Get-EntraPolicyProperty $dc 'users' + $existingUsers = Get-EntraPolicyProperty $ec 'users' + foreach ($field in 'includeUsers', 'includeGroups', 'includeRoles') { + if (((Get-EntraNormalizedSet (Get-EntraPolicyProperty $desiredUsers $field)) -join ',') -ne + ((Get-EntraNormalizedSet (Get-EntraPolicyProperty $existingUsers $field)) -join ',')) { return $false } + } + foreach ($field in 'excludeUsers', 'excludeGroups', 'excludeRoles') { + if (((Get-EntraNormalizedSet (Get-EntraPolicyProperty $desiredUsers $field)) -join ',') -ne + ((Get-EntraNormalizedSet (Get-EntraPolicyProperty $existingUsers $field)) -join ',')) { return $false } + } + foreach ($field in 'includeGuestsOrExternalUsers', 'excludeGuestsOrExternalUsers') { + if (-not (Test-EntraManagedPropertiesMatch ` + -Expected (Get-EntraPolicyProperty $desiredUsers $field) ` + -Actual (Get-EntraPolicyProperty $existingUsers $field))) { return $false } + } + $desiredPlatforms = Get-EntraPolicyProperty $dc 'platforms' + $existingPlatforms = Get-EntraPolicyProperty $ec 'platforms' + foreach ($field in 'includePlatforms', 'excludePlatforms') { + if (((Get-EntraNormalizedSet (Get-EntraPolicyProperty $desiredPlatforms $field)) -join ',') -ne + ((Get-EntraNormalizedSet (Get-EntraPolicyProperty $existingPlatforms $field)) -join ',')) { return $false } + } + $desiredFlows = Get-EntraPolicyProperty $dc 'authenticationFlows' + $existingFlows = Get-EntraPolicyProperty $ec 'authenticationFlows' + if ((Get-EntraPolicyProperty $desiredFlows 'transferMethods') -ne + (Get-EntraPolicyProperty $existingFlows 'transferMethods')) { return $false } + foreach ($field in 'signInRiskLevels', 'userRiskLevels') { + if (((Get-EntraNormalizedSet (Get-EntraPolicyProperty $dc $field)) -join ',') -ne + ((Get-EntraNormalizedSet (Get-EntraPolicyProperty $ec $field)) -join ',')) { return $false } + } + $desiredLocations = Get-EntraPolicyProperty $dc 'locations' + $existingLocations = Get-EntraPolicyProperty $ec 'locations' + foreach ($field in 'includeLocations', 'excludeLocations') { + if (((Get-EntraNormalizedSet (Get-EntraPolicyProperty $desiredLocations $field)) -join ',') -ne + ((Get-EntraNormalizedSet (Get-EntraPolicyProperty $existingLocations $field)) -join ',')) { return $false } + } + + $desiredSession = Get-EntraPolicyProperty $Desired 'sessionControls' + if ($null -ne $desiredSession -and -not (Test-EntraManagedPropertiesMatch ` + -Expected $desiredSession -Actual (Get-EntraPolicyProperty $Existing 'sessionControls'))) { + return $false + } + $desiredDevices = Get-EntraPolicyProperty $dc 'devices' + if ($null -ne $desiredDevices -and -not (Test-EntraManagedPropertiesMatch ` + -Expected $desiredDevices -Actual (Get-EntraPolicyProperty $ec 'devices'))) { + return $false + } + + if (-not (Test-EntraBreakGlassExcluded -Policy $Existing ` + -BreakGlassUsers $BreakGlassUsers -BreakGlassGroups $BreakGlassGroups)) { + return $false + } + + return $true +} + +# When adopting an existing same-named policy, change only what this tool +# manages: ensure the required grant controls and the break-glass exclusion, +# and preserve every other tenant-specific setting by starting from the existing +# policy. Only grantControls and conditions are returned, so a PATCH leaves +# state, session controls, and display name untouched. +function Get-EntraAdoptBody { + param( + [Parameter(Mandatory)] $Existing, + [Parameter(Mandatory)] [hashtable] $Desired, + [string[]] $BreakGlassUsers = @(), + [string[]] $BreakGlassGroups = @() + ) + + if ($Desired.grantControls.authenticationStrength -or $Desired.sessionControls) { + throw 'Authentication-strength and session-control policies require manual review; automatic adoption is not supported.' + } + + # Deep copy so the customer's existing complex objects are preserved intact. + $work = ($Existing | ConvertTo-Json -Depth 30) | ConvertFrom-Json -AsHashtable + $body = @{} + + $requiredControls = @($Desired.grantControls.builtInControls | Where-Object { $_ }) + if ($work.grantControls -is [hashtable]) { + $gc = $work.grantControls + $gc['builtInControls'] = @(@($gc['builtInControls']) + $requiredControls | Where-Object { $_ } | Select-Object -Unique) + if ([string]::IsNullOrWhiteSpace([string] $gc['operator'])) { + $gc['operator'] = if ($Desired.grantControls.operator) { $Desired.grantControls.operator } else { 'OR' } + } + $body['grantControls'] = $gc + } + elseif ($Desired.grantControls) { + $body['grantControls'] = $Desired.grantControls + } + + if ($work.conditions -isnot [hashtable]) { $work.conditions = @{} } + if ($work.conditions.users -isnot [hashtable]) { $work.conditions.users = @{} } + $users = $work.conditions.users + if ($BreakGlassUsers.Count) { $users['excludeUsers'] = @(@($users['excludeUsers']) + $BreakGlassUsers | Where-Object { $_ } | Select-Object -Unique) } + if ($BreakGlassGroups.Count) { $users['excludeGroups'] = @(@($users['excludeGroups']) + $BreakGlassGroups | Where-Object { $_ } | Select-Object -Unique) } + $body['conditions'] = $work.conditions + + return $body +} + +if ($Context -and @($Context.BlockedItemKeys) -contains $bestPracticeKey) { + Add-EntraRunLogEntry -Module $module -Action 'ConditionalAccess' -BestPracticeKey $bestPracticeKey ` + -Status 'Skipped' -Disposition 'Blocked' -Detail 'Conditional Access baseline was withheld by preflight or a gate.' + return +} + +if (-not $Context -or [string]::IsNullOrWhiteSpace([string] $Context.TenantAdminUpn)) { + throw 'Setup-ConditionalAccessBaseline.ps1 requires Context.TenantAdminUpn and Graph connection settings.' +} + +$v1 = $Config.Api.GraphBaseUri +$breakGlassUsers = @(@($Context.BreakGlassUserIds) | Where-Object { $_ }) +$breakGlassGroups = @(@($Context.BreakGlassGroupIds) | Where-Object { $_ }) + +# Fail closed: never create an enforcing-capable policy set without an emergency +# exclusion, even in report-only, so promotion to enabled stays safe. +if ($ca.RequireBreakGlassExclusion -and $breakGlassUsers.Count -eq 0 -and $breakGlassGroups.Count -eq 0) { + Add-EntraRunLogEntry -Module $module -Action 'ConditionalAccess' -BestPracticeKey $bestPracticeKey ` + -Status 'Skipped' -Disposition 'Blocked' ` + -Detail 'No break-glass exclusion is available. Configure ConditionalAccess.BreakGlass or run Setup-EmergencyAccess with CreateAccountIfMissing before deploying the baseline.' + return +} + +$writeWithheld = @($Context.WriteBlockedItemKeys) -contains $bestPracticeKey + +# Resolve the assignment target for the all-users-style policies. +$targetGroup = $null +$tenantWide = $Context.AssignmentScope -eq 'TenantWide' +if (-not $tenantWide) { $targetGroup = [string] $Context.PilotGroupId } + +$state = $ca.DefaultState +$templateDir = Join-Path (Split-Path -Parent $PSScriptRoot) $ca.PolicyTemplateDirectory + +# Read existing policies once, in full, for idempotency and protection +# comparison. Fail closed: without this list the tool cannot tell an existing +# policy from a missing one, so it must not proceed to create (and risk +# duplicating existing policies) when the read fails. +$existingByName = @{} +Add-EntraRunLogEntry -Module $module -Action 'Stage' -Status 'Info' ` + -Detail 'Existing Conditional Access assessment' +try { + $existing = Get-EntraGraphCollection -BaseUri $v1 ` + -Uri "$v1/identity/conditionalAccess/policies" -Description 'Read existing Conditional Access policies' + foreach ($p in @($existing)) { + if ($p.displayName) { + $name = [string] $p.displayName + if (-not $existingByName.ContainsKey($name)) { $existingByName[$name] = @() } + $existingByName[$name] += $p + } + } +} +catch { + Add-EntraRunLogEntry -Module $module -Action 'ReadExisting' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -HttpStatusCode (Get-EntraHttpStatusCode -ErrorRecord $_) ` + -Detail "Could not enumerate existing Conditional Access policies; stopping before any write so existing policies are not duplicated: $($_.Exception.Message)" + throw +} + +$currentTier = $null +foreach ($policyRef in @($ca.Policies)) { + $tier = Get-EntraPolicyTier -PolicyReference $policyRef + if ($tier -ne $currentTier) { + $currentTier = $tier + $tierName = if ($tier -eq 'P1Hardened') { 'P1 optional/hardened policies' } else { 'Business Premium / P1 baseline' } + Add-EntraRunLogEntry -Module $module -Action 'Stage' -Status 'Info' ` + -Detail "$tierName - assessment and gated deployment" + } + if (-not (Test-EntraPolicySelected -PolicyReference $policyRef)) { + Add-EntraRunLogEntry -Module $module -Action 'PolicySelection' -BestPracticeKey $bestPracticeKey ` + -Status 'Skipped' -Disposition 'Skipped' -Target $policyRef.Key ` + -Detail "$tier policy is not selected (Enabled=false). Existing tenant policies are not disabled or deleted." + continue + } + $templatePath = Join-Path $templateDir $policyRef.File + if (-not (Test-Path -LiteralPath $templatePath -PathType Leaf)) { + Add-EntraRunLogEntry -Module $module -Action 'Create' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Target $policyRef.Key -Detail "Policy template not found: $($policyRef.File)" + throw "Policy template not found: $templatePath" + } + + $policy = Get-Content -Raw -LiteralPath $templatePath | ConvertFrom-Json -AsHashtable + $templateName = [string] $policy.displayName + $displayName = Get-EntraPolicyDisplayName -DisplayName $templateName -PolicyReference $policyRef -ConditionalAccess $ca + $policy.displayName = $displayName + Set-EntraArrayFields -Policy $policy + $policy.state = $state + + if ($policy.conditions -isnot [hashtable]) { $policy.conditions = @{} } + if ($policy.conditions.users -isnot [hashtable]) { $policy.conditions.users = @{} } + $users = $policy.conditions.users + + # Scope all-users-style policies (those the template targets by group) to the + # pilot group, or to every user under -AssignTenantWide. Role-, guest- and + # app-scoped policies keep their template scope. + if ($users.ContainsKey('includeGroups') -and @($users.includeGroups).Count -gt 0) { + if ($tenantWide) { + $users.Remove('includeGroups') | Out-Null + $users.includeUsers = @('All') + } + elseif (-not [string]::IsNullOrWhiteSpace($targetGroup)) { + $users.includeGroups = @($targetGroup) + } + } + + # Always exclude the break-glass principals, merged with any template roles. + if ($breakGlassUsers.Count -gt 0) { + $users.excludeUsers = @(@($users['excludeUsers']) + $breakGlassUsers | Where-Object { $_ } | Select-Object -Unique) + } + if ($breakGlassGroups.Count -gt 0) { + $users.excludeGroups = @(@($users['excludeGroups']) + $breakGlassGroups | Where-Object { $_ } | Select-Object -Unique) + } + + $candidateNames = Get-EntraPolicyDisplayNames -DisplayName $templateName -PolicyReference $policyRef -ConditionalAccess $ca + $policyMatches = @( + foreach ($name in $candidateNames) { + if ($existingByName.ContainsKey($name)) { $existingByName[$name] } + } + ) + if ($policyMatches.Count -gt 1) { + Add-EntraRunLogEntry -Module $module -Action 'ExistingPolicy' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Target $policyRef.Key ` + -Detail 'Multiple current or legacy policy names match this baseline item. Reconcile the policies manually; none was overwritten or duplicated.' + throw "Ambiguous existing policies for baseline item '$($policyRef.Key)'." + } + $existingPolicy = if ($policyMatches.Count -eq 1) { $policyMatches[0] } else { $null } + $reviewExistingOnly = (Get-EntraPolicyProperty $policyRef 'ReviewExistingOnly') -eq $true + + if ($existingPolicy -and $reviewExistingOnly) { + $comparedControlsMatch = Test-EntraPolicyCompliant -Desired $policy -Existing $existingPolicy ` + -BreakGlassUsers $breakGlassUsers -BreakGlassGroups $breakGlassGroups + $comparisonDetail = if ($comparedControlsMatch) { + 'The compared controls match, but this is not verified compliance or approval.' + } else { 'The compared controls do not match the intended policy.' } + Add-EntraRunLogEntry -Module $module -Action 'Create' -BestPracticeKey $bestPracticeKey ` + -Status 'Skipped' -Disposition 'GuidedOnly' -Target $displayName ` + -Readback $(if ($comparedControlsMatch) { 'NotAttempted' } else { 'Mismatch' }) ` + -Detail "$comparisonDetail This existing policy requires manual review of grants, authentication strength/flow, session controls, user actions, targeting, prerequisites and emergency access. It was not changed or duplicated, including with AdoptExisting." + continue + } + + # A name match is not proof of compliance. Only treat an existing policy as + # complete when it actually carries the required protections and excludes + # the break-glass principals; otherwise report drift instead of silently + # accepting it, and never create a second policy with the same name. + if ($existingPolicy -and -not $AdoptExisting) { + if (Test-EntraPolicyCompliant -Desired $policy -Existing $existingPolicy -BreakGlassUsers $breakGlassUsers -BreakGlassGroups $breakGlassGroups) { + Add-EntraRunLogEntry -Module $module -Action 'Create' -BestPracticeKey $bestPracticeKey ` + -Status 'Skipped' -Disposition 'AlreadyCompliant' -Target $displayName -Readback 'Verified' ` + -Detail 'An existing Conditional Access policy matches this baseline item and the compared controls, scope and emergency-access exclusions. Its existing name and settings are unchanged.' + } + else { + Add-EntraRunLogEntry -Module $module -Action 'Create' -BestPracticeKey $bestPracticeKey ` + -Status 'Skipped' -Disposition 'GuidedOnly' -Target $displayName -Readback 'Mismatch' ` + -Detail 'An existing Conditional Access policy matches a current or previous name but not the compared protections or emergency-access exclusions. Review it, or rerun with -AdoptExisting for the supported in-place update. No duplicate policy was created.' + } + continue + } + + if ($writeWithheld) { + Add-EntraRunLogEntry -Module $module -Action 'ConditionalAccess' -BestPracticeKey $bestPracticeKey ` + -Status 'Succeeded' -Disposition 'GuidedOnly' -Target $displayName ` + -Detail "Assessment only: would create '$displayName' (tier=$tier, state=$state, recommended target=$($policyRef.RecommendedState)); write withheld until IncludeHighRisk with CustomerApprovalId, BreakGlassExclusionsConfirmed, RollbackAcknowledged and an approved assignment scope are supplied." + continue + } + + if (@($users['includeGroups']) -contains '{ID}') { + Add-EntraRunLogEntry -Module $module -Action 'AssignmentGate' -BestPracticeKey $bestPracticeKey ` + -Status 'Skipped' -Disposition 'Blocked' -Target $policyRef.Key ` + -Detail 'PilotGroupId is required to replace the template group placeholder. No policy was written.' + continue + } + if (-not (Test-EntraSecurityDefaultsWriteAllowed -BaseUri $v1 -Module $module -BestPracticeKey $bestPracticeKey)) { + continue + } + if (-not $PSCmdlet.ShouldProcess($displayName, "Create Conditional Access policy (state=$state)")) { + Add-EntraRunLogEntry -Module $module -Action 'ConditionalAccess' -BestPracticeKey $bestPracticeKey ` + -Status 'Info' -Disposition 'WillChange' -Target $displayName -Readback 'NotAttempted' ` + -Detail "WhatIf: would create '$displayName' in state=$state, break-glass excluded, recommended target=$($policyRef.RecommendedState)." + continue + } + + $policy.Remove('id') | Out-Null + $body = $policy | ConvertTo-Json -Depth 30 + $created = $null + $wasAdopt = [bool]($existingPolicy -and $AdoptExisting) + if ($wasAdopt) { + # Update the existing policy in place (never POST a duplicate), changing + # only what this tool manages: ensure the required grant controls and the + # break-glass exclusion, and preserve the customer's other conditions, + # session controls, targeting, and state. + $adoptBody = (Get-EntraAdoptBody -Existing $existingPolicy -Desired $policy -BreakGlassUsers $breakGlassUsers -BreakGlassGroups $breakGlassGroups) | ConvertTo-Json -Depth 30 + try { + Invoke-WithTransientRetry -Description "Update Conditional Access policy '$displayName'" -Action { + Invoke-MgGraphRequest -Method PATCH -Uri "$v1/identity/conditionalAccess/policies/$($existingPolicy.id)" -Body $adoptBody -ContentType 'application/json' | Out-Null + } + $created = [pscustomobject]@{ id = [string] $existingPolicy.id } + Add-EntraRunLogEntry -Module $module -Action 'Create' -BestPracticeKey $bestPracticeKey ` + -Status 'Adopted' -Disposition 'Applicable' -Target $displayName ` + -Detail "Adopted the existing Conditional Access policy: ensured the required grant controls and the break-glass exclusion; left its other conditions, session controls, targeting, and state unchanged." + } + catch { + Add-EntraRunLogEntry -Module $module -Action 'Create' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Applicable' -Target $displayName ` + -HttpStatusCode (Get-EntraHttpStatusCode -ErrorRecord $_) ` + -Detail "Failed to update Conditional Access policy '$displayName': $($_.Exception.Message)" + throw + } + } + else { + try { + $created = Invoke-WithTransientRetry -Description "Create Conditional Access policy '$displayName'" -Action { + Invoke-MgGraphRequest -Method POST -Uri "$v1/identity/conditionalAccess/policies" -Body $body -ContentType 'application/json' + } + Add-EntraRunLogEntry -Module $module -Action 'Create' -BestPracticeKey $bestPracticeKey ` + -Status 'Created' -Disposition 'Applicable' -Target $displayName ` + -Detail "Created Conditional Access policy in state=$state (recommended target=$($policyRef.RecommendedState))." + } + catch { + Add-EntraRunLogEntry -Module $module -Action 'Create' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Applicable' -Target $displayName ` + -HttpStatusCode (Get-EntraHttpStatusCode -ErrorRecord $_) ` + -Detail "Failed to create Conditional Access policy '$displayName': $($_.Exception.Message)" + throw + } + } + + $verify = $null + try { + $verify = Invoke-WithTransientRetry -Description "Read back Conditional Access policy '$displayName'" -RetryStatusCodes 404 -MaxAttempts 5 -Action { + Invoke-MgGraphRequest -Method GET -Uri "$v1/identity/conditionalAccess/policies/$($created.id)" + } + } + catch { + Add-EntraRunLogEntry -Module $module -Action 'Readback' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Target $displayName -Readback 'NotAttempted' ` + -Detail "Read-back could not be completed; stopping to report the unconfirmed change: $($_.Exception.Message)" + throw + } + if (-not $verify) { + Add-EntraRunLogEntry -Module $module -Action 'Readback' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Target $displayName -Readback 'Mismatch' ` + -Detail 'Read-back returned no policy after the write. Stopping so the discrepancy can be reviewed.' + throw "Conditional Access policy '$displayName' could not be read back after write." + } + if ($wasAdopt) { + # Adopt preserves state and targeting, so confirm the two guarantees the + # tool actually makes: the required grant controls and the break-glass + # exclusion are present. + $verifiedControls = Get-EntraNormalizedSet $verify.grantControls.builtInControls + $missingControls = @(@($policy.grantControls.builtInControls) | Where-Object { $_ -and ($_ -notin $verifiedControls) }) + $verifiedExUsers = Get-EntraNormalizedSet $verify.conditions.users.excludeUsers + $verifiedExGroups = Get-EntraNormalizedSet $verify.conditions.users.excludeGroups + $missingExclusions = @(@($breakGlassUsers | Where-Object { $_ -notin $verifiedExUsers }) + @($breakGlassGroups | Where-Object { $_ -notin $verifiedExGroups })) + if ($missingControls.Count -gt 0 -or $missingExclusions.Count -gt 0) { + Add-EntraRunLogEntry -Module $module -Action 'Readback' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Target $displayName -Readback 'Mismatch' ` + -Detail 'Read-back did not confirm the adopted policy carries the required grant controls and break-glass exclusion. Stopping so the discrepancy can be reviewed.' + throw "Adopted Conditional Access policy '$displayName' could not be confirmed after write." + } + Add-EntraRunLogEntry -Module $module -Action 'Readback' -BestPracticeKey $bestPracticeKey ` + -Status 'Info' -Disposition 'Applicable' -Target $displayName -Readback 'Verified' ` + -Detail "Read-back confirmed the required grant controls and break-glass exclusion; other settings preserved (state='$($verify.state)')." + } + else { + if ($verify.state -ne $state -or -not (Test-EntraPolicyCompliant -Desired $policy -Existing $verify ` + -BreakGlassUsers $breakGlassUsers -BreakGlassGroups $breakGlassGroups)) { + Add-EntraRunLogEntry -Module $module -Action 'Readback' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Target $displayName -Readback 'Mismatch' ` + -Detail "Read-back did not confirm the expected state='$state' or the required grant, session controls, device filter, authentication strength/flow, user action, targeting, platforms and emergency-access exclusions. Stopping so the discrepancy can be reviewed." + throw "Conditional Access policy '$displayName' could not be confirmed after write (state='$($verify.state)')." + } + Add-EntraRunLogEntry -Module $module -Action 'Readback' -BestPracticeKey $bestPracticeKey ` + -Status 'Info' -Disposition 'Applicable' -Target $displayName -Readback 'Verified' ` + -Detail "Read-back confirmed state='$($verify.state)' and the compared grants, session controls, device filter, authentication strength/flow, user action, targeting, platforms and emergency-access exclusions." + } +} diff --git a/Products/Entra/Modules/Setup-EmergencyAccess.ps1 b/Products/Entra/Modules/Setup-EmergencyAccess.ps1 new file mode 100644 index 0000000..9bfdb0a --- /dev/null +++ b/Products/Entra/Modules/Setup-EmergencyAccess.ps1 @@ -0,0 +1,209 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Emergency access (break-glass) account handling for Conditional Access + (Identity Protection guide, Priority 1). + +.DESCRIPTION + Resolves the emergency-access principals that every Conditional Access policy + must exclude. It verifies the accounts/groups configured in + ConditionalAccess.BreakGlass, and, when none are configured and + CreateAccountIfMissing is set, creates a dedicated cloud-only break-glass + account. The resolved principal IDs are written to the run's break-glass + output file so the Conditional Access baseline can exclude them. + + A generated account password is never written to evidence; the operator must + reset it in the portal and store it securely (see the end-user guide). All + writes are gated by ShouldProcess and wrapped in the shared retry boundary. +#> + +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +param( + [Parameter(Mandatory)] [hashtable] $Config, + [hashtable] $Context +) + +$ErrorActionPreference = 'Stop' +$ConfirmPreference = 'None' + +. (Join-Path $PSScriptRoot 'EntraRunLog.ps1') +. (Join-Path $PSScriptRoot 'EntraGraphClient.ps1') +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') +. (Join-Path $PSScriptRoot 'EntraPolicyComparison.ps1') +. (Join-Path $PSScriptRoot 'EntraDirectoryReads.ps1') +. (Join-Path $PSScriptRoot 'EntraSecurityDefaults.ps1') + +$module = 'Setup-EmergencyAccess' +$bestPracticeKey = 'emergency-access-account' + +if (-not $Context -or [string]::IsNullOrWhiteSpace([string] $Context.TenantAdminUpn)) { + throw 'Setup-EmergencyAccess.ps1 requires Context.TenantAdminUpn and Graph connection settings.' +} + +$v1 = $Config.Api.GraphBaseUri +$bg = $Config.ConditionalAccess.BreakGlass +$userIds = [System.Collections.Generic.List[string]]::new() +$groupIds = [System.Collections.Generic.List[string]]::new() +foreach ($u in @($Context.BreakGlassUserIds)) { if ($u) { $userIds.Add([string] $u) } } +foreach ($g in @($Context.BreakGlassGroupIds)) { if ($g) { $groupIds.Add([string] $g) } } + +# Microsoft's emergency-access guidance requires the break-glass account to hold +# Global Administrator *permanently* assigned (not PIM-eligible) so a PIM outage +# cannot lock the tenant out. The role id and the assignment read live in +# EntraDirectoryReads.ps1 so the deployment health check evaluates recoverability +# exactly the same way this module does. +$globalAdminRoleId = Get-EntraGlobalAdminRoleId +$gaPrincipalIds = $null + +# Verify configured principals exist, are usable, and can actually recover the +# tenant, so a typo or a disabled/under-privileged account cannot silently +# undermine the exclusion the CA baseline depends on. +foreach ($u in @($userIds)) { + try { + $account = Invoke-WithTransientRetry -Description 'Verify break-glass user' -Action { + Invoke-MgGraphRequest -Method GET -Uri "$v1/users/$u`?`$select=id,accountEnabled,userPrincipalName" + } + } + catch { + Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $u ` + -HttpStatusCode (Get-EntraHttpStatusCode -ErrorRecord $_) ` + -Detail "Configured break-glass user could not be verified: $($_.Exception.Message)" + throw + } + + if (-not [bool] $account.accountEnabled) { + Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $u ` + -Detail "Break-glass account '$($account.userPrincipalName)' is disabled; a disabled account cannot be used in an emergency. Enable it before deploying the baseline." + throw "Break-glass account $u is disabled." + } + + # Confirm the account can recover the tenant: it must hold a permanently + # assigned Global Administrator role, directly or through a role-assignable + # group. Any other role (or none) is not a valid break-glass account, and if + # the role cannot be read the run stops rather than assume it is safe. + try { + if ($null -eq $gaPrincipalIds) { $gaPrincipalIds = Get-EntraGlobalAdminPrincipalId -BaseUri $v1 -RoleId $globalAdminRoleId } + $roleState = Get-EntraGlobalAdminRecoveryState -BaseUri $v1 ` + -PrincipalId ([string] $u) -PrincipalType User ` + -GlobalAdminPrincipalIds @($gaPrincipalIds) + } + catch { + Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $u ` + -HttpStatusCode (Get-EntraHttpStatusCode -ErrorRecord $_) ` + -Detail "Could not confirm the break-glass account's Global Administrator role; stopping so it can be reviewed: $($_.Exception.Message)" + throw + } + + if ($roleState.HasGlobalAdmin) { + Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` + -Status 'Succeeded' -Disposition 'AlreadyCompliant' -Readback 'Verified' -Target $u ` + -Detail "Break-glass account '$($account.userPrincipalName)' is enabled and holds a permanently assigned, tenant-wide Global Administrator role with no expiry ($($roleState.RoleVia))." + } + else { + Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $u ` + -Detail "Break-glass account '$($account.userPrincipalName)' is enabled but does not hold a permanently-assigned Global Administrator role. Microsoft's emergency-access guidance requires Global Administrator assigned permanently (not PIM-eligible). Assign it before deploying the baseline." + throw "Break-glass account $u does not hold a permanently-assigned Global Administrator role." + } +} +foreach ($g in @($groupIds)) { + try { + $group = Invoke-WithTransientRetry -Description 'Verify break-glass group' -Action { + Invoke-MgGraphRequest -Method GET -Uri "$v1/groups/$g`?`$select=id,displayName" + } + $members = Get-EntraGraphCollection -BaseUri $v1 ` + -Uri "$v1/groups/$g/transitiveMembers/microsoft.graph.user?`$select=id,accountEnabled,userPrincipalName" ` + -Description 'Read break-glass group members' + if ($null -eq $gaPrincipalIds) { $gaPrincipalIds = Get-EntraGlobalAdminPrincipalId -BaseUri $v1 -RoleId $globalAdminRoleId } + } + catch { + Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $g ` + -HttpStatusCode (Get-EntraHttpStatusCode -ErrorRecord $_) ` + -Detail "Configured break-glass group could not be verified; stopping so it can be reviewed: $($_.Exception.Message)" + throw + } + + # A break-glass group is only usable if it actually contains an enabled + # emergency account (direct or nested, via transitive membership), and either + # the group or one of those members holds a permanently-assigned Global + # Administrator role. + $enabledMembers = @(@($members) | Where-Object { + [bool] (Get-EntraPolicyProperty -InputObject $_ -Name 'accountEnabled') + }) + if ($enabledMembers.Count -eq 0) { + Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $g ` + -Detail "Break-glass group '$($group.displayName)' contains no enabled user account, so it cannot serve as an emergency-access exclusion. Add an enabled emergency account that holds Global Administrator." + throw "Break-glass group $g contains no enabled user account." + } + + $enabledMemberIds = @($enabledMembers | ForEach-Object { + [string] (Get-EntraPolicyProperty -InputObject $_ -Name 'id') + } | Where-Object { $_ }) + $roleState = Get-EntraGlobalAdminRecoveryState -BaseUri $v1 ` + -PrincipalId ([string] $g) -PrincipalType Group ` + -GlobalAdminPrincipalIds @($gaPrincipalIds) ` + -EnabledMemberIds $enabledMemberIds + + if ($roleState.HasGlobalAdmin) { + Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` + -Status 'Succeeded' -Disposition 'AlreadyCompliant' -Readback 'Verified' -Target $g ` + -Detail "Break-glass group '$($group.displayName)' has $($enabledMembers.Count) enabled member(s) and provides a permanently assigned, tenant-wide Global Administrator role with no expiry ($($roleState.RoleVia))." + } + else { + Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $g ` + -Detail "Break-glass group '$($group.displayName)' has enabled members but neither the group nor any member holds a permanently assigned, tenant-wide Global Administrator role with no expiry. Assign Global Administrator permanently, not through PIM eligibility or temporary activation, before deploying." + throw "Break-glass group $g provides no permanently-assigned Global Administrator." + } +} + +if ($userIds.Count -eq 0 -and $groupIds.Count -eq 0) { + if ($bg.CreateAccountIfMissing -and + (Test-EntraSecurityDefaultsWriteAllowed -BaseUri $v1 -Module $module -BestPracticeKey $bestPracticeKey)) { + $identity = $Context.ConnectionInfo.TenantIdentity + $domain = if ($identity -and $identity.ExpectedDomain) { $identity.ExpectedDomain } + elseif ($Context.TenantAdminUpn -match '@(?[^@]+)$') { $Matches.d } else { $null } + if (-not $domain) { throw 'Unable to derive a tenant domain for the break-glass account UPN.' } + $upn = "$($bg.AccountUpnPrefix)@$domain" + + if ($PSCmdlet.ShouldProcess($upn, 'Create cloud-only break-glass account')) { + # A random, strong password that is never emitted to evidence; the + # operator resets and stores it out of band (see end-user guide). + $password = ([guid]::NewGuid().ToString('N') + 'Aa1!') + $body = @{ + accountEnabled = $true + displayName = $bg.AccountDisplayName + mailNickname = $bg.AccountUpnPrefix + userPrincipalName = $upn + passwordProfile = @{ forceChangePasswordNextSignIn = $false; password = $password } + } | ConvertTo-Json -Depth 5 + $created = Invoke-WithTransientRetry -Description 'Create break-glass account' -Action { + Invoke-MgGraphRequest -Method POST -Uri "$v1/users" -Body $body -ContentType 'application/json' + } + $password = $null + $userIds.Add([string] $created.id) + Add-EntraRunLogEntry -Module $module -Action 'CreateBreakGlass' -BestPracticeKey $bestPracticeKey ` + -Status 'Created' -Disposition 'Applicable' -Target $upn ` + -Detail 'Created a cloud-only break-glass account. It has no admin role yet: assign it Global Administrator, reset its password in the portal, and store the credentials securely. The next run confirms the role; the account is excluded from every Conditional Access policy automatically.' + } + else { + Add-EntraRunLogEntry -Module $module -Action 'CreateBreakGlass' -BestPracticeKey $bestPracticeKey ` + -Status 'Info' -Disposition 'WillChange' -Target $upn -Readback 'NotAttempted' ` + -Detail "WhatIf: would create a cloud-only break-glass account '$upn' and exclude it from every Conditional Access policy." + } + } + elseif (-not $bg.CreateAccountIfMissing) { + Add-EntraRunLogEntry -Module $module -Action 'BreakGlass' -BestPracticeKey $bestPracticeKey ` + -Status 'Succeeded' -Disposition 'GuidedOnly' ` + -Detail 'No emergency-access account is configured. Create one (Identity guide Priority 1), set ConditionalAccess.BreakGlass.ExcludeUserIds/ExcludeGroupIds, or set CreateAccountIfMissing. The Conditional Access baseline will not write until a break-glass exclusion exists.' + } +} + +# Local evidence output, not a tenant change; never suppressed under -WhatIf. +@{ userIds = @($userIds); groupIds = @($groupIds) } | ConvertTo-Json | + Set-Content -LiteralPath $Context.BreakGlassOutputPath -Encoding utf8 -WhatIf:$false diff --git a/Products/Entra/Modules/Setup-TenantSecuritySettings.ps1 b/Products/Entra/Modules/Setup-TenantSecuritySettings.ps1 new file mode 100644 index 0000000..ac46700 --- /dev/null +++ b/Products/Entra/Modules/Setup-TenantSecuritySettings.ps1 @@ -0,0 +1,226 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Tenant-wide identity hardening (Zero Trust "Configure Microsoft Entra for + increased security"). + +.DESCRIPTION + Applies the Business-Premium-appropriate, GA Graph settings from the Zero + Trust configuration guidance: restrict user consent, enable the admin + consent workflow, restrict guest access, and disable password expiration. + + Each setting is opt-in via TenantSecurity..Apply. When Apply is + $false the module reads the current value and reports it against the + recommendation (GuidedOnly) without changing anything. Every write is + idempotent (read-before-write), gated by ShouldProcess, wrapped in the shared + retry boundary, and read back. Settings that conflict with Conditional Access + (security defaults) or require higher licenses are documented in Coverage.md, + not applied here. +#> + +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +param( + [Parameter(Mandatory)] [hashtable] $Config, + [hashtable] $Context, + [switch] $AdoptExisting +) + +$ErrorActionPreference = 'Stop' +$ConfirmPreference = 'None' + +. (Join-Path $PSScriptRoot 'EntraRunLog.ps1') +. (Join-Path $PSScriptRoot 'EntraGraphClient.ps1') +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') +. (Join-Path $PSScriptRoot 'EntraSecurityDefaults.ps1') + +$module = 'Setup-TenantSecuritySettings' +$bestPracticeKey = 'tenant-security-settings' + +if ($Context -and @($Context.BlockedItemKeys) -contains $bestPracticeKey) { + Add-EntraRunLogEntry -Module $module -Action 'TenantSecurity' -BestPracticeKey $bestPracticeKey ` + -Status 'Skipped' -Disposition 'Blocked' -Detail 'Tenant security settings were withheld by preflight or a gate.' + return +} + +if (-not $Context -or [string]::IsNullOrWhiteSpace([string] $Context.TenantAdminUpn)) { + throw 'Setup-TenantSecuritySettings.ps1 requires Context.TenantAdminUpn and Graph connection settings.' +} + +$v1 = $Config.Api.GraphBaseUri +$ts = $Config.TenantSecurity + +function Set-EntraTenantSetting { + param( + [Parameter(Mandatory)] [string] $Action, + [Parameter(Mandatory)] [bool] $Apply, + [Parameter(Mandatory)] [bool] $AlreadyCompliant, + [Parameter(Mandatory)] [string] $CompliantDetail, + [Parameter(Mandatory)] [string] $DriftDetail, + [Parameter(Mandatory)] [scriptblock] $WriteAction, + [Parameter(Mandatory)] [scriptblock] $Readback + ) + if ($AlreadyCompliant) { + Add-EntraRunLogEntry -Module $module -Action $Action -BestPracticeKey $bestPracticeKey ` + -Status 'Skipped' -Disposition 'AlreadyCompliant' -Readback 'Verified' -Detail $CompliantDetail + return + } + if (-not $Apply) { + Add-EntraRunLogEntry -Module $module -Action $Action -BestPracticeKey $bestPracticeKey ` + -Status 'Succeeded' -Disposition 'GuidedOnly' -Detail "Assessment only (Apply=`$false): $DriftDetail" + return + } + if (-not (Test-EntraSecurityDefaultsWriteAllowed -BaseUri $v1 -Module $module -BestPracticeKey $bestPracticeKey)) { + return + } + if (-not $PSCmdlet.ShouldProcess($Action, 'Apply tenant security setting')) { + Add-EntraRunLogEntry -Module $module -Action $Action -BestPracticeKey $bestPracticeKey ` + -Status 'Info' -Disposition 'WillChange' -Readback 'NotAttempted' -Detail "WhatIf: would apply. $DriftDetail" + return + } + try { + Invoke-WithTransientRetry -Description "Apply $Action" -Action $WriteAction + Add-EntraRunLogEntry -Module $module -Action $Action -BestPracticeKey $bestPracticeKey ` + -Status 'Updated' -Disposition 'Applicable' -Detail "Applied. $DriftDetail" + } + catch { + Add-EntraRunLogEntry -Module $module -Action $Action -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Applicable' -HttpStatusCode (Get-EntraHttpStatusCode -ErrorRecord $_) ` + -Detail "Failed to apply ${Action}: $($_.Exception.Message)" + throw + } + $verdict = $null + try { + $verdict = & $Readback + } + catch { + Add-EntraRunLogEntry -Module $module -Action "$Action.Readback" -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'NotAttempted' -Detail "Read-back could not confirm ${Action}; stopping to report the unconfirmed change: $($_.Exception.Message)" + throw + } + if (-not $verdict) { + Add-EntraRunLogEntry -Module $module -Action "$Action.Readback" -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Detail "Read-back did not confirm ${Action}; stopping so the discrepancy can be reviewed." + throw "Tenant security setting '$Action' could not be confirmed after write." + } + Add-EntraRunLogEntry -Module $module -Action "$Action.Readback" -BestPracticeKey $bestPracticeKey ` + -Status 'Info' -Disposition 'Applicable' -Readback 'Verified' -Detail "Read-back confirmed ${Action}." +} + +# --- User consent + guest access live on authorizationPolicy --------------- +$authPolicy = Invoke-WithTransientRetry -Description 'Read authorizationPolicy' -Action { + Invoke-MgGraphRequest -Method GET -Uri "$v1/policies/authorizationPolicy" +} + +$uc = $ts.UserConsent +$currentGrants = @($authPolicy.defaultUserRolePermissions.permissionGrantPoliciesAssigned) +$desiredGrants = @($uc.PermissionGrantPoliciesAssigned) +Set-EntraTenantSetting -Action 'UserConsent' -Apply ([bool] $uc.Apply) ` + -AlreadyCompliant (($currentGrants -join ',') -eq ($desiredGrants -join ',')) ` + -CompliantDetail "User consent already restricted to: $($desiredGrants -join ', ')." ` + -DriftDetail "Restrict user app consent to: $($desiredGrants -join ', ') (currently: $($currentGrants -join ', '))." ` + -WriteAction { + # Read-modify-write: preserve every other user-role permission the + # customer has set and change only the consent-grant assignment. + $current = $authPolicy.defaultUserRolePermissions + $perms = @{} + if ($current -is [System.Collections.IDictionary]) { + foreach ($k in @($current.Keys)) { $perms[$k] = $current[$k] } + } + elseif ($current) { + foreach ($p in $current.PSObject.Properties) { $perms[$p.Name] = $p.Value } + } + $perms['permissionGrantPoliciesAssigned'] = $desiredGrants + $body = @{ defaultUserRolePermissions = $perms } | ConvertTo-Json -Depth 6 + Invoke-MgGraphRequest -Method PATCH -Uri "$v1/policies/authorizationPolicy" -Body $body -ContentType 'application/json' | Out-Null + } ` + -Readback { + $after = Invoke-MgGraphRequest -Method GET -Uri "$v1/policies/authorizationPolicy" + (@($after.defaultUserRolePermissions.permissionGrantPoliciesAssigned) -join ',') -eq ($desiredGrants -join ',') + } + +$ga = $ts.GuestAccess +Set-EntraTenantSetting -Action 'GuestAccess' -Apply ([bool] $ga.Apply) ` + -AlreadyCompliant (($authPolicy.allowInvitesFrom -eq $ga.AllowInvitesFrom) -and ([string] $authPolicy.guestUserRoleId -eq [string] $ga.GuestUserRoleId)) ` + -CompliantDetail "Guest access already restricted (invitesFrom=$($authPolicy.allowInvitesFrom))." ` + -DriftDetail "Set allowInvitesFrom=$($ga.AllowInvitesFrom) and guestUserRoleId to the restricted role (currently invitesFrom=$($authPolicy.allowInvitesFrom))." ` + -WriteAction { + $body = @{ allowInvitesFrom = $ga.AllowInvitesFrom; guestUserRoleId = $ga.GuestUserRoleId } | ConvertTo-Json + Invoke-MgGraphRequest -Method PATCH -Uri "$v1/policies/authorizationPolicy" -Body $body -ContentType 'application/json' | Out-Null + } ` + -Readback { + $after = Invoke-MgGraphRequest -Method GET -Uri "$v1/policies/authorizationPolicy" + ($after.allowInvitesFrom -eq $ga.AllowInvitesFrom) -and ([string] $after.guestUserRoleId -eq [string] $ga.GuestUserRoleId) + } + +# --- Admin consent workflow ----------------------------------------------- +$acw = $ts.AdminConsentWorkflow +$acwPolicy = Invoke-WithTransientRetry -Description 'Read adminConsentRequestPolicy' -Action { + Invoke-MgGraphRequest -Method GET -Uri "$v1/policies/adminConsentRequestPolicy" +} +if ($acw.Apply -and $acw.IsEnabled -and @($acw.ReviewerGroupIds).Count -eq 0) { + Add-EntraRunLogEntry -Module $module -Action 'AdminConsentWorkflow' -BestPracticeKey $bestPracticeKey ` + -Status 'Succeeded' -Disposition 'GuidedOnly' ` + -Detail 'Cannot enable the admin consent workflow without reviewers. Set TenantSecurity.AdminConsentWorkflow.ReviewerGroupIds to at least one group, then re-run.' +} +else { + Set-EntraTenantSetting -Action 'AdminConsentWorkflow' -Apply ([bool] $acw.Apply) ` + -AlreadyCompliant ([bool] $acwPolicy.isEnabled -eq [bool] $acw.IsEnabled) ` + -CompliantDetail "Admin consent workflow already isEnabled=$($acwPolicy.isEnabled)." ` + -DriftDetail "Set admin consent workflow isEnabled=$($acw.IsEnabled) with $(@($acw.ReviewerGroupIds).Count) reviewer group(s)." ` + -WriteAction { + # Preserve the customer's existing workflow settings and merge our + # reviewer group(s) with any already configured, rather than + # replacing the whole policy. + $existingReviewers = @($acwPolicy.reviewers) + $newReviewers = @(@($acw.ReviewerGroupIds) | ForEach-Object { + @{ query = "/groups/$_"; queryType = 'MicrosoftGraph'; queryRoot = $null } + }) + $mergedReviewers = @(($existingReviewers + $newReviewers) | Where-Object { $_ } | + Group-Object { [string] $_.query } | ForEach-Object { $_.Group[0] }) + $body = @{ + isEnabled = [bool] $acw.IsEnabled + notifyReviewers = $(if ($null -ne $acwPolicy.notifyReviewers) { [bool] $acwPolicy.notifyReviewers } else { $true }) + remindersEnabled = $(if ($null -ne $acwPolicy.remindersEnabled) { [bool] $acwPolicy.remindersEnabled } else { $true }) + requestDurationInDays = $(if ($acwPolicy.requestDurationInDays) { $acwPolicy.requestDurationInDays } else { 30 }) + reviewers = $mergedReviewers + } | ConvertTo-Json -Depth 6 + Invoke-MgGraphRequest -Method PUT -Uri "$v1/policies/adminConsentRequestPolicy" -Body $body -ContentType 'application/json' | Out-Null + } ` + -Readback { + $after = Invoke-MgGraphRequest -Method GET -Uri "$v1/policies/adminConsentRequestPolicy" + [bool] $after.isEnabled -eq [bool] $acw.IsEnabled + } +} + +# --- Password expiration on the primary verified domain -------------------- +$pe = $ts.PasswordExpiration +$neverExpire = 2147483647 +$domains = Invoke-WithTransientRetry -Description 'Read domains' -Action { + Invoke-MgGraphRequest -Method GET -Uri "$v1/domains" +} +$primary = @($domains.value | Where-Object { $_.isDefault }) | Select-Object -First 1 +if (-not $primary) { $primary = @($domains.value | Where-Object { $_.isInitial }) | Select-Object -First 1 } +if ($primary) { + $current = $primary.passwordValidityPeriodInDays + if (-not $pe.DisableExpiration) { + # This toolkit only manages *disabling* expiration; with DisableExpiration + # false it leaves the customer's password expiration untouched. + Add-EntraRunLogEntry -Module $module -Action 'PasswordExpiration' -BestPracticeKey $bestPracticeKey ` + -Status 'Succeeded' -Disposition 'GuidedOnly' ` + -Detail "PasswordExpiration.DisableExpiration is false; leaving password expiration on $($primary.id) unchanged (current validity=$current days)." + } + else { + Set-EntraTenantSetting -Action 'PasswordExpiration' -Apply ([bool] $pe.Apply) ` + -AlreadyCompliant ($current -eq $neverExpire) ` + -CompliantDetail "Password expiration already disabled on $($primary.id)." ` + -DriftDetail "Disable password expiration on domain $($primary.id) (current validity=$current days)." ` + -WriteAction { + $body = @{ passwordValidityPeriodInDays = $neverExpire; passwordNotificationWindowInDays = 14 } | ConvertTo-Json + Invoke-MgGraphRequest -Method PATCH -Uri "$v1/domains/$($primary.id)" -Body $body -ContentType 'application/json' | Out-Null + } ` + -Readback { + $after = Invoke-MgGraphRequest -Method GET -Uri "$v1/domains/$($primary.id)" + $after.passwordValidityPeriodInDays -eq $neverExpire + } + } +} diff --git a/Products/Entra/Modules/Write-EntraHtmlReport.ps1 b/Products/Entra/Modules/Write-EntraHtmlReport.ps1 new file mode 100644 index 0000000..dba1bd7 --- /dev/null +++ b/Products/Entra/Modules/Write-EntraHtmlReport.ps1 @@ -0,0 +1,149 @@ +#requires -Version 7.0 +# Get-EntraModuleVerdict and the identity redaction helpers live in +# EntraRunLog.ps1 so the JSON sidecar and this report use the same verdict and +# privacy rules. Callers dot-source that file before invoking this function. +function Write-EntraHtmlReport { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Path, + [Parameter(Mandatory)] [hashtable[]] $Entries, + [guid] $RunId, + [datetime] $StartTime, + [datetime] $EndTime = [datetime]::UtcNow, + [string] $TenantId, + [string] $TenantAdminUpn, + [string] $ScriptVersion = 'unknown', + [switch] $WhatIfRun + ) + + function Encode([object] $Value) { + if ($null -eq $Value) { return '' } + return [System.Net.WebUtility]::HtmlEncode([string] $Value) + } + + function Get-StatusClass([string] $Status) { + switch ($Status) { + 'Failed' { return 'failed' } + 'Skipped' { return 'skipped' } + 'Retried' { return 'retried' } + 'Succeeded' { return 'succeeded' } + 'Created' { return 'succeeded' } + 'Updated' { return 'succeeded' } + 'Adopted' { return 'succeeded' } + default { return 'info' } + } + } + + try { + $failed = @($Entries | Where-Object Status -eq 'Failed').Count + $skipped = @($Entries | Where-Object Status -eq 'Skipped').Count + $guided = @($Entries | Where-Object Disposition -eq 'GuidedOnly').Count + $succeeded = @($Entries | Where-Object Status -in @('Succeeded','Created','Updated','Adopted')).Count + $duration = if ($StartTime) { [int] (($EndTime - $StartTime).TotalSeconds) } else { $null } + $startText = if ($StartTime) { $StartTime.ToString('o') } else { $null } + $modeBanner = if ($WhatIfRun) { + '' + } + else { + '' + } + + # The operator report requires a final summary showing + # each module as OK, FAILED, SKIPPED, or BLOCKED. Entry counts alone do + # not satisfy it: an operator scanning a long run needs the per-module + # verdict without reading every row. + $moduleRows = foreach ($group in ($Entries | Group-Object Module | Sort-Object Name)) { + $items = @($group.Group) + $verdict = Get-EntraModuleVerdict -Entries $items + $verdictClass = switch ($verdict) { + 'FAILED' { 'failed' } + 'BLOCKED' { 'skipped' } + 'SKIPPED' { 'skipped' } + default { 'succeeded' } + } + '{0}{1}{2}' -f ` + (Encode $group.Name), + ('{1}' -f $verdictClass, $verdict), + $items.Count + } + + $rows = foreach ($entry in $Entries) { + $statusClass = Get-StatusClass $entry.Status + '{0}{1}{2}{3}{4}{5}{6}{7}{8}{9}{10}' -f ` + (Encode $entry.Timestamp), (Encode $entry.Module), (Encode $entry.Action), + (Encode $entry.BestPracticeKey), (Encode $entry.Disposition), (Encode $entry.Target), + ('{1}' -f $statusClass, (Encode $entry.Status)), + (Encode $entry.Detail), (Encode $entry.Readback), + (Encode $entry.HttpStatusCode), (Encode $entry.ElapsedMs) + } + + $html = @" + + + + +Entra deployment report + + +
+

Microsoft Entra Best Practice Deployment Report

Read-only and state-changing evidence for one deployment run.

+
$modeBanner

Run metadata

+
Run ID
$(Encode $RunId)
+
Script version
$(Encode $ScriptVersion)
+
Tenant ID
$(Encode (Protect-EntraTenantId -Value $TenantId))
+
Tenant administrator
$(Encode (Protect-EntraIdentity -Value $TenantAdminUpn))
+
Started
$(Encode $startText)
+
Ended
$(Encode $EndTime.ToString('o'))
+
Duration (seconds)
$(Encode $duration)
+
+

Evidence summary

+
$($Entries.Count)Total entries
+
$succeededSuccessful/change entries
+
$skippedSkipped
+
$guidedGuided only
+
$failedFailed
+
+

Module summary

+ +$($moduleRows -join [Environment]::NewLine) +
ModuleResultEntries
+

Decision-point evidence

+ +$($rows -join [Environment]::NewLine) +
TimestampModuleActionBP keyDispositionTargetStatusDetail / reasonReadbackHTTPElapsed ms
+
+"@ + # Evidence output is local, not a tenant change; never suppress it under + # -WhatIf. A dry run must still produce its report. + Set-Content -LiteralPath $Path -Value $html -Encoding utf8 -WhatIf:$false + } + catch { + throw "Unable to write Entra HTML report '$Path': $($_.Exception.Message)" + } +} diff --git a/Products/Entra/README.md b/Products/Entra/README.md new file mode 100644 index 0000000..f1c74ad --- /dev/null +++ b/Products/Entra/README.md @@ -0,0 +1,409 @@ +--- +title: Microsoft Entra +layout: default +nav_order: 5 +has_children: true +permalink: /entra/ +--- + +# Microsoft Entra Best Practice Toolkit + +> ⚠️ **Write-capable. Pilot first, and keep a break-glass account.** +> This product creates real Conditional Access policies in your tenant. Every +> new policy is **report-only** by default and the default configuration +> requires an emergency-access exclusion. Configuration and existing enforcing +> policies still matter; this is not a lockout guarantee. Security Defaults +> must be verified disabled before any tenant write. The toolkit never disables +> it or performs the transition automatically. + +PowerShell automation that deploys the **Conditional Access baseline** from the +Microsoft **Identity Protection Best Practice Deployment** guide for a Microsoft +365 **Business Premium** tenant (Microsoft Entra ID P1). Built to the same +operator contract as the Purview and Intune toolkits: configuration-driven, +`-WhatIf`/`ShouldProcess` on every write, a shared transient-retry boundary, +read-back verification, and structured JSON/HTML evidence. + +## First time here? Start with a preview + +1. Download or clone the approved repository version containing + `Products\Entra`. Keep the entire product folder, including `Modules` and + `Config`, and use the documentation from that same version. +2. Open PowerShell 7 in an interactive desktop terminal and change to the + repository root. Review the + [operator prerequisites](docs/Operator-Guide.md#before-you-run) before sign-in. +3. Replace the sample administrator UPN and run: + +```powershell +cd .\Products\Entra +.\Deploy-EntraBestPractice.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com ` + -WhatIf +``` + +This first preview needs no pilot group or high-risk approval switches. +It reads available tenant state and writes local evidence without changing +tenant configuration. It can still require sign-in and approved Graph consent. +If a required module is missing, approve its installation only if permitted, +or add `-AutoInstallModules` when CurrentUser installation is already approved. + +Use the printed command to open the saved HTML report. The defaults are +`Reports\entra-run-report.html` and `Reports\entra-run-log.json`. Missing +emergency-access configuration can block deployment and make health +`Indeterminate`; that is not proof that the tenant has no emergency accounts. +No tenant action should be `Created`, `Updated`, or `Adopted` in a preview. + +Do not disable Security Defaults or remove `-WhatIf` just to clear a warning. +Follow the [pilot preparation and apply sequence](docs/Operator-Guide.md) +and use [evidence and troubleshooting](docs/Evidence-Troubleshooting.md) to +interpret blocked work or a failed health check. + +## Choose your starting point + +| Need | Guide | +|---|---| +| Run the first preview, prepare a pilot, or apply an approved change | [Operator guide](docs/Operator-Guide.md) | +| Understand the policies and their user impact | [Scenarios](docs/Scenarios.md) | +| Interpret a report or recover from a failure | [Evidence and troubleshooting](docs/Evidence-Troubleshooting.md) | +| Configure and test emergency access | [Break-glass guide](docs/Break-Glass-Guide.md) | +| Plan Security Defaults transition or app-protection migration | [Transition guide](docs/Security-Defaults-Transition.md) | +| Check licensing, API boundaries, and remaining gaps | [Coverage](docs/Coverage.md) | + +## What it deploys + +Twelve P1 Conditional Access policies: the ten guide templates plus device-code +blocking and security-information registration protection. Each is created in the state configured by +`ConditionalAccess.DefaultState` (report-only by default): + +| Policy | Recommended target | +|---|---| +| Require MFA for admins | On | +| Block legacy authentication | On | +| Require MFA for all users | On | +| Require MFA for guest access | On | +| Require MFA for Azure management | On | +| Require MFA for Microsoft admin portals | On | +| Block access for unknown/unsupported device platform | On | +| No persistent browser session | Report-only | +| Require app protection policy | Report-only | +| Require compliant/hybrid device or MFA for all users | Report-only | +| Block device code flow | Report-only, review device-code dependencies before enforcement | +| Protect security information registration | Report-only, verify combined registration and MFA bootstrap | + +The optional **phishing-resistant MFA for privileged/admin roles** policy is +also P1, but is not selected by default. Set its policy reference's `Enabled` +to `$true` in your private config after reviewing method readiness. It targets +the configured directory roles, not the pilot group, and defaults to +report-only. SMS, voice and ordinary MFA do not satisfy this strength. + +P2 / Identity Protection risk policies are **not deployed**. Console output +distinguishes `P1Baseline`, `P1Hardened` and the P2 guidance boundary. See +[coverage, prerequisites and current Microsoft references](docs/Coverage.md#p1-additions-and-p2-boundary). + +Plus **emergency access (break-glass)** handling (guide Priority 1): the toolkit +verifies a configured break-glass account/group, or creates a dedicated +cloud-only one, and excludes it from every policy. + +The **"Require MFA for Intune enrollment"** Conditional Access policy is +intentionally **not** part of this product — it belongs to the Intune toolkit, +which owns device-enrollment Conditional Access. + +## Safety model + +- **Security Defaults gate.** Enabled or unknown means assessment-only across + every tenant writer, including emergency-account creation and opted-in + tenant settings. `Policy.Read.All` reads the state before setup and again at + selected write boundaries. See the + [coverage and transition guide](docs/Security-Defaults-Transition.md). +- **Report-only by default.** `ConditionalAccess.DefaultState` is + `enabledForReportingButNotEnforced`. Nothing is enforced until you promote it. +- **Break-glass is mandatory.** `RequireBreakGlassExclusion` is `true`; the + baseline refuses to create any policy until an emergency-access exclusion + exists, and it injects that exclusion into every policy. See the + [break-glass guide](docs/Break-Glass-Guide.md). +- **High-risk gates.** The Conditional Access write requires `-IncludeHighRisk` + (with `-CustomerApprovalId`), `-BreakGlassExclusionsConfirmed`, and + `-RollbackAcknowledged`. Without them the module reports what it would create + and changes nothing. +- **Pilot scope by default.** All-users-style policies are scoped to a + `-PilotGroupId`; tenant-wide is an explicit `-AssignTenantWide` opt-in. + The admin MFA and admin-portal policies target their configured directory + roles across the tenant. Azure-management MFA targets all users for that + application. Guest MFA targets all guests and external users acting in this + tenant, not workforce members of the pilot group. These policies are not + capped by the pilot group; review their + scope and emergency exclusions before creating or enforcing them. +- **Existing policies.** Current and configured legacy names are matched + across all result pages; ambiguous matches block creation. The app-protection + policy is review-only when present, including with `-AdoptExisting`. + It is not silently repaired, renamed or duplicated. + +The corrected guest MFA, admin MFA, admin-portal, Azure-management, device-or-MFA and +browser-session policies also require `ReviewExistingOnly = $true` in custom +configuration. Existing matches remain unchanged and require manual review, +including with `-AdoptExisting`. This avoids silently changing the coverage or +grant requirements of an enforcing policy. The device-or-MFA template uses +an OR between compliant device, hybrid-joined device and MFA, not a requirement +to satisfy all three. + +Creation readback checks the compared grants, targeting and emergency +exclusions as well as state. For browser-session policies it also checks the +configured session controls and device filter. Missing or changed fields are +reported as a mismatch, not successful verification. Existing authentication +strength policies remain manual-review only; ordinary MFA is not substituted +for phishing-resistant authentication. + +Guest MFA uses the documented `includeUsers = ["GuestsOrExternalUsers"]` +selector without group or role includes. It remains report-only by default. +The toolkit does not combine a guest selector with the pilot group, because +Conditional Access includes are a union, not a guest/group intersection. +Existing group-scoped guest policies are left unchanged for manual migration +review, including when they are enforcing or `-AdoptExisting` is supplied. + +These templates are not a proven one-for-one replacement for Security +Defaults. Protecting registration does not enroll everyone, and report-only +device-code blocking does not block sign-ins. Both need explicit coverage +review. Do not disable active protection and leave only report-only +policies in its place. Use an approved transition with verified active +replacement controls, not the toolkit's first preview, for cutover. + +New app-protection policies use `compliantApplication` and include Android/iOS +without excluding those same platforms. They require compatible apps and +assigned Intune app-protection policies. The legacy key/file +`require-approved-client-apps` is retained for configuration compatibility. +Use the [migration procedure](docs/Security-Defaults-Transition.md#corrected-app-protection-policy-and-existing-tenants) +for existing policies and update custom policy references to the current +`LegacyDisplayNames` / `ReviewExistingOnly` fields. + +## Authentication + +Entra uses the same delegated Microsoft Graph sign-in behavior as Purview. +Supply the administrator UPN for a direct tenant run, or add the customer +tenant's verified domain through `-DelegatedOrganization` for GDAP: + +```powershell +.\Deploy-EntraBestPractice.ps1 ` + -TenantAdminUpn delegatedadmin@partner.onmicrosoft.com ` + -DelegatedOrganization customer.onmicrosoft.com ` + -WhatIf +``` + +The orchestrator connects once before any setup module runs. It requests the +delegated scopes in `Config\EntraConfig.psd1`, reuses a cached Graph context +only when the account and every required scope match, and verifies the live +tenant through `/organization`. For GDAP, it authenticates directly to the +customer domain and rejects a cached context for another customer. + +Current Microsoft Graph Authentication modules use Windows Authentication +Manager (WAM) on a supported interactive Windows desktop. A first consent, +missing scope, account change, or customer-tenant switch can still require a +browser prompt. Use `-AutoInstallModules` to install a missing Graph +authentication module to `CurrentUser` without a toolkit prompt. + +`-NonInteractive` suppresses toolkit confirmation and module-install prompts. +It does not select application authentication and cannot guarantee a +prompt-free first sign-in. Pre-authenticate in an approved interactive session +before using it. Client IDs, certificates, and client secrets are not accepted. + +Setup modules use the orchestrator's verified context and do not reconnect. To +run a setup module directly, establish the Graph connection first in the same +PowerShell session. + +## Usage + +Start in `Products\Entra` in an interactive PowerShell 7 terminal: + +```powershell +# Preview: no tenant configuration writes; sign-in and local reports still run. +.\Deploy-EntraBestPractice.ps1 -TenantAdminUpn admin@contoso.onmicrosoft.com -WhatIf +``` + +The first preview prints all missing Conditional Access apply parameters before +sign-in, even when no break-glass IDs are configured. Removing `-WhatIf` alone +does not authorize Conditional Access deployment. + +| Apply prerequisite | What to supply or verify | +|---|---| +| Private configuration | Copy the complete `Config\EntraConfig.psd1` outside the repository, then supply `-ConfigPath`. Keep `DefaultState` report-only and tenant-setting `Apply` flags false unless separately approved. | +| Emergency access | Configure verified `ConditionalAccess.BreakGlass.ExcludeUserIds` or `ExcludeGroupIds`. Independently test recovery and keep the emergency-access verification module selected. | +| Change approval | `-IncludeHighRisk -CustomerApprovalId ''`. Only a hash of the reference enters evidence. | +| Safety confirmations | `-BreakGlassExclusionsConfirmed -RollbackAcknowledged`. These record operator decisions; they do not configure recovery for you. | +| Pilot scope | `-PilotGroupId ''`. Role-, guest- and app-scoped templates retain their configured targeting; the pilot is not an intersection with privileged roles or guest users. | +| Live readiness | Verified tenant identity, effective Graph scopes/roles, P1 entitlement and disabled Security Defaults. Disabled is not proof of active replacement protection. | + +After an approved Security Defaults transition, or independent confirmation of +an already-disabled tenant's protection, preview the exact intended write: + +```powershell +# Later pilot preview: only after an approved Security Defaults transition +# or independent confirmation of an already-disabled tenant's protection. +# Use a complete private config with verified existing break-glass IDs, +# report-only DefaultState, and tenant-setting Apply flags still false. +.\Deploy-EntraBestPractice.ps1 -TenantAdminUpn admin@contoso.onmicrosoft.com ` + -ConfigPath '' ` + -IncludeHighRisk -CustomerApprovalId 'CHG0001234' ` + -BreakGlassExclusionsConfirmed -RollbackAcknowledged ` + -PilotGroupId '' -WhatIf +``` + +Read the reports before a separately approved apply. Disabled Security +Defaults passes one gate, not all safety or authorization checks. The state +is never automatically changed, and there is no bypass switch. + +The final output prints the full paths of successfully saved HTML and JSON +reports and a copyable `Invoke-Item -LiteralPath` command to open the HTML. +This also runs for `-WhatIf`, blocked work, and failures after logging has +initialized. The browser never opens automatically. A report-write warning +means that file was not confirmed saved; an older file at the same path is +not evidence of the current run. + +The default files are `Reports\entra-run-report.html` and +`Reports\entra-run-log.json`, relative to `Deploy-EntraBestPractice.ps1`. +Override them in the private configuration's `Report` section if needed. +Reruns overwrite these filenames, so preserve each run's evidence in an +approved private location before rerunning. Do not commit tenant reports. + +For that approved apply, run the same command with the same private config, +parameters and tenant, omitting only `-WhatIf`. Creating report-only policies +and promoting them to enforcement are separate changes. `-NonInteractive` +does not waive any approval or readiness requirement. + +Tenant-wide assignment is not the default: it requires `-AssignTenantWide`, +`-RollbackAcknowledged` and +`Assignment.AllowTenantWideAssignmentForHighRisk = $true` in configuration. +Do not use it merely to avoid preparing a pilot group. + +### Reading the console + +Numbered stages show prerequisites, tenant discovery/Security Defaults, +emergency access, Conditional Access assessment/deployment, optional tenant +settings, read-only validation and the final summary. The CA stage separates +existing-policy discovery, P1 baseline and optional P1 hardening. + +Information is cyan, successful operations and verified reads are green, +warnings/blocked/guided-only/retry outcomes are yellow, failures are red and +skips are gray. Text status/disposition labels remain visible without color. +`VALIDATED` means the recorded readback check passed, not full tenant security +validation. Stages and module results also appear in the HTML/JSON evidence. +The summary uses `OK`, `FAILED`, `SKIPPED` and `BLOCKED`; a completed process +does not mean every requested operation was applied. + +### Policy naming and selection + +Existing names remain the default. For new policies, set +`ConditionalAccess.DisplayNamePrefix = 'SMB-CA-'` in your complete private +configuration. Each reference supplies a stable tier, sequence and scope +`NameCode`, producing names such as +`SMB-CA-P1-11-Users-Block device code flow`. + +Naming is descriptive, not an assignment or licensing gate. The purpose +comes from the template; actual scope is determined by its conditions and the +approved run parameters. The toolkit recognizes both prefixed and original +names and does not rename existing policies. Multiple matches block the run. +If you later change or remove a prefix, retain the old value in +`PreviousDisplayNamePrefixes`; for a changed `NameCode` or manual rename, add +the exact previous full name to that reference's `LegacyDisplayNames`. + +Policy references can set Boolean `Enabled = $false` to exclude an item from +deployment and the selected-policy health inventory. This does not disable or +delete a tenant policy. Existing enforcing policies are still checked for +emergency-access risk. The new device-code, registration and hardened-admin +references require `ReviewExistingOnly = $true`: existing matches are assessed +without automatic migration, including with `-AdoptExisting`. +Even when compared fields match, these policies report `GuidedOnly` manual +review rather than verified compliance. Matching fields do not establish +application, registration or authentication-method readiness. + +## Deployment health check + +After earlier tasks complete, the run finishes with a **read-only health +check** for Security Defaults readiness and emergency-access drift. It issues GET requests +only, changes nothing, and is safe to run on a schedule. Skip it with +`-SkipDeploymentHealth`. + +The check exists mainly to catch one thing: **break-glass exclusion drift**. +Every policy the toolkit creates excludes your emergency-access principals. If +someone later edits a policy and removes that exclusion, nothing appears broken +while the policy is report-only, and then the tenant locks every administrator +out the moment that policy is promoted to enforcing. + +It reports a verdict rather than stopping the run: + +| Verdict | Meaning | +|---|---| +| `Healthy` | Security Defaults is verified disabled, no legacy migration remains, and the implemented policy-presence, state and emergency-access checks passed. This is not a full coverage or sign-in guarantee. | +| `DriftDetected` | A readiness or drift finding needs review, including enabled Security Defaults, a legacy name, missing/duplicate policy, unknown policy state or an exclusion gap. Enabled Security Defaults alone is not a security defect; toolkit writes remain blocked. | +| `LockoutRisk` | Emergency access is compromised right now. An enforcing policy (managed **or** customer-authored) does not exclude a break-glass principal; a break-glass account is disabled, has lost Global Administrator, or cannot be read; or a break-glass group has no enabled member or no longer provides Global Administrator. Act on this first. | +| `Indeterminate` | Security Defaults or an emergency-access control could not be fully evaluated. Never accept an unknown state as `Healthy`. More severe lockout findings retain priority. | + +The summary line carries the counts behind the verdict, for example: + +``` +Verdict=Healthy; ExclusionsEvaluated=True; UnmanagedPoliciesEvaluated=True; +ManagedPoliciesConfigured=12; Present=12; Missing=0; Unevaluable=0; +DuplicateNames=0; Enforcing=0; Disabled=0; UnknownState=0; +EnforcingPoliciesInTenant=0; +BreakGlassExclusionGaps=0; LockoutRisks=0; UnmanagedEnforcingRisks=0; +BreakGlassAccountsDisabled=0; BreakGlassAccountsWithoutRole=0; +BreakGlassGroupsUnusable=0; BreakGlassGroupsWithoutRole=0; +BreakGlassPrincipalsUnreadable=0; Assessment=ReadOnly. +SecurityDefaultsState=Disabled; TenantWritesAllowed=True; LegacyPolicies=0. +``` + +`TenantWritesAllowed` covers the Security Defaults gate only. Enabled or +unknown state adds a blocked disposition; every selected writer independently +checks the live state. A completed run can still contain blocked findings. + +Read `ExclusionsEvaluated` first. When it is `False` no break-glass principal +was available, so every exclusion-related counter is `0` because the check did +not run, not because nothing was found. `EnforcingPoliciesInTenant` is still +counted in that case, and a tenant that is enforcing access with no configured +break-glass principal is reported as a finding in its own right. + +`UnmanagedPoliciesEvaluated=False` means the unmanaged-policy check could not +run. This happens when no break-glass principal is available, or when the +toolkit could not build the complete managed-policy name set and therefore +could not safely distinguish toolkit policies from customer-authored ones. The +overall verdict is `Indeterminate`, never `Healthy`. + +The check fails closed everywhere it can: + +- If the policy list cannot be read, or any page of it fails, no verdict is + produced at all. +- A policy state outside the three documented Conditional Access states is + treated as potentially enforcing rather than assumed report-only. +- A break-glass principal that cannot be read, or whose role cannot be + confirmed, is treated as unavailable rather than assumed intact. +- Enforcing policies the toolkit does **not** manage are checked too, because a + customer-authored policy can lock the tenant out just as easily. Their display + names are customer data and are not written to evidence, so the finding points + at the portal instead. + +### What it does not cover + +The check now includes Security Defaults state, but it does **not** prove +replacement coverage, perform a transition, or verify complete per-policy body +or assignment drift beyond the emergency-access checks. Legacy policy +presence is recorded for migration review, not proof of effective protection. +Treat the verdict as readiness and emergency-access health, not a full tenant +security assessment. + +Break-glass **role** verification (permanently assigned Global Administrator) +runs twice by design: `Setup-EmergencyAccess` verifies it before policy +creation, and the health check verifies it again to catch later drift. The two +modules use the same shared direct-or-group role check. The check reads active +role assignment schedule instances and accepts only tenant-wide assignments +with no expiry. Temporary PIM activations and expiring assignments do not +qualify. Pagination covers role assignments and group memberships. + +## Known gaps (Product Group asks) + +See [docs/Coverage.md](docs/Coverage.md) for the full mapping and the +Product-Group ask list, including: + +- **Registration readiness**: the new policy protects registration with MFA, + but does not enable combined registration, issue Temporary Access Passes, + configure authentication methods or force every user to register. +- **Guest migration readiness**: the guest MFA policy targets guests and + external users across the tenant. An existing group-scoped policy needs + manual migration review; the toolkit does not prove guest MFA enrollment, + cross-tenant trust or sign-in compatibility. diff --git a/Products/Entra/docs/Break-Glass-Guide.md b/Products/Entra/docs/Break-Glass-Guide.md new file mode 100644 index 0000000..7c312ea --- /dev/null +++ b/Products/Entra/docs/Break-Glass-Guide.md @@ -0,0 +1,95 @@ +--- +title: Emergency access (break-glass) guide +parent: Microsoft Entra +layout: default +--- + +# Emergency access (break-glass) accounts + +Conditional Access can deny sign-in to every account, including administrators. +A **break-glass** (emergency access) account provides a recovery path when +normal administration is unavailable. Exclude emergency accounts from +Conditional Access policies that block or restrict sign-in, protect them with +phishing-resistant authentication, and test recovery regularly. No account or +exclusion is a guarantee against lockout. The Identity Protection guide lists +emergency access as a Priority 1 task. + +## How this toolkit uses it + +The Entra toolkit **will not deploy the Conditional Access baseline without a +break-glass exclusion.** You give it one of two ways: + +1. **Use an existing account or group.** Put its object ID in the config: + + ```powershell + ConditionalAccess = @{ + BreakGlass = @{ + ExcludeUserIds = @('') # and/or + ExcludeGroupIds = @('') + } + } + ``` + +2. **Let the toolkit create one.** Set `CreateAccountIfMissing = $true`. The + `Setup-EmergencyAccess` module creates a dedicated **cloud-only** account + (`break-glass-emergency@`) and excludes it from every policy. + +Under the default configuration, every policy the toolkit creates excludes +the break-glass principal, including report-only policies. This is the +toolkit's preparation for later enforcement; Microsoft notes that report-only +policies themselves do not block sign-in. + +This includes device-code blocking, registration protection and the optional +phishing-resistant admin policy. The hardened policy targets privileged roles +rather than intersecting them with the pilot group. Confirm emergency access +and authentication-method readiness before enforcement. The +[apply checklist](../README.md#usage) explains the configuration, approval and +confirmation parameters shown during the first preview. + +Account creation is also withheld while Security Defaults is enabled or +unknown. Existing-account verification and local evidence remain read-only. +Do not disable Security Defaults to provision a test account; follow the +[approved transition and coverage procedure](Security-Defaults-Transition.md). +The script never performs that transition. + +## After a break-glass account is created + +The toolkit sets a random password that it **never writes to logs or evidence** +(so it cannot leak). You must finish the setup by hand: + +1. In the Microsoft Entra admin center, open the account and **reset its + password** to a long, unique value. +2. **Store the credentials securely** (offline/sealed, split knowledge if + possible). Do not store them in the same systems the account protects. +3. Have the authorized identity owner make the Global Administrator role + assignment active and permanent, not merely eligible or temporarily + activated through PIM. The toolkit does not complete role assignment for you. +4. Register a phishing-resistant method such as a FIDO2 security key, with + dependencies different from normal administrator sign-in. Emergency + accounts must satisfy Microsoft's mandatory MFA requirements; do not treat + Conditional Access exclusions as a blanket MFA exemption. +5. Maintain at least two cloud-only emergency accounts on the tenant's + `.onmicrosoft.com` domain, securely store their credentials, monitor every + use, and test sign-in and administrative recovery at least every 90 days. + +These steps follow [Microsoft's emergency-access guidance](https://learn.microsoft.com/entra/identity/role-based-access-control/security-emergency-access), +checked on 2026-09-25. The script's account creation, role checks, and +exclusion evidence do not replace an independently tested recovery procedure. + +Every toolkit run finishes with a read-only deployment health check unless you +use `-SkipDeploymentHealth`. It verifies that the emergency principals remain +enabled, still provide a tenant-wide Global Administrator role through an +active role assignment schedule instance with no expiry, and remain excluded +from enforcing Conditional Access policies. Temporary PIM activations and +expiring assignments do not qualify. Treat `LockoutRisk` as immediate action, +`Indeterminate` as an incomplete safety check that must not be accepted as +healthy, and review the detailed counts in the HTML or JSON run report. + +## Removing a toolkit-created account + +Do not delete an emergency account just because a pilot finished. First have +the identity owner verify and test replacement emergency access, inspect every +policy and role that depends on the old account, and approve its retirement. +Only then remove the exact account and reconcile its exclusions. Preserve +working recovery access throughout; do not remove exclusions from enforcing +policies without the approved replacement plan. diff --git a/Products/Entra/docs/Coverage.md b/Products/Entra/docs/Coverage.md new file mode 100644 index 0000000..95a2309 --- /dev/null +++ b/Products/Entra/docs/Coverage.md @@ -0,0 +1,287 @@ +--- +title: Coverage and Product-Group asks +parent: Microsoft Entra +layout: default +--- + +# Entra Conditional Access: coverage and Product-Group asks + +Source: **Identity Protection Best Practice Deployment** guide (Business Premium / +Microsoft Entra ID P1). All Conditional Access policies fall under the guide's +**Priority 1** task "Set up Conditional Access using built-in templates," +preceded by the Priority 1 task "Create emergency access account(s)." + +## Coverage matrix + +| Guide task | Item | Scripted | State shipped | Guide target | API | +|---|---|---|---|---|---| +| P1 — emergency access | Break-glass exclusion (verify or create) | ✅ | n/a | Exclude from all CA | `POST /users` (GA) | +| P1 — CA templates | Require MFA for admins | ✅ | report-only | On | `POST /identity/conditionalAccess/policies` (GA) | +| P1 — CA templates | Block legacy authentication | ✅ | report-only | On | GA | +| P1 — CA templates | Require MFA for all users | ✅ | report-only | On | GA | +| P1 — CA templates | Require MFA for guest access | ✅ | report-only | On | GA | +| P1 — CA templates | Require MFA for Azure management | ✅ | report-only | On | GA | +| P1 — CA templates | Require MFA for Microsoft admin portals | ✅ | report-only | On | GA | +| P1 — CA templates | Block unknown/unsupported device platform | ✅ | report-only | On | GA | +| P1 — CA templates | No persistent browser session | ✅ | report-only | Report-only | GA | +| P1 - CA templates | Require app protection policy | Yes | report-only | Report-only | GA | +| P1 — CA templates | Require compliant/hybrid device or MFA | ✅ | report-only | Report-only | GA | +| P1 addition | Block device code flow | Yes | report-only | Pilot before enforcement | GA | +| P1 addition | Protect security information registration | Yes | report-only | Pilot before enforcement | GA | +| P1 optional/hardened | Phishing-resistant MFA for admin roles | Yes | Not selected; report-only when selected | Method-readiness review before enforcement | GA | + +Every scripted policy uses the **GA** `v1.0` Conditional Access API and requires +`Policy.ReadWrite.ConditionalAccess` for policy creation. The Security Defaults +read uses the existing `Policy.Read.All` scope. These API scopes do not prove +effective operator roles, licensing or GDAP access. The twelve selected policies +and optional hardened-admin policy have no P2 / Identity Protection dependency +and use no beta-only surface. Authentication strength is available with P1; +risk-based conditions require P2. Neither static configuration labels nor the +toolkit's informational SKU map prove the tenant's actual entitlement. + +## P1 additions and P2 boundary + +Entra 0.4.0 adds two selected P1 templates and one opt-in P1 hardening template. +All use the existing CA writer, approval gates, break-glass exclusions, shared +retry and readback. Every new-scenario reference requires +`ReviewExistingOnly = $true`; existing matches are never silently adopted, +renamed or replaced. Mismatched flow, user-action, strength or target conditions +produce review evidence. Readback checks these fields after creation. +Existing review-only matches remain `GuidedOnly` even when those fields match; +the comparison is not a substitute for the required manual readiness review. + +| Tier | Scenario and payload | Prerequisites and limitations | +|---|---|---| +| `P1Baseline` | Block device code flow: `conditions.authenticationFlows.transferMethods = deviceCodeFlow`, `builtInControls = block`, all resources | Selected by default, pilot-group scoped. Inventory devices, CLI tools and other legitimate device-code clients. Review report-only sign-ins before enforcement and agree necessary exceptions. Does not block every authentication flow. | +| `P1Baseline` | Protect security information registration: `includeUserActions = urn:user:registersecurityinfo`, `builtInControls = mfa` | Selected by default, pilot-group scoped, guests/external users excluded. Requires combined registration and a viable initial MFA/bootstrap method such as an operator-issued Temporary Access Pass. It does not enroll users or issue credentials. There is no implicit trusted-network bypass. | +| `P1Hardened` | Admin phishing-resistant MFA: `authenticationStrength.id = 00000000-0000-0000-0000-000000000004` | Not selected by default. Explicit Boolean `Enabled=true` selects it. Targets the configured directory roles across the tenant, not the pilot group; CA includes are a union, not a role/group intersection. Verify usable phishing-resistant methods for every targeted admin and independently tested emergency access before enforcement. | +| P2 guidance only | Sign-in risk, user risk and stronger authentication triggered by risk | Not deployed by this release. Requires separately verified Entra ID P2 entitlement, risk/remediation design, emergency access and approval. Adding P2 licenses does not automatically opt in or create risk policies. | + +The registration policy requires MFA from every in-scope location. Microsoft's +example also demonstrates excluding trusted locations; this template +deliberately has no such exception. Review your bootstrap path before +enforcement. Registration protection is not a requirement that all users +register, and guest registration needs a separate design. Microsoft also +documents registration-targeted CA evaluation for Windows Hello for Business +and macOS Platform SSO credential registration from July 6, 2026; include those +flows in the pilot. + +Phishing-resistant strength accepts supported passkeys/FIDO2, Windows Hello +for Business/platform credentials and multifactor certificate-based +authentication. Ordinary MFA (for example password plus SMS) is not equivalent. +The template does not combine `mfa` with `authenticationStrength`, an unsupported +grant combination. Enabling methods and registering them remains outside this +writer. + +### Corrected targeting and verification + +The admin and admin-portal MFA templates target only their configured roles. +They do not combine those roles with a pilot-group include. Conditional Access +includes are a union, so that combination would also target non-admin pilot +members rather than limit the roles to the pilot. Azure-management MFA +explicitly targets all users for the configured application. Review these +tenant-wide principal selections even when the run supplies a pilot group. + +Guest MFA uses `includeUsers = ["GuestsOrExternalUsers"]`, the documented +Graph v1.0 selector for guests and external users. It contains no group or +role includes, so a workforce user is not targeted by this policy merely +because they belong to the pilot group. Its scope is all guests and external +users acting in the resource tenant, not the intersection with the pilot. +Report-only remains the creation default and emergency exclusions remain in +place. Other independently applicable policies can still require workforce MFA. + +Guest-policy readback compares the guest selector and rejects unexpected +groups, roles or structured guest exclusions. Existing group-scoped or +otherwise same-named guest policies require manual review, even with +`-AdoptExisting`; their scope, state and customer exceptions are not changed +automatically. Custom references must retain `ReviewExistingOnly = $true`. +Guest MFA registration, cross-tenant trust and representative sign-in behavior +still need an approved pilot before enforcement. + +The device-or-MFA template includes `mfa` in its OR grant controls alongside +`compliantDevice` and `domainJoinedDevice`. Browser-session comparisons check +the managed session settings and device filter, while ignoring unrelated +server response metadata. Every new CA policy is read back for the compared +controls, targeting and exclusions, not just its state. + +Existing matches for these corrected templates require +`ReviewExistingOnly = $true`, including with `-AdoptExisting`. The same +manual-review boundary already applies to the phishing-resistant policy. +Automatic adoption does not change authentication strength or session +controls. Custom configuration must retain these review gates; a missing or +false gate stops execution rather than silently migrating enforcing policies. + +These corrections have offline regression coverage, not new tenant-pilot +evidence. The identity owner must review targeting, MFA readiness and recovery +before enforcement. References checked September 25, 2026: + +- [Conditional Access user and group assignments](https://learn.microsoft.com/entra/identity/conditional-access/concept-conditional-access-users-groups) +- [Graph user selectors, including GuestsOrExternalUsers](https://learn.microsoft.com/graph/api/resources/conditionalaccessusers?view=graph-rest-1.0) +- [Guest and external-user categories](https://learn.microsoft.com/entra/external-id/authentication-conditional-access#assign-conditional-access-policies-to-external-user-types) +- [Grant controls](https://learn.microsoft.com/graph/api/resources/conditionalaccessgrantcontrols?view=graph-rest-1.0) +- [Session controls](https://learn.microsoft.com/graph/api/resources/conditionalaccesssessioncontrols?view=graph-rest-1.0) + +### API and source verification for the additions + +References checked **2026-09-24**. No live tenant validation of the additions +has been performed. + +| Operation | Surface, access and handling | +|---|---| +| Discover existing and read back created policies | Graph v1.0 CA collection/item GET, existing delegated `Policy.Read.All`; complete pagination for discovery, bounded transient retry and bounded 404 retry for propagation after creation | +| Create selected policies | Graph v1.0 `POST /identity/conditionalAccess/policies`, existing `Policy.ReadWrite.ConditionalAccess` plus the configured read scope; verify effective Conditional Access Administrator (or other supported role), consent and GDAP access independently | +| Set authentication flow / user action / strength | Documented v1.0 CA body fields, no new endpoint or permission; strength ID is Microsoft's documented built-in phishing-resistant policy | +| Recovery | Human-approved portal disable/delete or restoration of recorded prior settings; no automatic rollback, rename, method change or Security Defaults transition | + +The identity owner is responsible for method readiness, entitlement, recovery +and pilot sign-off. The product owner and security/permissions reviewer must +review the addition before release. Deterministic fixtures check requests, +readback mismatch, no-write previews, selection and existing-policy preservation; +they do not establish service behavior or effective tenant access. + +Microsoft references: + +- [Block device code flow](https://learn.microsoft.com/entra/identity/conditional-access/policy-block-authentication-flows) +- [Graph authentication-flow condition](https://learn.microsoft.com/graph/api/resources/conditionalaccessauthenticationflows?view=graph-rest-1.0) +- [Protect security information registration](https://learn.microsoft.com/entra/identity/conditional-access/policy-all-users-security-info-registration) +- [Graph application and user-action targets](https://learn.microsoft.com/graph/api/resources/conditionalaccessapplications?view=graph-rest-1.0) +- [Graph user and role targets](https://learn.microsoft.com/graph/api/resources/conditionalaccessusers?view=graph-rest-1.0) +- [Authentication strengths and P1 licensing](https://learn.microsoft.com/entra/identity/authentication/concept-authentication-strengths) +- [Conditional Access and P2 risk-policy licensing](https://learn.microsoft.com/entra/identity/conditional-access/overview#license-requirements) +- [Built-in authentication-strength IDs](https://learn.microsoft.com/graph/api/authenticationstrengthroot-list-policies?view=graph-rest-1.0) +- [Create Conditional Access policies](https://learn.microsoft.com/graph/api/conditionalaccessroot-post-policies?view=graph-rest-1.0) + +## Tenant security settings (Zero Trust) + +`Setup-TenantSecuritySettings` covers the tenant-wide identity hardening from the +Microsoft Learn Zero Trust guidance +[*Configure Microsoft Entra ID for increased security*](https://learn.microsoft.com/entra/fundamentals/concept-secure-remote-workers). +Every item is **opt-in** via its `Apply` flag; while `Apply` is `$false` the +module compares current vs recommended state and reports `GuidedOnly` without +changing anything. Each write uses a **GA** `v1.0` Graph API. + +| Zero Trust setting | Scripted | Default | Recommended | API | +|---|---|---|---|---| +| Restrict user consent to apps (verified publishers / low-impact) | ✅ | GuidedOnly | `managePermissionGrantsForSelf.microsoft-user-default-low` | `PATCH /policies/authorizationPolicy` (GA) | +| Admin consent request workflow | ✅ | GuidedOnly | Enabled with reviewers | `PUT /policies/adminConsentRequestPolicy` (GA) | +| Restrict guest access defaults | ✅ | GuidedOnly | `adminsAndGuestInviters` + restricted guest role | `PATCH /policies/authorizationPolicy` (GA) | +| Disable password expiration | ✅ | GuidedOnly | Never-expire on primary domain | `PATCH /domains/{id}` (GA) | + +The admin consent workflow reports `GuidedOnly` (never enables) when `Apply` is +set but no reviewer group is configured, because enabling the workflow without a +reviewer would strand user requests. + +Even with `Apply = $true`, all setting writes are withheld while Security +Defaults is enabled or unknown. Assessment and local reporting continue where +other prerequisites permit. The toolkit never changes Security Defaults. + +## Zero Trust page — Product-Group asks and out-of-scope items + +The following items appear in the Zero Trust "increased security" guidance but +are **documented rather than scripted** (no hallucinated APIs), with the reason: + +1. **Security Defaults**: the toolkit reads + `identitySecurityDefaultsEnforcementPolicy` but never changes it. Enabled or + unknown state blocks all tenant writes. This baseline is not established as + equivalent protection. Use the [control mapping and approved transition + procedure](Security-Defaults-Transition.md), including registration, MFA + prompting, legacy clients and device code flow. Report-only policies do not + replace active Security Defaults. +2. **Authentication methods hardening** (migrate to the Authentication methods + policy; disable SMS/voice) — `authenticationMethodsPolicy` is GA, but the + correct target state is tenant-specific and disabling a method a user depends + on can lock them out. A per-method, verified template is a backlog item. +3. **Self-service password reset (SSPR) enablement** — there is no supported GA + Microsoft Graph write for the SSPR enablement toggle (configured in the portal + / legacy APIs). Documented gap for Product-Group. +4. **Smart lockout thresholds** — exposed only through the `beta` directory + settings surface; beta-only, so out of scope for a GA baseline. +5. **Identity Protection risk policies** require Microsoft Entra ID **P2** + and remain guidance-only. PIM and Global Secure Access are also outside this + toolkit's scope; verify their separate licensing rather than treating them + as features deployed by the P1 baseline. + +## Design decisions (documented for review) + +- **Report-only default.** The guide lists a target state per policy (some + "On"), but the toolkit ships new policies report-only. This is a configuration + default, not a lockout guarantee or active replacement protection. The guide + target is recorded per policy (`RecommendedState`) for separately approved + promotion once its preconditions hold. +- **Break-glass required, verified, and injected.** The toolkit refuses to + create the baseline without an emergency-access exclusion, and injects it into + every policy's `excludeUsers`/`excludeGroups`. Verification is strict, matching + Microsoft's emergency-access guidance: the account must be **enabled** and hold + a **permanently-assigned Global Administrator** role (directly or through a + role-assignable group). The check reads active role assignment schedule + instances and accepts only tenant-wide assignments with no expiry. Temporary + PIM activations, PIM-eligible-only status, and expiring assignments do not + qualify. A break-glass *group* must contain an enabled member and provide + Global Administrator (on the group or a member). Anything else, including a + wrong role, no role, a disabled account, or a failed role read, stops the run + rather than proceeding with a false exclusion. + The final read-only deployment health check repeats this same shared + recoverability check so a role, membership, or exclusion removed after + deployment cannot be reported as healthy. +- **Existing-policy matching and adopt.** A same-named policy is only treated as + compliant when it actually carries the required protections and excludes the + break-glass principals; otherwise drift is reported (no duplicate is created). + `-AdoptExisting` updates only policies outside the mandatory manual-review + set described above, in place as a read-modify-write: + it ensures **only** the required grant controls and the break-glass exclusion, + and preserves the customer's other conditions (named locations, risk levels), + session controls, targeting, and state. +- **App-protection migration.** New policies use `compliantApplication`, not + the retired `approvedApplication` grant, and include Android/iOS without + conflicting platform exclusions. Current and configured legacy names are + recognized across paginated reads. Existing app-protection policies require + manual migration review even with `-AdoptExisting`; neither silent repair + nor duplicate creation is allowed. Corrected creation readback checks the + app-policy grant/operator, target/platform fields, state and exclusions. + See [migration prerequisites and service guidance](Security-Defaults-Transition.md#corrected-app-protection-policy-and-existing-tenants). +- **Enrollment CA excluded.** "Require MFA for Intune enrollment" is owned by the + Intune product; it is not shipped here to avoid duplicate policies. +- **Pilot-group scope.** All-users-style templates are scoped to a pilot group + by default rather than every user. + +## Product-Group / API asks + +These are documented rather than half-implemented (no hallucinated APIs): + +1. **Registration bootstrap and guest registration**: the P1 template protects + member registration with MFA, but does not configure combined registration, + issue Temporary Access Passes, deploy guest registration controls or prove + organization-wide MFA enrollment. Those remain operator prerequisites or + separately designed controls. +2. **Break-glass password handoff**: creating a break-glass account sets a + random password that is never emitted to evidence; there is no supported way + to hand the credential to the operator programmatically without logging it. + The operator resets and stores it out of band (see the break-glass guide). A + supported secure-handoff mechanism would be a Product-Group ask. + +## Tenant validation + +The historical write paths were validated against a Business Premium test tenant in +**report-only** state (dedicated pilot group + break-glass account, torn down +afterward): + +- All ten Conditional Access policies were created in + `enabledForReportingButNotEnforced`, each with the break-glass principal in + `excludeUsers` and the all-users-style policies scoped to the pilot group. +- Every create was **read back and verified** (`state` matched) before teardown. + A freshly created policy can briefly return `404 Not Found` on read-back while + directory replication settles, so the read-back tolerates `404` as a transient + status (`Invoke-WithTransientRetry -RetryStatusCodes 404`) and retries with + backoff rather than reporting a false failure. +- The tenant-security settings ran with their default opt-out (`Apply = $false`) + and reported `GuidedOnly`, changing nothing. +- Teardown removed the ten policies, the pilot group, and the break-glass + account, returning the tenant to its prior state. + +That historical record does not validate the corrected targeting, MFA alternative, +session-control verification, the 0.3.0 Security Defaults gate, +the corrected app-protection filters or the 0.4.0 policy additions. Offline fixtures cover their code +behavior; fresh pilot preview, effective roles/licensing, representative app +behavior and transition/recovery evidence are still required before rollout. +Current operation references and their verification date are recorded in the +[transition guide's API matrix](Security-Defaults-Transition.md#api-and-validation-record). diff --git a/Products/Entra/docs/Evidence-Troubleshooting.md b/Products/Entra/docs/Evidence-Troubleshooting.md new file mode 100644 index 0000000..292df84 --- /dev/null +++ b/Products/Entra/docs/Evidence-Troubleshooting.md @@ -0,0 +1,168 @@ +--- +title: Entra evidence and troubleshooting +parent: Microsoft Entra +layout: default +nav_order: 3 +permalink: /entra/evidence-troubleshooting/ +--- + +# Entra evidence and troubleshooting + +## Find and preserve the reports + +After logging initializes, the toolkit attempts to save HTML and JSON evidence +even when sign-in, deployment, or health evaluation fails. The final output +prints the full path of each successfully written report and a copyable +command to open the HTML manually. It never launches a browser automatically. + +With the default configuration, run this from `Products\Entra`: + +```powershell +Invoke-Item -LiteralPath '.\Reports\entra-run-report.html' +$report = Get-Content -LiteralPath '.\Reports\entra-run-log.json' -Raw | + ConvertFrom-Json +$report.moduleSummary | Format-Table module, result, entryCount +``` + +The private configuration's `Report` section can change these paths. +A write warning means that file was not confirmed saved; an older file can +still exist at the same location. Check the run ID and timestamps, not just +file presence. Preserve reports before retrying because filenames are reused. + +`entra-breakglass.json` is a local handoff containing resolved emergency-access +IDs. Empty lists mean no principals were resolved for that run, not that no +emergency accounts exist anywhere in the tenant. Never treat this file as +credential storage or as proof of successful recovery. + +Reports are private tenant evidence. Redaction is pattern-based, not a +guarantee that every service-authored error is safe to share. Review excerpts, +and never commit reports, private configuration, tokens, or credentials. + +## Interpret results without confusing execution and protection + +| Result or field | Meaning | +|---|---| +| `OK` | Recorded module work completed without a recorded failure or block. It does not prove the baseline was applied or the tenant is secure. | +| `FAILED` | An operation failed or a safety/health assessment returned a failure. Read its action and detail; this does not always mean the script crashed. | +| `BLOCKED` | A prerequisite, safety gate, or assessment finding prevents proceeding. Resolve it under change control. | +| `SKIPPED` | Work was not executed or remained assessment/guidance-only. It is not proof that configuration is complete. | +| `GuidedOnly` | Human work or review remains. It is not verified compliance. | +| `VALIDATED` / `Readback=Verified` | The stated readback check passed, not an end-to-end sign-in or security test. | +| `TenantWritesAllowed=True` | The Security Defaults gate passed. Approval, scope, emergency access, permissions, and other gates still apply. | +| `ExclusionsEvaluated=False` | Exclusion checks were not completed. Zero gap counters do not mean no gaps exist. | + +Review actual writes separately: + +```powershell +$report.entries | + Where-Object status -in @('Created', 'Updated', 'Adopted') | + Select-Object module, action, status, readback, detail +``` + +A `-WhatIf` preview must have no applied tenant actions in that result. +For an apply, compare each entry with the approved change and confirm its +readback. `Started` or `Module process completed` alone is not deployment +evidence. Intentionally skipped modules reduce the assessment's coverage. + +Selected modules that never start after an earlier failure are recorded as +`Skipped` with a `Blocked` disposition and an explicit reason. Console, HTML, +and JSON use the same module verdicts. Summaries of a failed child do not +change the orchestrator's own verdict, but genuine orchestrator failures and +readiness blocks remain visible. The child failure still requires action. + +Report-write warnings remain nonterminating even with `-WarningAction Stop`, +so they cannot hide the original error or prevent the other report attempt. + +## A first run is blocked and health is indeterminate + +A first run can connect successfully, find no configured emergency-access +principals, report missing toolkit policies, and stop Conditional Access +deployment. If enforcing customer policies already exist, the health check +cannot verify their emergency exclusions without those principals and can +return `Indeterminate` with a failed module result. + +This does not prove the tenant lacks emergency accounts or is currently +locked out. It means the tool lacks enough verified configuration to assess +recovery. Confirm the existing emergency accounts with the identity owner, +test them independently, and configure their object IDs in a complete +private config. Then rerun the preview. Do not create duplicate emergency +accounts or disable existing policies just to clear the report. + +Missing managed policies are expected before the baseline is deployed. +The separate `EnforcingPoliciesInTenant` count includes existing policies, +so a missing toolkit baseline does not mean the tenant has no access controls. + +For `LockoutRisk`, stop rollout and use the approved access incident/recovery +process immediately. For `DriftDetected`, reconcile the specific changes +before expansion. `Healthy` covers only the implemented readiness and +emergency-access checks, not full policy coverage or a sign-in guarantee. + +## Common failures + +| Symptom | What to do | +|---|---| +| Wrong tenant or verified-domain mismatch | Stop. Check the administrator UPN and, for GDAP, the customer domain. Do not bypass identity verification. | +| `401` / `403` or a failed read | Check the exact Graph operation, configured scopes, effective roles, GDAP access, and session. Do not add broad permissions speculatively. | +| Security Defaults enabled or unknown | Keep active protection. Use the [approved transition guide](Security-Defaults-Transition.md); the toolkit never disables it. | +| Missing apply parameters or pilot group | Complete the [operator checklist](Operator-Guide.md). Removing `-WhatIf` alone does not authorize writes. | +| No configured break-glass principal | Verify existing accounts/groups, populate the private configuration, and rerun. An ID is not a successful recovery test. | +| Existing or duplicate policy names | Review the exact objects and aliases. Do not use adoption or naming changes to bypass collisions or migration review. | +| Readback mismatch or unavailable readback | Treat the operation as failed or blocked. Inspect the tenant before retrying because the service might have accepted the preceding write. | +| Report-write warning | Keep the console output, verify the destination and permissions, and preserve any surviving report. Do not use stale files as current evidence. | + +### Windows sign-in needs a window handle + +`WAM broker available` describes Windows capability, not a guarantee that +the current host can display the sign-in window. If Graph reports +`A window handle must be configured`, open Windows Terminal with PowerShell 7 +yourself, change to the same product folder, and rerun the same `-WhatIf` +command. Complete sign-in in the Microsoft window and verify tenant identity. + +Do not disable MFA, add permissions, or switch to app-only authentication to +work around the host. `-NonInteractive` suppresses toolkit prompts but cannot +create a desktop sign-in window. See the +[Microsoft WAM parent-window guidance](https://aka.ms/msal-net-wam#parent-window-handles). + +## Recover from a partial deployment + +### Existing guest MFA policy reports a scope mismatch + +The current guest template uses `includeUsers = ["GuestsOrExternalUsers"]` +without a group include. An older policy targeting the pilot group is not +equivalent, even if its name says "guest access". The toolkit reports +`GuidedOnly` and `Readback=Mismatch`, without changing or duplicating it. +`-AdoptExisting` does not bypass this review boundary. A matching corrected +policy also remains review-only, not an automatic enforcement approval. + +Inspect the existing policy by ID with the identity owner, record its state, +scope and emergency exclusions, and plan an approved migration. Guest-wide +scope can include external users outside the pilot group. Keep existing +protection until replacement coverage and recovery are verified. + +### Recovery procedure + +There is no automatic rollback. Before apply, privately record the exact +policy IDs, states, targeting, emergency exclusions, ownership, and any +tenant-setting values that may change. Reports are redacted evidence, not +a complete backup. + +After a failure, stop expansion and preserve that run's reports. With the +identity owner, inspect the precise object and whether the service accepted +the write. Restore approved before-state or withdraw only the newly created +pilot object when safe. Do not delete by display name, remove broad sets of +policies, or disable existing protection. Emergency-account removal requires +verified replacement access and review of every dependent policy. + +If an existing policy requires manual migration, follow the +[migration procedure](Security-Defaults-Transition.md#corrected-app-protection-policy-and-existing-tenants). +After recovery, rerun `-WhatIf`, reconcile its findings, and obtain approval +before another apply or enforcement change. + +## Escalation + +Provide the product version, run time, module/action, sanitized error, +selected scope, and reviewed report excerpt. Record whether the issue is +sign-in, permission, configuration, readback, or incomplete health evidence. +Never include secrets, raw exports, unreviewed tenant reports, or private IDs +in a public issue. Live sign-in, role, policy impact, and recovery validation +remain the deployment and identity owners' responsibility. diff --git a/Products/Entra/docs/Operator-Guide.md b/Products/Entra/docs/Operator-Guide.md new file mode 100644 index 0000000..473d11e --- /dev/null +++ b/Products/Entra/docs/Operator-Guide.md @@ -0,0 +1,200 @@ +--- +title: Entra operator guide +parent: Microsoft Entra +layout: default +nav_order: 2 +permalink: /entra/operator-guide/ +--- + +# Entra operator guide + +Use this guide to move from a first assessment to an approved, report-only +Conditional Access pilot. The toolkit can make real tenant changes. +Report-only policies evaluate sign-ins but do not enforce protection. +Existing customer policies continue to apply. + +## Before you run + +- Use PowerShell 7 in an interactive desktop terminal, not Windows + PowerShell 5.1 or a background agent process. +- Keep the full `Products\Entra` folder from one approved release. Do not + mix configuration templates, scripts, or documentation from different versions. +- Confirm Business Premium or the appropriate Microsoft Entra ID P1 + entitlement for the selected Conditional Access capabilities. Configuration + selection is not proof of licensing. P2 risk policies are not deployed. +- Obtain authorization for the target tenant and verify the effective + directory roles and delegated Graph permissions needed for the selected + operations. See the [coverage](Coverage.md) and + [Security Defaults API record](Security-Defaults-Transition.md#api-and-validation-record). +- Install `Microsoft.Graph.Authentication` for the current user, or approve + the toolkit's missing-module prompt. `-AutoInstallModules` permits that + installation without a toolkit prompt; it is not permission to change the tenant. +- Choose a private evidence location. Reports and private configuration must + not be committed or published. + +The orchestrator connects Graph once. It requests `Api.GraphScopes` from +`Config\EntraConfig.psd1`, including the configured write scopes even during +`-WhatIf`. Consent does not authorize a write: the configuration, approval +gates, and `ShouldProcess` still control tenant changes. + +Use an existing approved emergency-access arrangement for a pilot. +Preparing privileged accounts and proving recovery are separate identity-owner +tasks, not something a successful report can substitute for. + +## First read-only preview + +From the repository root: + +```powershell +cd .\Products\Entra +.\Deploy-EntraBestPractice.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com ` + -WhatIf +``` + +No pilot group or high-risk switches are needed for this discovery run. +Complete Microsoft sign-in in the interactive window and confirm the expected +tenant. Read the numbered stages and final module summary. A blocked +Conditional Access stage is expected when emergency-access exclusions or +write approvals have not been supplied. Failed reads still need investigation. + +The toolkit never disables Security Defaults. If its state is enabled or +unknown, tenant writers stay assessment-only. Follow the +[transition procedure](Security-Defaults-Transition.md) rather than disabling +existing protection to make a preview look successful. + +## Prepare private configuration and a pilot + +Copy the complete `Config\EntraConfig.psd1` to an approved private location +outside the repository, then supply its path through `-ConfigPath`. +Edit the existing fields in that copy, not a replacement partial hashtable. +Keep the following settings for the initial pilot: + +| Setting | Preparation | +|---|---| +| `ConditionalAccess.DefaultState` | Keep `enabledForReportingButNotEnforced`. | +| `ConditionalAccess.RequireBreakGlassExclusion` | Keep `$true`. | +| `ConditionalAccess.BreakGlass.ExcludeUserIds` / `ExcludeGroupIds` | Supply independently verified emergency-access object IDs. Do not use display names or assume an empty list proves no accounts exist. | +| `ConditionalAccess.BreakGlass.CreateAccountIfMissing` | Keep `$false` when using verified existing accounts. Account creation requires separate preparation and manual completion. | +| `TenantSecurity.*.Apply` | Keep `$false` unless the individual tenant-wide change has its own approval and recovery plan. | +| `Assignment.AllowTenantWideAssignmentForHighRisk` | Keep `$false` for the pilot. | + +In the Entra admin center, prepare a dedicated security group containing only +approved pilot users and record its object ID. Confirm membership, owners, +licenses, and the intended test accounts before using it as `-PilotGroupId`. +Keep emergency-access identities out of normal pilot activity and verify +their exclusions independently. + +The pilot group narrows all-users-style templates, not every policy. +Role- and app-scoped templates retain their configured targeting. In +particular, the optional phishing-resistant admin policy targets privileged +roles, not the intersection of those roles and the pilot group. Review +every selected policy's actual conditions before apply. + +The standard admin MFA and Microsoft admin-portal policies also target +directory roles across the tenant, without adding pilot-group members. +Azure-management MFA targets all users for that application, even when a +pilot group is supplied. New policies remain report-only unless you change +the configured default. Do not treat the pilot switch as a cap on these +role-, guest- or application-scoped policies. + +Guest MFA selects all guests and external users acting in this tenant through +`GuestsOrExternalUsers`, not everyone in the pilot group. It does not +intersect the guest category with pilot membership. New policies remain +report-only by default. Review external-user MFA readiness and cross-tenant +trust before separately approving enforcement. + +Optional P1 hardening requires method readiness; P2 risk controls remain out +of scope. App-protection enforcement depends on compatible applications and +assigned Intune app-protection policies. See [Scenarios](Scenarios.md). + +## Preview the approved deployment + +Only after existing protection and emergency access have been independently +verified, or an approved Security Defaults transition has completed: + +```powershell +.\Deploy-EntraBestPractice.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com ` + -ConfigPath 'C:\ApprovedConfig\EntraConfig.psd1' ` + -IncludeHighRisk -CustomerApprovalId 'CHG0001234' ` + -BreakGlassExclusionsConfirmed -RollbackAcknowledged ` + -PilotGroupId '' ` + -WhatIf +``` + +Replace placeholders with the approved private configuration, change +reference, and group ID. The confirmation switches record operator decisions; +they do not create a pilot group, test recovery, assign emergency roles, or +prove active replacement protection. + +Review the HTML report for scope, existing-policy collisions, failed reads, +and blocked work. `TenantWritesAllowed=True` only means the Security Defaults +gate passed. It is not overall deployment authorization. + +## Apply and verify + +After the preview is reviewed and apply is separately approved, run the exact +same command with the same private configuration, tenant, and scope, +omitting only `-WhatIf`. Keep the default report-only policy state. + +Verify the resulting policies, state, targeting, emergency exclusions, and +readback evidence in the Entra admin center. Rerun with `-WhatIf` and +investigate unexpected changes. Existing-policy matches can require manual +review even when fields match; `-AdoptExisting` does not bypass review-only +policies or ambiguous matches. + +Use the current configuration's `ReviewExistingOnly = $true` for guest MFA, admin MFA, +admin portals, Azure management, device-or-MFA and browser-session policies. +Old custom references without this gate must be updated before running. +The toolkit leaves existing matches unchanged, including with adoption. +Review their effective scope and grants manually before migrating them. +Browser-session comparisons include the session controls and device filter; +successful readback is no longer based on policy state alone. + +An older guest policy may still target a mixed workforce/guest pilot group. +The toolkit reports that mismatch and leaves it unchanged, including its +enforcement state. Review the actual policy ID and approved replacement scope +with the identity owner; do not disable existing protection or use adoption +to bypass the migration review. + +Enforcement is a separate change after review of report-only sign-ins, +application compatibility, user readiness, and tested recovery. Do not leave +a tenant relying on report-only policies after removing existing protection. +Tenant-wide assignment also requires explicit configuration permission and +`-AssignTenantWide`; it is not a shortcut around pilot preparation. + +## GDAP and unattended use + +For an approved delegated administration relationship, add the customer +tenant's verified domain: + +```powershell +.\Deploy-EntraBestPractice.ps1 ` + -TenantAdminUpn delegatedadmin@partner.onmicrosoft.com ` + -DelegatedOrganization customer.onmicrosoft.com ` + -WhatIf +``` + +The connection must verify the customer tenant, not just the administrator's +home tenant. `-NonInteractive` suppresses toolkit prompts but does not provide +app-only authentication or guarantee a prompt-free first sign-in. Standalone +setup modules require a verified Graph connection in the same session. + +## Reports and stopping conditions + +The final output announces successfully saved HTML and JSON run reports and +prints a manual open command. Defaults are `Reports\entra-run-report.html` +and `Reports\entra-run-log.json` under the product folder. The emergency-access +module can also write `Reports\entra-breakglass.json`; this contains resolved +IDs for module coordination and is not proof that recovery works. + +Preserve each run's files privately before rerunning because the default +filenames are overwritten. Reporting can still finish after deployment +fails; a saved report does not mean a successful deployment. + +Stop apply or expansion for a wrong tenant, an unreadable prerequisite, +unverified emergency access, unexpected targeting, failed readback, or a +`LockoutRisk`/`Indeterminate` health verdict. Do not add skip switches or +broader permissions to hide the failure. Follow +[evidence and recovery](Evidence-Troubleshooting.md) with the identity owner. diff --git a/Products/Entra/docs/Scenarios.md b/Products/Entra/docs/Scenarios.md new file mode 100644 index 0000000..9eadf24 --- /dev/null +++ b/Products/Entra/docs/Scenarios.md @@ -0,0 +1,158 @@ +--- +title: Scenarios and what it does +parent: Microsoft Entra +layout: default +nav_order: 1 +permalink: /entra/scenarios/ +--- + +# Microsoft Entra: scenarios and what it does +{: .no_toc } + +A plain-language tour of what the Entra toolkit sets up for a Microsoft 365 +**Business Premium** (Microsoft Entra ID P1) tenant, who each control is for, and +why it is safe to run first. For the exact APIs, license notes, and Product-Group +gaps, see [Coverage and Product-Group asks](Coverage.md). Start with the +[README preview](../README.md#first-time-here-start-with-a-preview), use the +[operator guide](Operator-Guide.md) for deployment, and consult +[evidence and troubleshooting](Evidence-Troubleshooting.md) for blocked runs. + +1. TOC +{:toc} + +--- + +## Who this is for + +Partners and IT admins standing up a **secure-by-default identity baseline** on a +new or existing Business Premium tenant. New policies default to +**report-only** (`enabledForReportingButNotEnforced`) and the default +configuration requires emergency-access exclusions. Neither default is a +guarantee against lockout or a replacement for existing enforcing protection. + +## The safety model in one minute + +- **Report-only by default.** New policies evaluate rather than enforce. + Existing policies and changed configuration can still affect sign-in. + Turning a policy on is a separate, + deliberate change once you have reviewed the impact. +- **Break-glass always excluded.** The toolkit refuses to create the baseline + unless an emergency-access account (or group) is available, and it is excluded + from every new policy under the default configuration. +- **Security Defaults unchanged.** Enabled or unknown state limits every + writer to assessment-only. The toolkit reads the state and never disables + it. Follow the [coverage and transition guide](Security-Defaults-Transition.md) + before moving from existing protection to approved active replacements. +- **Pilot group first.** All-users-style policies target a pilot group by + default; tenant-wide is an explicit opt-in. +- **High-risk gates.** Creating Conditional Access requires `-IncludeHighRisk` + plus break-glass and rollback confirmation. Preview everything with `-WhatIf`. + +## Conditional Access baseline (12 policies, report-only) + +| Policy | Problem it solves | +|---|---| +| Require MFA for admins | Privileged accounts are the top target; MFA on every admin sign-in blocks password-only compromise. | +| Require MFA for all users | Requires MFA for the pilot group, not just admins. This grant alone does not require phishing-resistant methods. | +| Block legacy authentication | Legacy protocols (IMAP/POP/SMTP auth) bypass MFA; blocking them closes the most common MFA-evasion path. | +| Require MFA for Azure management | Protects the Azure control plane (portal, CLI, PowerShell, ARM) with MFA. | +| Require MFA for Microsoft admin portals | Adds MFA in front of the admin portals themselves. | +| Require MFA for guest access | Targets all guests and external users acting in your tenant, not workforce pilot-group members. Report-only by default; the pilot group does not narrow this scope. | +| Block access from unknown/unsupported device platforms | Denies platforms you have not chosen to support, shrinking the attack surface. | +| No persistent browser session | Prevents indefinitely "stay signed in" sessions on unmanaged browsers. | +| Require app protection policy | Uses `compliantApplication` for Android/iOS. Requires compatible applications and assigned Intune app-protection policies; the Entra template does not deploy those policies. | +| Require compliant/hybrid device or MFA | Accepts a compliant device, a hybrid-joined device, or MFA. The OR controls do not require a managed device when MFA is satisfied. | +| Block device code flow | Evaluates blocking device-code authentication for the pilot group. Inventory legitimate devices/tools and review report-only results before enforcing. | +| Protect security information registration | Requires MFA for member registration in the pilot group. Combined registration and an initial authentication method or Temporary Access Pass must be ready; guests/external users are excluded. | + +The original ten policies record the guide's recommended target state. The +additions retain a report-only target pending their own pilot and enforcement +decision. The Intune device-enrollment Conditional +Access policy is intentionally **not** duplicated here; it is owned by the +Intune product. + +The corrected app-protection template retains its old configuration key/file. +Existing current or legacy policy names are assessed without silent repair or +duplicate creation, including with `-AdoptExisting`. Review conflicting +platform filters and migration impact manually before broadening effective +scope. Multiple matching policies block the run. + +The new device-code and registration policies also require manual review when +already present, even with `-AdoptExisting`. They do not change customer +exceptions or automatically migrate existing policies. + +Admin MFA and admin-portal MFA target the configured directory roles, not +pilot-group membership. Azure-management MFA targets all users for that +application. The pilot group therefore does not limit every policy's scope. +Guest MFA uses `GuestsOrExternalUsers` without a group include. Adding the +pilot group would also target its workforce members, not restrict the guest +selector to that group. +Existing guest, admin, admin-portal, Azure-management, device-or-MFA and +browser-session matches are review-only as well, even with `-AdoptExisting`. +Review the corrected scope, MFA alternative or session settings before +manually migrating an existing policy. New-policy readback checks those +managed controls and fails on missing settings. + +### Optional P1 hardening and P2 extensions + +Set `Enabled = $true` on `require-phishing-resistant-mfa-admins` in your private +configuration to select the optional P1 policy. It requires Microsoft's built-in +phishing-resistant authentication strength and is report-only by default. +It targets privileged roles, not the pilot group. Verify supported methods for +every targeted administrator before enforcing; SMS or voice MFA does not +satisfy this policy. + +P2-dependent sign-in risk, user risk and risk-triggered authentication controls +are not deployed. Authentication strength without a risk condition is a P1 +capability. See [the tier and prerequisite matrix](Coverage.md#p1-additions-and-p2-boundary). + +### From assessment to deployment + +The console uses numbered stages, color and text status labels. Its readiness +section lists missing apply parameters before sign-in. Follow the +[preview-to-apply checklist](../README.md#usage), rather than simply removing +`-WhatIf`. You need verified emergency access, an approved change reference, +all high-risk confirmations and an approved assignment scope. + +An optional naming prefix exposes policy tier, sequence, scope and purpose for +new policies. Existing policies retain their names and are matched without +duplication. See [policy naming and selection](../README.md#policy-naming-and-selection). + +## Emergency access (break-glass) + +The toolkit either **verifies** an emergency-access account you already have — +confirming it is enabled and holds a permanently assigned, tenant-wide Global +Administrator role with no expiry — or, if you opt in, **creates** a dedicated +cloud-only account for you. Temporary PIM activations and expiring assignments +do not qualify. Either way the account is excluded from every Conditional +Access policy. See the +[break-glass guide](Break-Glass-Guide.md) for the end-user steps. + +## Tenant security settings (Zero Trust, opt-in) + +These tenant-wide identity settings from Microsoft's Zero Trust guidance are +**off by default** — the tool reports current vs recommended state until you opt +in per setting: + +- **Restrict user consent to apps** — users can only consent to low-impact + permissions from verified publishers, cutting illicit-consent phishing. +- **Admin consent workflow** — users request access that an admin reviews, + instead of consenting themselves. +- **Restrict guest access defaults** — limit who can invite guests and what + guests can read in the directory. +- **Disable password expiration** — long-lived passwords with MFA beat forced + rotation (Microsoft guidance). + +Every selected tenant-setting write also requires Security Defaults to be +verified disabled. Unknown state is a blocker, not assumed consent to write. + +## What it does not do + +- It never changes Security Defaults, certifies replacement coverage, or + automatically promotes report-only policies. Do not remove existing + protection to test a report-only baseline. +- It does not change settings the tool does not own; tenant settings use + read-modify-write to preserve your existing configuration. +- Risk-based Conditional Access requires Entra ID P2 and is not deployed. + PIM and Global Secure Access are also out of scope and have their own + licensing requirements. See [Coverage](Coverage.md) for the full boundary. diff --git a/Products/Entra/docs/Security-Defaults-Transition.md b/Products/Entra/docs/Security-Defaults-Transition.md new file mode 100644 index 0000000..5362320 --- /dev/null +++ b/Products/Entra/docs/Security-Defaults-Transition.md @@ -0,0 +1,198 @@ +--- +title: Security Defaults and app-protection migration +parent: Microsoft Entra +layout: default +nav_order: 4 +permalink: /entra/security-defaults-transition/ +--- + +# Security Defaults and app-protection migration + +Entra reads Security Defaults before setup and again at each selected +write boundary. It never enables or disables Security Defaults. If the state +is enabled or cannot be verified, all toolkit tenant writes are withheld: +Conditional Access creation/adoption, emergency-account creation and the four +tenant-setting opt-ins. Supported reads and local reporting can continue +unless another prerequisite or service failure stops the run. + +This gate also applies to standalone modules. A supplied context value, +`-AdoptExisting`, `-NonInteractive`, a tenant-setting `Apply` flag or all CA +approval switches cannot bypass the live state check. `-WhatIf` remains +mandatory for preview: disabled Security Defaults is not approval to write. + +## Start without changing protection + +In an interactive PowerShell 7 terminal, from `Products\Entra`: + +```powershell +.\Deploy-EntraBestPractice.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com ` + -WhatIf +``` + +For GDAP, add `-DelegatedOrganization` with the customer domain. Confirm the +target tenant, consent, roles and licensing independently. The configured +Graph scopes, including write scopes, are still requested at sign-in even +during `-WhatIf`; no new scope was added for the Security Defaults read. + +Review `Reports\entra-run-report.html` and `Reports\entra-run-log.json`. +After each successful report write, the final output prints its full path +and a command to open the HTML manually. This includes blocked and `-WhatIf` +runs; report generation does not mean the tenant is ready for deployment. +If a report write fails, retain the console warning and do not use an older +file at that path as evidence of this run. + +The numbered tenant-discovery stage shows this state explicitly. The +prerequisite stage also explains the apply parameters, even on a first preview +without configured emergency-access IDs. See the +[preview-to-apply checklist](../README.md#usage). No console instruction or +approval switch disables this safety gate. + +| Observed state | Meaning and operator action | +| --- | --- | +| `SecurityDefaultsState=Enabled` | Assessment-only. Keep protection in place and plan the transition; do not disable it just to clear this gate. | +| `SecurityDefaultsState=Disabled` | Only this readiness check passed. Existing approval, pilot, recovery, license and role requirements still apply. It does not prove replacement protection is active. | +| `SecurityDefaultsState=Unknown` | The GET failed, was unauthorized, or did not return Boolean `isEnabled`. Writes are blocked. Verify effective access/service state and rerun. | + +`TenantWritesAllowed` in the evidence describes this gate only, not overall +authorization. The health summary includes the state and never returns +`Healthy` when it is enabled or unknown. With no more severe finding, enabled +produces `DriftDetected` with a blocked disposition, and unknown produces +`Indeterminate`. Enabled Security Defaults is not itself a security defect; +it is a blocker for this toolkit's deployment path. A module or run may +complete while reporting blocked work. Read the dispositions and health +verdict, not just process completion. + +The reads are point-in-time checks, not a transaction with the service. +Earlier writes are not undone if the state changes later or another operation +fails. Retain before-state evidence and an approved recovery plan. + +## Do not assume the P1 baseline replaces Security Defaults + +Microsoft documents these Security Defaults protections. The table is a +review checklist, not an assertion of identical policy behavior or licensing +equivalence. Verify the current tenant and Microsoft guidance before cutover. + +| Security Defaults protection | Current toolkit coverage | Required transition review | +| --- | --- | --- | +| Require everyone to register for MFA | Registration-protection policy, but no complete enrollment workflow | The new policy requires MFA during member registration; it does not enroll everyone, configure methods or issue Temporary Access Passes. Confirm bootstrap and coverage for all intended users. | +| Require administrator MFA | Admin/portal templates | Compare effective role/user targets, exclusions and enforcement. Pilot targeting is not organization-wide replacement. | +| Require user MFA when Microsoft determines it necessary | All-user MFA template, with different targeting/trigger semantics | Do not claim to reproduce Microsoft's prompting logic. Compare the intended user experience and coverage. | +| Block legacy authentication | Legacy-client template | Verify protocols, all affected users and active enforcement. | +| Block device code flow | Dedicated P1 template, report-only by default and pilot-scoped | Inventory dependencies, review sign-in impact and independently approve enforcement/exception coverage. A report-only pilot is not active tenant-wide protection. | +| Protect privileged activities, such as Azure management | Azure-management/admin-portal templates | Validate actual applications, role targets and sign-in behavior. | + +P1 provides Conditional Access capability. It does not prove that this +particular template set reproduces every Security Defaults behavior. +Risk-based policies and Microsoft-managed policy eligibility have their own +requirements; check the current documentation and tenant rather than treating +P1/P2 or a product license as a blanket coverage result. + +## Approved transition, not automatic disabling + +1. Keep Security Defaults enabled during discovery. Inventory existing + customer and Microsoft-managed policies, authentication methods, MFA + registration, legacy/device-code clients and emergency access. +2. Agree the coverage matrix, customer owner, recovery route and cutover + criteria. Verify emergency accounts independently using Microsoft's + current guidance; a directory ID is not a recovery test. +3. Test the intended replacement in an isolated, approved nonproduction + environment. Review policy semantics, actual targets and sign-in effects. + Report-only is useful evaluation but does not enforce replacement controls. +4. Have the authorized identity owner perform an approved transition using + current Microsoft guidance. Microsoft says to immediately enable replacement + Conditional Access protection after disabling Security Defaults. Do not + disable it and then wait for a later toolkit pilot or report-only review. + Confirm Microsoft-managed policies' actual availability, state, exclusions + and coverage; do not assume they are already active. +5. After the transition is independently confirmed, rerun `-WhatIf`. Review + the exact configuration and pilot scope before a separately approved apply. + Keep the default new-policy state report-only for toolkit evaluation; + enforcement promotion remains a separate decision. + +This toolkit does not execute the cutover, certify protection equivalence, +enable Microsoft-managed policies, or automatically restore previous writes. +If replacement coverage cannot be demonstrated, leave the existing protection +in place and record the deployment as blocked. + +The 0.4.0 P1 additions reduce documented coverage gaps but do not automate this +transition or prove equivalence. The optional phishing-resistant admin policy +also requires method readiness; its P1 authentication strength is not a P2 +risk policy. See [the additions and prerequisites](Coverage.md#p1-additions-and-p2-boundary). + +## Corrected app-protection policy and existing tenants + +New policies use the name **Require app protection policy** and the +`compliantApplication` grant. Android and iOS are included without excluding +those same platforms. The configuration key and JSON filename +`require-approved-client-apps` remain stable for compatibility; they do not +mean the retired `approvedApplication` control is requested. + +The current Microsoft migration notice recommends only **Require app +protection policy** for new policies. Its dated notice places policies using +the approved-client grant in a read-only state from June 30, 2026, while +allowing disable/delete and continued enforcement of existing enabled +policies. Some procedural text on the same page still mentions both controls; +use the current new-policy notice and obtain product clarification for legacy +service behavior rather than assuming an old edit recipe works. + +The toolkit recognizes both the corrected display name and the legacy name +**Require approved client apps or app protection policies**. It reads all +policy pages and refuses ambiguous matches. It does not automatically rename, +repair or duplicate either existing policy, including with `-AdoptExisting`. +Incorrect mobile filters or grant logic produce `GuidedOnly` migration +evidence instead of a compliance claim. Health recognizes the legacy policy +as present but records `LegacyPolicies` and requires migration review. + +For an existing policy: + +1. Record its exact state, targets, grants, exclusions and customer settings + privately. Inspect the actual grant: the old name alone does not mean it + contains the retired control. +2. Establish compatible client applications and assigned Intune app-protection + policies, licensing and user readiness. The Entra policy does not create + those Intune policies. Unsupported apps can be blocked after enforcement. +3. Agree a manual, staged migration in an isolated test scope. Removing + conflicting exclusions can activate protection for users who were + previously outside effective scope. Do not silently broaden an enforcing + policy or edit/delete a retired control based only on its display name. +4. Verify platforms, application behavior, grants and emergency-access + exclusions before any production change or rename. Rerun assessment to + confirm the resulting policy. Multiple current/legacy matches remain + blocked until the identity owner reconciles them. + +Custom configuration must retain the current policy reference's +`LegacyDisplayNames` and `ReviewExistingOnly = $true`. Older custom references +fail with an upgrade message rather than losing migration protection. + +## API and validation record + +Source verification date: **2026-09-21**. No live tenant validation is implied. + +| Operation | Surface and permission | Evidence / failure behavior | +| --- | --- | --- | +| Read Security Defaults | Graph v1.0 `GET /policies/identitySecurityDefaultsEnforcementPolicy`; delegated `Policy.Read.All`, already configured | Require Boolean `isEnabled`. Only normalized state and HTTP status are logged; malformed/unauthorized/unavailable results are `Unknown`. Shared bounded retry applies to transient failures, not 403. | +| Create corrected CA policy | Existing Graph v1.0 CA collection and `Policy.ReadWrite.ConditionalAccess` | Existing operator roles and high-risk gates still apply. Requires disabled Security Defaults, pilot scope and exclusions. New app-policy readback compares state, grants/operator, targets/platforms and required exclusions. | +| Existing app-policy migration | Human-approved workflow, not a new API writer | No automatic mutation by this change. Preserve customer settings until the approved migration is verified. | + +The GET documentation lists `Policy.Read.All` for delegated and application +access. This product remains delegated UPN/GDAP only; API permission support +does not add an authentication mode. The GET method page does not establish +a tested minimum directory/GDAP role for every customer. Verify effective +access in the approved pilot instead of inventing a new role requirement. + +Offline tests cover enabled/disabled/unknown state, missing/null/non-Boolean +responses, 403, bounded transient retry, standalone writers, full orchestration, +WhatIf, reruns, partial failure, corrected readback, existing aliases, +pagination, duplicate blocking and HTML/JSON evidence. Fresh direct/GDAP +pilot preview, app/device behavior, effective role/license checks and manual +transition/recovery evidence remain human release gates. + +## Microsoft references + +- [Security Defaults and moving to Conditional Access](https://learn.microsoft.com/entra/fundamentals/security-defaults) +- [GET Security Defaults policy](https://learn.microsoft.com/graph/api/identitysecuritydefaultsenforcementpolicy-get?view=graph-rest-1.0) +- [Approved-client grant migration](https://learn.microsoft.com/entra/identity/conditional-access/migrate-approved-client-app) +- [Microsoft-managed Conditional Access policies](https://learn.microsoft.com/entra/identity/conditional-access/managed-policies) +- [Report-only evaluation](https://learn.microsoft.com/entra/identity/conditional-access/concept-conditional-access-report-only) +- [Emergency-access accounts](https://learn.microsoft.com/entra/identity/role-based-access-control/security-emergency-access) diff --git a/Products/Intune/Config/CompliancePayloads/android-work-profile-compliance.json b/Products/Intune/Config/CompliancePayloads/android-work-profile-compliance.json new file mode 100644 index 0000000..46a93a3 --- /dev/null +++ b/Products/Intune/Config/CompliancePayloads/android-work-profile-compliance.json @@ -0,0 +1,36 @@ +{ + "@odata.type": "#microsoft.graph.androidWorkProfileCompliancePolicy", + "displayName": "Android Work Profile - Baseline security compliance", + "description": "Minimum security configuration for personal Android devices that access work or school data (guide task 7).", + "scheduledActionsForRule": [ + { + "ruleName": "PasswordRequired", + "scheduledActionConfigurations": [ + { + "actionType": "block", + "gracePeriodHours": 0, + "notificationTemplateId": "" + } + ] + } + ], + "passwordRequired": true, + "passwordMinimumLength": 6, + "passwordRequiredType": "numericComplex", + "passwordMinutesOfInactivityBeforeLock": 5, + "securityPreventInstallAppsFromUnknownSources": true, + "securityDisableUsbDebugging": true, + "securityRequireVerifyApps": false, + "deviceThreatProtectionEnabled": false, + "deviceThreatProtectionRequiredSecurityLevel": "unavailable", + "advancedThreatProtectionRequiredSecurityLevel": "unavailable", + "securityBlockJailbrokenDevices": true, + "osMinimumVersion": "9.0", + "storageRequireEncryption": true, + "securityRequireSafetyNetAttestationBasicIntegrity": true, + "securityRequireSafetyNetAttestationCertifiedDevice": true, + "securityRequireGooglePlayServices": true, + "securityRequireUpToDateSecurityProviders": true, + "securityRequireCompanyPortalAppIntegrity": true, + "securityRequiredAndroidSafetyNetEvaluationType": "hardwareBacked" +} diff --git a/Products/Intune/Config/CompliancePayloads/ios-compliance.json b/Products/Intune/Config/CompliancePayloads/ios-compliance.json new file mode 100644 index 0000000..fc8b8e5 --- /dev/null +++ b/Products/Intune/Config/CompliancePayloads/ios-compliance.json @@ -0,0 +1,30 @@ +{ + "@odata.type": "#microsoft.graph.iosCompliancePolicy", + "displayName": "iOS - Baseline security compliance", + "description": "Minimum security configuration for personal and supervised iOS/iPadOS devices that access work or school data (guide task 7).", + "scheduledActionsForRule": [ + { + "ruleName": "PasswordRequired", + "scheduledActionConfigurations": [ + { + "actionType": "block", + "gracePeriodHours": 0, + "notificationTemplateId": "" + } + ] + } + ], + "passcodeBlockSimple": true, + "passcodeMinimumLength": 6, + "passcodeMinutesOfInactivityBeforeLock": 5, + "passcodeMinutesOfInactivityBeforeScreenTimeout": 5, + "passcodeRequiredType": "numeric", + "passcodeRequired": true, + "osMinimumVersion": "14.8", + "securityBlockJailbrokenDevices": true, + "deviceThreatProtectionEnabled": false, + "deviceThreatProtectionRequiredSecurityLevel": "unavailable", + "advancedThreatProtectionRequiredSecurityLevel": "unavailable", + "managedEmailProfileRequired": false, + "restrictedApps": [] +} diff --git a/Products/Intune/Config/CompliancePayloads/windows-compliance.json b/Products/Intune/Config/CompliancePayloads/windows-compliance.json new file mode 100644 index 0000000..309885b --- /dev/null +++ b/Products/Intune/Config/CompliancePayloads/windows-compliance.json @@ -0,0 +1,37 @@ +{ + "@odata.type": "#microsoft.graph.windows10CompliancePolicy", + "displayName": "Windows - Baseline security compliance", + "description": "Windows device compliance baseline (guide task 7): BitLocker, Secure Boot, code integrity, firewall, TPM, and Microsoft Defender with real-time protection.", + "roleScopeTagIds": ["0"], + "scheduledActionsForRule": [ + { + "ruleName": "PasswordRequired", + "scheduledActionConfigurations": [ + { + "actionType": "block", + "gracePeriodHours": 0, + "notificationTemplateId": "", + "notificationMessageCCList": [] + } + ] + } + ], + "deviceThreatProtectionRequiredSecurityLevel": "unavailable", + "passwordRequiredType": "alphanumeric", + "bitLockerEnabled": true, + "secureBootEnabled": true, + "codeIntegrityEnabled": true, + "passwordRequired": true, + "passwordBlockSimple": true, + "passwordMinimumCharacterSetCount": 2, + "passwordMinutesOfInactivityBeforeLock": 15, + "storageRequireEncryption": true, + "activeFirewallRequired": true, + "tpmRequired": true, + "antivirusRequired": true, + "antiSpywareRequired": true, + "defenderEnabled": true, + "signatureOutOfDate": true, + "rtpEnabled": true, + "deviceThreatProtectionEnabled": false +} diff --git a/Products/Intune/Config/IntuneConfig.psd1 b/Products/Intune/Config/IntuneConfig.psd1 new file mode 100644 index 0000000..5225f14 --- /dev/null +++ b/Products/Intune/Config/IntuneConfig.psd1 @@ -0,0 +1,366 @@ +@{ + ProductName = 'Microsoft Intune Best Practice Toolkit' + ProductVersion = '0.3.0' + ManagedByTag = '[Managed by SMBTool Intune Toolkit]' + + # Source baseline: Device Management Deployment Guide for Small Business + # (Device Enrollment Best Practices, Business Premium / Intune Plan 1, + # 7 March 2026). Keys are toolkit-internal identifiers and are not Microsoft + # recommendation identifiers. GuideTask maps back to the source baseline so + # evidence can be traced to the guide. + BestPracticeItems = @( + @{ + Key = 'windows-auto-enrollment' + Name = 'Enable automatic MDM enrollment for Windows devices' + Module = 'Setup-EnrollmentPrerequisites' + LicenseCapability = 'intune' + Risk = 'Standard' + RequiresHighRiskGate = $false + Priority = 1 + GuideTask = 1 + } + @{ + Key = 'apple-push-certificate' + Name = 'Apple MDM push certificate for iOS/iPadOS and macOS' + Module = 'Setup-EnrollmentPrerequisites' + LicenseCapability = 'intune' + Risk = 'Standard' + RequiresHighRiskGate = $false + Priority = 1 + GuideTask = 2 + } + @{ + Key = 'managed-google-play' + Name = 'Connect the tenant to a managed Google Play account' + Module = 'Setup-EnrollmentPrerequisites' + LicenseCapability = 'intune' + Risk = 'Standard' + RequiresHighRiskGate = $false + Priority = 1 + GuideTask = 3 + } + @{ + Key = 'default-compliance-settings' + Name = 'Treat devices with no compliance policy as not compliant' + Module = 'Setup-ComplianceBaseline' + LicenseCapability = 'intune' + Risk = 'High' + RequiresHighRiskGate = $true + Priority = 1 + GuideTask = 4 + } + @{ + Key = 'enrollment-restrictions' + Name = 'Restrict enrollment for unused platforms and personal devices' + Module = 'Setup-EnrollmentRestrictions' + LicenseCapability = 'intune' + Risk = 'High' + RequiresHighRiskGate = $true + Priority = 2 + GuideTask = 5 + } + @{ + Key = 'app-protection-policies' + Name = 'Deploy app protection policies for core Microsoft apps' + Module = 'Setup-AppProtectionPolicies' + LicenseCapability = 'intune' + Risk = 'Standard' + RequiresHighRiskGate = $false + Priority = 2 + GuideTask = 6 + } + @{ + Key = 'device-compliance-policies' + Name = 'Create per-platform device compliance policies' + Module = 'Setup-DeviceCompliancePolicies' + LicenseCapability = 'intune' + # High risk despite the source guide presenting it as routine. + # Creating a compliance policy can mark existing devices + # noncompliant, and if the customer already operates Conditional + # Access requiring a compliant device (whether or not this toolkit + # created it), those users lose access immediately. + Risk = 'High' + RequiresHighRiskGate = $true + Priority = 2 + GuideTask = 7 + } + @{ + Key = 'm365-apps-deployment' + Name = 'Deploy Microsoft 365 Apps to managed Windows devices' + Module = 'Setup-AppDeployment' + LicenseCapability = 'intune' + Risk = 'Standard' + RequiresHighRiskGate = $false + Priority = 2 + GuideTask = 8 + } + @{ + Key = 'enterprise-state-roaming' + Name = 'Configure Enterprise State Roaming through Windows Backup for Organizations' + Module = 'Setup-EnterpriseStateRoaming' + LicenseCapability = 'conditionalAccess' + Risk = 'Standard' + RequiresHighRiskGate = $false + Priority = 2 + GuideTask = 9 + } + @{ + Key = 'device-conditional-access' + Name = 'Require MFA and a compliant device for Intune enrollment' + Module = 'Setup-DeviceConditionalAccess' + LicenseCapability = 'conditionalAccess' + Risk = 'High' + RequiresHighRiskGate = $true + Priority = 3 + GuideTask = 10 + } + ) + + # These identifiers are configuration candidates, not an assertion that a + # tenant has the capability, and not an assertion that the names are + # current. Verify SKU and service-plan metadata before enabling apply + # behavior, and update this data file as Microsoft changes catalog names. + LicenseCapabilities = @{ + intune = @{ + SkuPartNumbers = @('SPB', 'INTUNE_A', 'EMS', 'EMSPREMIUM') + ServicePlanNames = @('INTUNE_A') + } + conditionalAccess = @{ + SkuPartNumbers = @('SPB', 'AAD_PREMIUM', 'EMS', 'EMSPREMIUM') + ServicePlanNames = @('AAD_PREMIUM', 'AAD_PREMIUM_P2') + } + } + + # Graph scopes are the least-privilege set the toolkit consents to. Read + # scopes cover preflight and readback; the ReadWrite scopes are each + # required by an implemented write path with deterministic fixture + # coverage. Refresh pilot evidence after changing a write contract. Do not + # place secrets or tokens here. + Api = @{ + GraphBaseUri = 'https://graph.microsoft.com/v1.0' + # Some device management resources (officeSuiteApp, managed app + # protection) are only projected on the beta endpoint. Writes that need + # it read this value rather than hard-coding the host. + GraphBetaBaseUri = 'https://graph.microsoft.com/beta' + GraphScopes = @( + 'User.Read' + 'LicenseAssignment.Read.All' + 'DeviceManagementConfiguration.Read.All' + 'DeviceManagementApps.Read.All' + 'DeviceManagementServiceConfig.Read.All' + # Write scopes, each tied to an implemented apply path: + 'DeviceManagementApps.ReadWrite.All' # M365 Apps, app protection + 'DeviceManagementServiceConfig.ReadWrite.All' # enrollment restrictions + 'DeviceManagementConfiguration.ReadWrite.All' # default compliance, device compliance policies + 'Policy.Read.All' # read Conditional Access policies + 'Policy.ReadWrite.ConditionalAccess' # device-based Conditional Access + 'Application.ReadWrite.All' # ensure the Intune enrollment service principal exists + ) + RequiredCommands = @( + 'Connect-MgGraph' + 'Get-MgDeviceManagement' + 'Update-MgDeviceManagement' + 'Invoke-MgGraphRequest' + 'Get-MgContext' + ) + } + + # The source guide instructs the reader to assign policies to all users. + # The toolkit deliberately diverges and defaults to a pilot group, because + # enrollment restrictions, compliance enforcement, and Conditional Access + # can deny access to every user in the tenant on a first run. Tenant-wide + # assignment is an explicit operator opt-in. + Assignment = @{ + DefaultScope = 'PilotGroup' + RequirePilotGroupForHighRisk = $true + # Named for what it actually controls: whether a high-risk run may go + # tenant-wide. It is not an absolute veto and must not be named like + # one, because a config author would otherwise rely on it as a hard + # stop that the command line can lift. + AllowTenantWideAssignmentForHighRisk = $false + } + + # Candidate payloads normalized from the contributor implementation. + # Runtime loading verifies file hashes and rejects export-only metadata. + # Every entry remains blocked for apply until its API, permission, pilot, + # rollback, and recovery gates are complete. + PolicyCatalog = @{ + ManifestPath = 'Config/PolicyCatalog/catalog.psd1' + RequiredEntryCount = 19 + } + + # These are explicit where Microsoft does not expose a stable, + # least-privilege, unattended path suitable for automation. + Preflight = @{ + ManagedGooglePlayDisposition = 'GuidedOnly' + WindowsAutoEnrollmentDisposition = 'GuidedOnly' + EnterpriseStateRoamingDisposition = 'GuidedOnly' + EmergencyAccessDisposition = 'GuidedOnly' + } + + # Renew before expiry. Microsoft documents a 365-day certificate lifetime + # and a 30-day post-expiry grace period; the toolkit uses the same interval + # as an earlier warning window rather than waiting for management disruption. + ApplePushCertificate = @{ + RenewalWarningDays = 30 + } + + # Conditional Access is the highest blast-radius item in the baseline. + # A policy requiring a compliant device, applied before devices are enrolled + # and evaluated, denies access and can lock administrators out. + ConditionalAccess = @{ + DefaultState = 'enabledForReportingButNotEnforced' + RequireBreakGlassExclusions = $true + EnrollmentAppId = 'd4ebce55-015a-49b5-a083-c84d1797ae8c' + DisplayName = 'Require MFA and compliant device for Intune enrollment' + } + + Report = @{ + OutputDirectory = '.\Reports' + JsonLogFileName = 'intune-run-log.json' + HtmlReportFileName = 'intune-run-report.html' + } + + # Microsoft 365 Apps for Windows (guide task 8). The payload matches the + # source guide: Monthly Enterprise Channel and Open Document Format as the + # default. Values live here so an operator can retune the deployment + # without editing module code. + AppDeployment = @{ + DisplayName = 'Microsoft 365 Apps for Windows' + Publisher = 'Microsoft' + Architecture = 'x64' + UpdateChannel = 'monthlyEnterprise' + DefaultFileFormat = 'officeOpenDocumentFormat' + UseSharedComputerActivation = $false + Locales = @('en-us') + ProductIds = @('o365ProPlusRetail') + ExcludedApps = @('groove', 'lync') + AssignmentIntent = 'required' + } + + # Tenant-wide default compliance behavior (guide task 4). secureByDefault + # treats a device with no targeted compliance policy as noncompliant. This + # is High risk: it becomes an access denial once compliant-device + # Conditional Access is enforced, so the write is gated behind + # IncludeHighRisk + EnableComplianceEnforcement. + DefaultCompliance = @{ + CheckinThresholdDays = 30 + } + + # App protection policies for core Microsoft apps (guide task 6), Level 1 + # basic data protection. Standard risk: MAM protects app data and denies no + # device access. Settings mirror the source guide's L1 baseline. Apps are + # the current core Microsoft mobile app identifiers. + AppProtection = @{ + Ios = @{ + OdataType = '#microsoft.graph.iosManagedAppProtection' + DisplayName = 'iOS - App Protection Baseline (L1)' + AppIdentifierType = '#microsoft.graph.iosMobileAppIdentifier' + AppIdentifierKey = 'bundleId' + Apps = @( + 'com.microsoft.Office.Outlook', 'com.microsoft.skype.teams', + 'com.microsoft.Office.Word', 'com.microsoft.Office.Excel', + 'com.microsoft.Office.Powerpoint', 'com.microsoft.skydrive', + 'com.microsoft.onenote', 'com.microsoft.msedge' + ) + Settings = @{ + periodOfflineBeforeAccessCheck = 'PT12H' + periodOnlineBeforeAccessCheck = 'PT30M' + allowedInboundDataTransferSources = 'allApps' + allowedOutboundDataTransferDestinations = 'managedApps' + organizationalCredentialsRequired = $false + allowedOutboundClipboardSharingLevel = 'managedAppsWithPasteIn' + dataBackupBlocked = $true + deviceComplianceRequired = $false + managedBrowserToOpenLinksRequired = $false + saveAsBlocked = $true + periodOfflineBeforeWipeIsEnforced = 'P90D' + pinRequired = $true + maximumPinRetries = 5 + simplePinBlocked = $false + minimumPinLength = 4 + pinCharacterSet = 'numeric' + allowedDataStorageLocations = @('oneDriveForBusiness', 'sharePoint') + contactSyncBlocked = $false + printBlocked = $false + fingerprintBlocked = $false + disableAppPinIfDevicePinIsSet = $true + faceIdBlocked = $false + appDataEncryptionType = 'whenDeviceLocked' + } + } + Android = @{ + OdataType = '#microsoft.graph.androidManagedAppProtection' + DisplayName = 'Android - App Protection Baseline (L1)' + AppIdentifierType = '#microsoft.graph.androidMobileAppIdentifier' + AppIdentifierKey = 'packageId' + Apps = @( + 'com.microsoft.office.outlook', 'com.microsoft.teams', + 'com.microsoft.office.word', 'com.microsoft.office.excel', + 'com.microsoft.office.powerpoint', 'com.microsoft.skydrive', + 'com.microsoft.office.onenote', 'com.microsoft.emmx' + ) + Settings = @{ + periodOfflineBeforeAccessCheck = 'PT12H' + periodOnlineBeforeAccessCheck = 'PT30M' + allowedInboundDataTransferSources = 'allApps' + allowedOutboundDataTransferDestinations = 'managedApps' + organizationalCredentialsRequired = $false + allowedOutboundClipboardSharingLevel = 'managedAppsWithPasteIn' + dataBackupBlocked = $true + deviceComplianceRequired = $false + managedBrowserToOpenLinksRequired = $false + saveAsBlocked = $true + periodOfflineBeforeWipeIsEnforced = 'P90D' + pinRequired = $true + maximumPinRetries = 5 + simplePinBlocked = $false + minimumPinLength = 4 + pinCharacterSet = 'numeric' + allowedDataStorageLocations = @('oneDriveForBusiness', 'sharePoint') + contactSyncBlocked = $false + printBlocked = $false + fingerprintBlocked = $false + disableAppEncryptionIfDeviceEncryptionIsEnabled = $false + encryptAppData = $true + screenCaptureBlocked = $false + } + } + } + + # Per-platform device compliance policies (guide task 7). High risk: + # creating a compliance policy can mark existing devices noncompliant, and + # if compliant-device Conditional Access is enforced those users lose + # access, so the write is gated behind IncludeHighRisk (the orchestrator + # records the item in Context.WriteBlockedItemKeys when it is absent). The + # Windows payload includes Secure Boot and code integrity per the source + # guide; every payload carries its own scheduledActionsForRule. + DeviceCompliance = @{ + PayloadDirectory = 'Config/CompliancePayloads' + } + + # Device platform enrollment restrictions (guide task 5). High risk: a + # misconfigured restriction can block the enrollment the rest of the + # baseline depends on, so the write is gated behind IncludeHighRisk + + # EnableEnrollmentRestrictions. Safe by default: the shipped restrictions + # block only personally owned enrollment on used platforms (corporate + # enrollment continues to work). Set PlatformBlocked = $true to block a + # platform outright. Assignment follows the orchestrator's verified + # PilotGroup or explicitly approved TenantWide scope. + EnrollmentRestrictions = @{ + Restrictions = @( + @{ + DisplayName = 'Block personal iOS/iPadOS enrollment' + PlatformType = 'ios' + PlatformBlocked = $false + PersonalDeviceEnrollmentBlocked = $true + } + @{ + DisplayName = 'Block personal Android enrollment' + PlatformType = 'android' + PlatformBlocked = $false + PersonalDeviceEnrollmentBlocked = $true + } + ) + } +} diff --git a/Products/Intune/Config/PolicyCatalog/Advanced/android-fully-managed-compliance.json b/Products/Intune/Config/PolicyCatalog/Advanced/android-fully-managed-compliance.json new file mode 100644 index 0000000..eb9293a --- /dev/null +++ b/Products/Intune/Config/PolicyCatalog/Advanced/android-fully-managed-compliance.json @@ -0,0 +1,39 @@ +{ + "@odata.type": "#microsoft.graph.androidDeviceOwnerCompliancePolicy", + "description": "This compliance policy is the security configuration recommended as a standard for organization owned devices where users access more sensitive information.", + "displayName": "Android Fully Managed - Advanced security compliance", + "scheduledActionsForRule": [ + { + "ruleName": "PasswordRequired", + "scheduledActionConfigurations": [ + { + "actionType": "block", + "gracePeriodHours": 0, + "notificationTemplateId": "" + } + ] + } + ], + "deviceThreatProtectionEnabled": false, + "deviceThreatProtectionRequiredSecurityLevel": "unavailable", + "advancedThreatProtectionRequiredSecurityLevel": "unavailable", + "securityRequireSafetyNetAttestationBasicIntegrity": true, + "securityRequireSafetyNetAttestationCertifiedDevice": true, + "osMinimumVersion": "9.0", + "osMaximumVersion": null, + "minAndroidSecurityPatchLevel": null, + "passwordRequired": true, + "passwordMinimumLength": 6, + "passwordMinimumLetterCharacters": null, + "passwordMinimumLowerCaseCharacters": null, + "passwordMinimumNonLetterCharacters": null, + "passwordMinimumNumericCharacters": null, + "passwordMinimumSymbolCharacters": null, + "passwordMinimumUpperCaseCharacters": null, + "passwordRequiredType": "numericComplex", + "passwordMinutesOfInactivityBeforeLock": 5, + "passwordExpirationDays": 365, + "passwordPreviousPasswordCountToBlock": 5, + "storageRequireEncryption": true, + "securityRequireIntuneAppIntegrity": true +} diff --git a/Products/Intune/Config/PolicyCatalog/Advanced/android-fully-managed-configuration.json b/Products/Intune/Config/PolicyCatalog/Advanced/android-fully-managed-configuration.json new file mode 100644 index 0000000..3e2db85 --- /dev/null +++ b/Products/Intune/Config/PolicyCatalog/Advanced/android-fully-managed-configuration.json @@ -0,0 +1,135 @@ +{ + "@odata.type": "#microsoft.graph.androidDeviceOwnerGeneralDeviceConfiguration", + "deviceManagementApplicabilityRuleOsEdition": null, + "deviceManagementApplicabilityRuleOsVersion": null, + "deviceManagementApplicabilityRuleDeviceMode": null, + "description": "This configuration policy is the security configuration recommended as a standard for organization owned devices where users access more sensitive information.", + "displayName": "Android Fully Managed - Advanced security configuration", + "accountsBlockModification": null, + "appsAllowInstallFromUnknownSources": null, + "appsAutoUpdatePolicy": "wiFiOnly", + "appsDefaultPermissionPolicy": null, + "appsRecommendSkippingFirstUseHints": null, + "bluetoothBlockConfiguration": null, + "bluetoothBlockContactSharing": null, + "cameraBlocked": null, + "cellularBlockWiFiTethering": null, + "certificateCredentialConfigurationDisabled": true, + "crossProfilePoliciesAllowCopyPaste": null, + "crossProfilePoliciesAllowDataSharing": null, + "crossProfilePoliciesShowWorkContactsInPersonalProfile": null, + "microsoftLauncherConfigurationEnabled": null, + "microsoftLauncherCustomWallpaperEnabled": null, + "microsoftLauncherCustomWallpaperImageUrl": null, + "microsoftLauncherCustomWallpaperAllowUserModification": null, + "microsoftLauncherFeedEnabled": null, + "microsoftLauncherFeedAllowUserModification": null, + "microsoftLauncherDockPresenceConfiguration": null, + "microsoftLauncherDockPresenceAllowUserModification": null, + "microsoftLauncherSearchBarPlacementConfiguration": null, + "enrollmentProfile": "notConfigured", + "dataRoamingBlocked": null, + "dateTimeConfigurationBlocked": null, + "factoryResetDeviceAdministratorEmails": [], + "factoryResetBlocked": true, + "globalProxy": null, + "googleAccountsBlocked": true, + "kioskCustomizationDeviceSettingsBlocked": null, + "kioskCustomizationPowerButtonActionsBlocked": null, + "kioskCustomizationStatusBar": "notConfigured", + "kioskCustomizationSystemErrorWarnings": null, + "kioskCustomizationSystemNavigation": "notConfigured", + "kioskModeScreenSaverConfigurationEnabled": null, + "kioskModeScreenSaverImageUrl": null, + "kioskModeScreenSaverDisplayTimeInSeconds": null, + "kioskModeScreenSaverStartDelayInSeconds": null, + "kioskModeScreenSaverDetectMediaDisabled": null, + "kioskModeWallpaperUrl": null, + "kioskModeExitCode": null, + "kioskModeVirtualHomeButtonEnabled": null, + "kioskModeVirtualHomeButtonType": null, + "kioskModeBluetoothConfigurationEnabled": null, + "kioskModeWiFiConfigurationEnabled": null, + "kioskModeFlashlightConfigurationEnabled": null, + "kioskModeMediaVolumeConfigurationEnabled": null, + "kioskModeShowDeviceInfo": null, + "kioskModeManagedSettingsEntryDisabled": null, + "kioskModeDebugMenuEasyAccessEnabled": null, + "kioskModeShowAppNotificationBadge": null, + "kioskModeScreenOrientation": null, + "kioskModeIconSize": null, + "kioskModeFolderIcon": null, + "kioskModeWifiAllowedSsids": [], + "kioskModeAppOrderEnabled": null, + "kioskModeAppsInFolderOrderedByName": null, + "kioskModeGridHeight": null, + "kioskModeGridWidth": null, + "kioskModeLockHomeScreen": null, + "kioskModeManagedHomeScreenAutoSignout": null, + "kioskModeManagedHomeScreenInactiveSignOutDelayInSeconds": null, + "kioskModeManagedHomeScreenInactiveSignOutNoticeInSeconds": null, + "kioskModeManagedHomeScreenPinComplexity": null, + "kioskModeManagedHomeScreenPinRequired": null, + "kioskModeManagedHomeScreenPinRequiredToResume": null, + "kioskModeManagedHomeScreenSignInBackground": null, + "kioskModeManagedHomeScreenSignInBrandingLogo": null, + "kioskModeManagedHomeScreenSignInEnabled": null, + "microphoneForceMute": null, + "networkEscapeHatchAllowed": null, + "nfcBlockOutgoingBeam": null, + "passwordBlockKeyguard": null, + "passwordBlockKeyguardFeatures": [], + "passwordExpirationDays": 365, + "passwordMinimumLength": 6, + "passwordMinimumLetterCharacters": null, + "passwordMinimumLowerCaseCharacters": null, + "passwordMinimumNonLetterCharacters": null, + "passwordMinimumNumericCharacters": null, + "passwordMinimumSymbolCharacters": null, + "passwordMinimumUpperCaseCharacters": null, + "passwordMinutesOfInactivityBeforeScreenTimeout": 5, + "passwordPreviousPasswordCountToBlock": 5, + "passwordRequiredType": "numericComplex", + "passwordSignInFailureCountBeforeFactoryReset": 5, + "playStoreMode": null, + "screenCaptureBlocked": null, + "securityDeveloperSettingsEnabled": null, + "securityRequireVerifyApps": true, + "statusBarBlocked": null, + "stayOnModes": [], + "storageAllowUsb": null, + "storageBlockExternalMedia": true, + "storageBlockUsbFileTransfer": true, + "systemUpdateWindowStartMinutesAfterMidnight": null, + "systemUpdateWindowEndMinutesAfterMidnight": null, + "systemUpdateInstallType": "automatic", + "systemWindowsBlocked": null, + "usersBlockAdd": true, + "usersBlockRemove": true, + "volumeBlockAdjustment": null, + "vpnAlwaysOnLockdownMode": false, + "vpnAlwaysOnPackageIdentifier": "", + "wifiBlockEditConfigurations": null, + "wifiBlockEditPolicyDefinedConfigurations": null, + "personalProfileAppsAllowInstallFromUnknownSources": null, + "personalProfileCameraBlocked": null, + "personalProfileScreenCaptureBlocked": null, + "personalProfilePlayStoreMode": null, + "workProfilePasswordExpirationDays": 365, + "workProfilePasswordMinimumLength": 6, + "workProfilePasswordMinimumNumericCharacters": null, + "workProfilePasswordMinimumNonLetterCharacters": null, + "workProfilePasswordMinimumLetterCharacters": null, + "workProfilePasswordMinimumLowerCaseCharacters": null, + "workProfilePasswordMinimumUpperCaseCharacters": null, + "workProfilePasswordMinimumSymbolCharacters": null, + "workProfilePasswordPreviousPasswordCountToBlock": 5, + "workProfilePasswordSignInFailureCountBeforeFactoryReset": 10, + "workProfilePasswordRequiredType": "numericComplex", + "azureAdSharedDeviceDataClearApps": [], + "kioskModeApps": [], + "kioskModeManagedFolders": [], + "kioskModeAppPositions": [], + "systemUpdateFreezePeriods": [], + "personalProfilePersonalApplications": [] +} diff --git a/Products/Intune/Config/PolicyCatalog/Advanced/android-work-profile-compliance.json b/Products/Intune/Config/PolicyCatalog/Advanced/android-work-profile-compliance.json new file mode 100644 index 0000000..4446848 --- /dev/null +++ b/Products/Intune/Config/PolicyCatalog/Advanced/android-work-profile-compliance.json @@ -0,0 +1,41 @@ +{ + "@odata.type": "#microsoft.graph.androidWorkProfileCompliancePolicy", + "description": "This compliance policy is for devices used by specific users or groups who are uniquely high risk (for example, users who handle highly sensitive data where unauthorized disclosure causes considerable material loss to the organization).", + "displayName": "Android Work Profile - Advanced security compliance", + "scheduledActionsForRule": [ + { + "ruleName": "PasswordRequired", + "scheduledActionConfigurations": [ + { + "actionType": "block", + "gracePeriodHours": 0, + "notificationTemplateId": "" + } + ] + } + ], + "passwordRequired": true, + "passwordMinimumLength": 6, + "passwordRequiredType": "numericComplex", + "passwordMinutesOfInactivityBeforeLock": 5, + "passwordExpirationDays": 365, + "passwordPreviousPasswordBlockCount": 5, + "passwordSignInFailureCountBeforeFactoryReset": null, + "securityPreventInstallAppsFromUnknownSources": true, + "securityDisableUsbDebugging": true, + "securityRequireVerifyApps": false, + "deviceThreatProtectionEnabled": true, + "deviceThreatProtectionRequiredSecurityLevel": "secured", + "advancedThreatProtectionRequiredSecurityLevel": "secured", + "securityBlockJailbrokenDevices": true, + "osMinimumVersion": "11.0", + "osMaximumVersion": null, + "minAndroidSecurityPatchLevel": null, + "storageRequireEncryption": true, + "securityRequireSafetyNetAttestationBasicIntegrity": true, + "securityRequireSafetyNetAttestationCertifiedDevice": true, + "securityRequireGooglePlayServices": true, + "securityRequireUpToDateSecurityProviders": true, + "securityRequireCompanyPortalAppIntegrity": true, + "securityRequiredAndroidSafetyNetEvaluationType": "basic" +} diff --git a/Products/Intune/Config/PolicyCatalog/Advanced/android-work-profile-configuration.json b/Products/Intune/Config/PolicyCatalog/Advanced/android-work-profile-configuration.json new file mode 100644 index 0000000..96705e4 --- /dev/null +++ b/Products/Intune/Config/PolicyCatalog/Advanced/android-work-profile-configuration.json @@ -0,0 +1,51 @@ +{ + "@odata.type": "#microsoft.graph.androidWorkProfileGeneralDeviceConfiguration", + "deviceManagementApplicabilityRuleOsEdition": null, + "deviceManagementApplicabilityRuleOsVersion": null, + "deviceManagementApplicabilityRuleDeviceMode": null, + "description": "This device restriction configuration policy is for devices used by specific users or groups who are uniquely high risk (for example, users who handle highly sensitive data where unauthorized disclosure causes considerable material loss to the organization).", + "displayName": "Android Work Profile - Advanced security configuration", + "passwordBlockFaceUnlock": false, + "passwordBlockFingerprintUnlock": false, + "passwordBlockIrisUnlock": false, + "passwordBlockTrustAgents": false, + "passwordExpirationDays": 365, + "passwordMinimumLength": 6, + "passwordMinutesOfInactivityBeforeScreenTimeout": 5, + "passwordPreviousPasswordBlockCount": 5, + "passwordSignInFailureCountBeforeFactoryReset": 5, + "passwordRequiredType": "numericComplex", + "workProfileAllowAppInstallsFromUnknownSources": false, + "workProfileDataSharingType": "allowPersonalToWork", + "workProfileBlockNotificationsWhileDeviceLocked": true, + "workProfileBlockAddingAccounts": true, + "workProfileBluetoothEnableContactSharing": false, + "workProfileBlockScreenCapture": true, + "workProfileBlockCrossProfileCallerId": false, + "workProfileBlockCamera": false, + "workProfileBlockCrossProfileContactsSearch": true, + "workProfileBlockCrossProfileCopyPaste": true, + "workProfileDefaultAppPermissionPolicy": "deviceDefault", + "workProfilePasswordBlockFaceUnlock": false, + "workProfilePasswordBlockFingerprintUnlock": false, + "workProfilePasswordBlockIrisUnlock": false, + "workProfilePasswordBlockTrustAgents": true, + "workProfilePasswordExpirationDays": 365, + "workProfilePasswordMinimumLength": 6, + "workProfilePasswordMinNumericCharacters": null, + "workProfilePasswordMinNonLetterCharacters": null, + "workProfilePasswordMinLetterCharacters": null, + "workProfilePasswordMinLowerCaseCharacters": null, + "workProfilePasswordMinUpperCaseCharacters": null, + "workProfilePasswordMinSymbolCharacters": null, + "workProfilePasswordMinutesOfInactivityBeforeScreenTimeout": 5, + "workProfilePasswordPreviousPasswordBlockCount": 5, + "workProfilePasswordSignInFailureCountBeforeFactoryReset": 5, + "workProfilePasswordRequiredType": "numericComplex", + "workProfileRequirePassword": true, + "securityRequireVerifyApps": true, + "vpnAlwaysOnPackageIdentifier": null, + "vpnEnableAlwaysOnLockdownMode": false, + "workProfileAllowWidgets": false, + "workProfileBlockPersonalAppInstallsFromUnknownSources": true +} diff --git a/Products/Intune/Config/PolicyCatalog/Advanced/ios-compliance.json b/Products/Intune/Config/PolicyCatalog/Advanced/ios-compliance.json new file mode 100644 index 0000000..ee9f300 --- /dev/null +++ b/Products/Intune/Config/PolicyCatalog/Advanced/ios-compliance.json @@ -0,0 +1,36 @@ +{ + "@odata.type": "#microsoft.graph.iosCompliancePolicy", + "description": "This compliance policy is for devices used by specific users or groups who are uniquely high risk (for example, users who handle highly sensitive data where unauthorized disclosure causes considerable material loss to the organization).", + "displayName": "iOS - Advanced security compliance", + "scheduledActionsForRule": [ + { + "ruleName": "PasswordRequired", + "scheduledActionConfigurations": [ + { + "actionType": "block", + "gracePeriodHours": 0, + "notificationTemplateId": "" + } + ] + } + ], + "passcodeBlockSimple": true, + "passcodeExpirationDays": 365, + "passcodeMinimumLength": 6, + "passcodeMinutesOfInactivityBeforeLock": 5, + "passcodeMinutesOfInactivityBeforeScreenTimeout": 5, + "passcodePreviousPasscodeBlockCount": null, + "passcodeMinimumCharacterSetCount": null, + "passcodeRequiredType": "numeric", + "passcodeRequired": true, + "osMinimumVersion": "15.0", + "osMaximumVersion": null, + "osMinimumBuildVersion": null, + "osMaximumBuildVersion": null, + "securityBlockJailbrokenDevices": true, + "deviceThreatProtectionEnabled": true, + "deviceThreatProtectionRequiredSecurityLevel": "secured", + "advancedThreatProtectionRequiredSecurityLevel": "secured", + "managedEmailProfileRequired": false, + "restrictedApps": [] +} diff --git a/Products/Intune/Config/PolicyCatalog/Advanced/ios-personal-configuration.json b/Products/Intune/Config/PolicyCatalog/Advanced/ios-personal-configuration.json new file mode 100644 index 0000000..7c42c27 --- /dev/null +++ b/Products/Intune/Config/PolicyCatalog/Advanced/ios-personal-configuration.json @@ -0,0 +1,206 @@ +{ + "@odata.type": "#microsoft.graph.iosGeneralDeviceConfiguration", + "supportsScopeTags": true, + "deviceManagementApplicabilityRuleOsEdition": null, + "deviceManagementApplicabilityRuleOsVersion": null, + "deviceManagementApplicabilityRuleDeviceMode": null, + "description": "This device restriction configuration policy introduces data transfer controls for managed apps. This is the configuration that is applicable to most mobile users accessing work or school data using personally owned devices.", + "displayName": "iOS Personal - Advanced security configuration", + "accountBlockModification": false, + "activationLockAllowWhenSupervised": false, + "airDropBlocked": false, + "airDropForceUnmanagedDropTarget": true, + "airPlayForcePairingPasswordForOutgoingRequests": false, + "appleWatchBlockPairing": false, + "appleWatchForceWristDetection": true, + "appleNewsBlocked": false, + "appsVisibilityListType": "none", + "appStoreBlockAutomaticDownloads": false, + "appStoreBlocked": false, + "appStoreBlockInAppPurchases": false, + "appStoreBlockUIAppInstallation": false, + "appStoreRequirePassword": false, + "autoFillForceAuthentication": false, + "bluetoothBlockModification": false, + "cameraBlocked": false, + "cellularBlockDataRoaming": false, + "cellularBlockGlobalBackgroundFetchWhileRoaming": false, + "cellularBlockPerAppDataModification": false, + "cellularBlockPersonalHotspot": false, + "cellularBlockPlanModification": false, + "cellularBlockVoiceRoaming": false, + "certificatesBlockUntrustedTlsCertificates": true, + "classroomAppBlockRemoteScreenObservation": false, + "classroomAppForceUnpromptedScreenObservation": false, + "classroomForceAutomaticallyJoinClasses": false, + "classroomForceUnpromptedAppAndDeviceLock": false, + "compliantAppListType": "none", + "configurationProfileBlockChanges": false, + "definitionLookupBlocked": false, + "deviceBlockEnableRestrictions": false, + "deviceBlockEraseContentAndSettings": false, + "deviceBlockNameModification": false, + "diagnosticDataBlockSubmission": true, + "diagnosticDataBlockSubmissionModification": false, + "documentsBlockManagedDocumentsInUnmanagedApps": true, + "documentsBlockUnmanagedDocumentsInManagedApps": false, + "emailInDomainSuffixes": [], + "enterpriseAppBlockTrust": true, + "enterpriseAppBlockTrustModification": false, + "esimBlockModification": false, + "faceTimeBlocked": false, + "findMyFriendsBlocked": false, + "gamingBlockGameCenterFriends": false, + "gamingBlockMultiplayer": false, + "gameCenterBlocked": false, + "hostPairingBlocked": false, + "iBooksStoreBlocked": false, + "iBooksStoreBlockErotica": false, + "iCloudBlockActivityContinuation": false, + "iCloudBlockBackup": false, + "iCloudBlockDocumentSync": false, + "iCloudBlockManagedAppsSync": true, + "iCloudBlockPhotoLibrary": false, + "iCloudBlockPhotoStreamSync": false, + "iCloudBlockSharedPhotoStream": false, + "iCloudRequireEncryptedBackup": true, + "iTunesBlockExplicitContent": false, + "iTunesBlockMusicService": false, + "iTunesBlockRadio": false, + "keyboardBlockAutoCorrect": false, + "keyboardBlockDictation": false, + "keyboardBlockPredictive": false, + "keyboardBlockShortcuts": false, + "keyboardBlockSpellCheck": false, + "kioskModeAllowAssistiveSpeak": false, + "kioskModeAllowAssistiveTouchSettings": false, + "kioskModeAllowAutoLock": false, + "kioskModeBlockAutoLock": false, + "kioskModeAllowColorInversionSettings": false, + "kioskModeAllowRingerSwitch": false, + "kioskModeBlockRingerSwitch": false, + "kioskModeAllowScreenRotation": false, + "kioskModeBlockScreenRotation": false, + "kioskModeAllowSleepButton": false, + "kioskModeBlockSleepButton": false, + "kioskModeAllowTouchscreen": false, + "kioskModeBlockTouchscreen": false, + "kioskModeEnableVoiceControl": false, + "kioskModeAllowVoiceControlModification": false, + "kioskModeAllowVoiceOverSettings": false, + "kioskModeAllowVolumeButtons": false, + "kioskModeBlockVolumeButtons": false, + "kioskModeAllowZoomSettings": false, + "kioskModeAppStoreUrl": null, + "kioskModeBuiltInAppId": null, + "kioskModeRequireAssistiveTouch": false, + "kioskModeRequireColorInversion": false, + "kioskModeRequireMonoAudio": false, + "kioskModeRequireVoiceOver": false, + "kioskModeRequireZoom": false, + "kioskModeManagedAppId": null, + "lockScreenBlockControlCenter": false, + "lockScreenBlockNotificationView": true, + "lockScreenBlockPassbook": false, + "lockScreenBlockTodayView": true, + "mediaContentRatingAustralia": null, + "mediaContentRatingCanada": null, + "mediaContentRatingFrance": null, + "mediaContentRatingGermany": null, + "mediaContentRatingIreland": null, + "mediaContentRatingJapan": null, + "mediaContentRatingNewZealand": null, + "mediaContentRatingUnitedKingdom": null, + "mediaContentRatingUnitedStates": null, + "mediaContentRatingApps": "allAllowed", + "messagesBlocked": false, + "notificationsBlockSettingsModification": false, + "passcodeBlockFingerprintUnlock": false, + "passcodeBlockFingerprintModification": false, + "passcodeBlockModification": false, + "passcodeBlockSimple": true, + "passcodeExpirationDays": null, + "passcodeMinimumLength": 6, + "passcodeMinutesOfInactivityBeforeLock": 5, + "passcodeMinutesOfInactivityBeforeScreenTimeout": 5, + "passcodeMinimumCharacterSetCount": null, + "passcodePreviousPasscodeBlockCount": null, + "passcodeSignInFailureCountBeforeWipe": 10, + "passcodeRequiredType": "numeric", + "passcodeRequired": true, + "podcastsBlocked": false, + "proximityBlockSetupToNewDevice": false, + "safariBlockAutofill": false, + "safariBlockJavaScript": false, + "safariBlockPopups": false, + "safariBlocked": false, + "safariCookieSettings": "browserDefault", + "safariManagedDomains": [], + "safariPasswordAutoFillDomains": [], + "safariRequireFraudWarning": true, + "screenCaptureBlocked": false, + "siriBlocked": false, + "siriBlockedWhenLocked": true, + "siriBlockUserGeneratedContent": false, + "siriRequireProfanityFilter": false, + "softwareUpdatesEnforcedDelayInDays": null, + "softwareUpdatesForceDelayed": false, + "spotlightBlockInternetResults": false, + "voiceDialingBlocked": true, + "wallpaperBlockModification": false, + "wiFiConnectOnlyToConfiguredNetworks": false, + "classroomForceRequestPermissionToLeaveClasses": false, + "keychainBlockCloudSync": false, + "pkiBlockOTAUpdates": false, + "privacyForceLimitAdTracking": false, + "enterpriseBookBlockBackup": true, + "enterpriseBookBlockMetadataSync": true, + "airPrintBlocked": false, + "airPrintBlockCredentialsStorage": false, + "airPrintForceTrustedTLS": false, + "airPrintBlockiBeaconDiscovery": false, + "filesNetworkDriveAccessBlocked": false, + "filesUsbDriveAccessBlocked": false, + "wifiPowerOnForced": false, + "blockSystemAppRemoval": false, + "vpnBlockCreation": false, + "appRemovalBlocked": false, + "usbRestrictedModeBlocked": false, + "passwordBlockAutoFill": false, + "passwordBlockProximityRequests": false, + "passwordBlockAirDropSharing": false, + "dateAndTimeForceSetAutomatically": false, + "contactsAllowManagedToUnmanagedWrite": true, + "contactsAllowUnmanagedToManagedRead": false, + "cellularBlockPersonalHotspotModification": false, + "continuousPathKeyboardBlocked": false, + "findMyDeviceInFindMyAppBlocked": false, + "findMyFriendsInFindMyAppBlocked": false, + "iTunesBlocked": false, + "sharedDeviceBlockTemporarySessions": false, + "appClipsBlocked": false, + "applePersonalizedAdsBlocked": false, + "nfcBlocked": false, + "autoUnlockBlocked": false, + "unpairedExternalBootToRecoveryAllowed": false, + "onDeviceOnlyDictationForced": true, + "wiFiConnectToAllowedNetworksOnlyForced": false, + "onDeviceOnlyTranslationForced": true, + "managedPasteboardRequired": false, + "kioskModeAppType": "notConfigured", + "appsSingleAppModeList": [], + "appsVisibilityList": [], + "compliantAppsList": [], + "networkUsageRules": [ + { + "cellularDataBlockWhenRoaming": false, + "cellularDataBlocked": false, + "managedApps": [] + }, + { + "cellularDataBlockWhenRoaming": false, + "cellularDataBlocked": false, + "managedApps": [] + } + ] +} diff --git a/Products/Intune/Config/PolicyCatalog/Advanced/ios-supervised-configuration.json b/Products/Intune/Config/PolicyCatalog/Advanced/ios-supervised-configuration.json new file mode 100644 index 0000000..1a3367c --- /dev/null +++ b/Products/Intune/Config/PolicyCatalog/Advanced/ios-supervised-configuration.json @@ -0,0 +1,206 @@ +{ + "@odata.type": "#microsoft.graph.iosGeneralDeviceConfiguration", + "supportsScopeTags": true, + "deviceManagementApplicabilityRuleOsEdition": null, + "deviceManagementApplicabilityRuleOsVersion": null, + "deviceManagementApplicabilityRuleDeviceMode": null, + "description": "This device restriction configuration policy introduces data transfer controls for managed apps. This is the configuration that is applicable to most mobile users accessing work or school data using supervised devices.", + "displayName": "iOS Supervised - Advanced security configuration", + "accountBlockModification": false, + "activationLockAllowWhenSupervised": true, + "airDropBlocked": false, + "airDropForceUnmanagedDropTarget": true, + "airPlayForcePairingPasswordForOutgoingRequests": false, + "appleWatchBlockPairing": false, + "appleWatchForceWristDetection": true, + "appleNewsBlocked": false, + "appsVisibilityListType": "none", + "appStoreBlockAutomaticDownloads": false, + "appStoreBlocked": false, + "appStoreBlockInAppPurchases": false, + "appStoreBlockUIAppInstallation": false, + "appStoreRequirePassword": false, + "autoFillForceAuthentication": true, + "bluetoothBlockModification": false, + "cameraBlocked": false, + "cellularBlockDataRoaming": false, + "cellularBlockGlobalBackgroundFetchWhileRoaming": false, + "cellularBlockPerAppDataModification": false, + "cellularBlockPersonalHotspot": false, + "cellularBlockPlanModification": false, + "cellularBlockVoiceRoaming": false, + "certificatesBlockUntrustedTlsCertificates": true, + "classroomAppBlockRemoteScreenObservation": false, + "classroomAppForceUnpromptedScreenObservation": false, + "classroomForceAutomaticallyJoinClasses": false, + "classroomForceUnpromptedAppAndDeviceLock": false, + "compliantAppListType": "none", + "configurationProfileBlockChanges": false, + "definitionLookupBlocked": false, + "deviceBlockEnableRestrictions": false, + "deviceBlockEraseContentAndSettings": false, + "deviceBlockNameModification": false, + "diagnosticDataBlockSubmission": true, + "diagnosticDataBlockSubmissionModification": false, + "documentsBlockManagedDocumentsInUnmanagedApps": true, + "documentsBlockUnmanagedDocumentsInManagedApps": false, + "emailInDomainSuffixes": [], + "enterpriseAppBlockTrust": true, + "enterpriseAppBlockTrustModification": false, + "esimBlockModification": false, + "faceTimeBlocked": false, + "findMyFriendsBlocked": false, + "gamingBlockGameCenterFriends": false, + "gamingBlockMultiplayer": false, + "gameCenterBlocked": false, + "hostPairingBlocked": false, + "iBooksStoreBlocked": false, + "iBooksStoreBlockErotica": false, + "iCloudBlockActivityContinuation": false, + "iCloudBlockBackup": false, + "iCloudBlockDocumentSync": true, + "iCloudBlockManagedAppsSync": true, + "iCloudBlockPhotoLibrary": false, + "iCloudBlockPhotoStreamSync": false, + "iCloudBlockSharedPhotoStream": false, + "iCloudRequireEncryptedBackup": true, + "iTunesBlockExplicitContent": false, + "iTunesBlockMusicService": false, + "iTunesBlockRadio": false, + "keyboardBlockAutoCorrect": false, + "keyboardBlockDictation": false, + "keyboardBlockPredictive": false, + "keyboardBlockShortcuts": false, + "keyboardBlockSpellCheck": false, + "kioskModeAllowAssistiveSpeak": false, + "kioskModeAllowAssistiveTouchSettings": false, + "kioskModeAllowAutoLock": false, + "kioskModeBlockAutoLock": false, + "kioskModeAllowColorInversionSettings": false, + "kioskModeAllowRingerSwitch": false, + "kioskModeBlockRingerSwitch": false, + "kioskModeAllowScreenRotation": false, + "kioskModeBlockScreenRotation": false, + "kioskModeAllowSleepButton": false, + "kioskModeBlockSleepButton": false, + "kioskModeAllowTouchscreen": false, + "kioskModeBlockTouchscreen": false, + "kioskModeEnableVoiceControl": false, + "kioskModeAllowVoiceControlModification": false, + "kioskModeAllowVoiceOverSettings": false, + "kioskModeAllowVolumeButtons": false, + "kioskModeBlockVolumeButtons": false, + "kioskModeAllowZoomSettings": false, + "kioskModeAppStoreUrl": null, + "kioskModeBuiltInAppId": null, + "kioskModeRequireAssistiveTouch": false, + "kioskModeRequireColorInversion": false, + "kioskModeRequireMonoAudio": false, + "kioskModeRequireVoiceOver": false, + "kioskModeRequireZoom": false, + "kioskModeManagedAppId": null, + "lockScreenBlockControlCenter": false, + "lockScreenBlockNotificationView": true, + "lockScreenBlockPassbook": false, + "lockScreenBlockTodayView": true, + "mediaContentRatingAustralia": null, + "mediaContentRatingCanada": null, + "mediaContentRatingFrance": null, + "mediaContentRatingGermany": null, + "mediaContentRatingIreland": null, + "mediaContentRatingJapan": null, + "mediaContentRatingNewZealand": null, + "mediaContentRatingUnitedKingdom": null, + "mediaContentRatingUnitedStates": null, + "mediaContentRatingApps": "allAllowed", + "messagesBlocked": false, + "notificationsBlockSettingsModification": false, + "passcodeBlockFingerprintUnlock": false, + "passcodeBlockFingerprintModification": false, + "passcodeBlockModification": false, + "passcodeBlockSimple": true, + "passcodeExpirationDays": null, + "passcodeMinimumLength": 6, + "passcodeMinutesOfInactivityBeforeLock": 5, + "passcodeMinutesOfInactivityBeforeScreenTimeout": 5, + "passcodeMinimumCharacterSetCount": null, + "passcodePreviousPasscodeBlockCount": null, + "passcodeSignInFailureCountBeforeWipe": 10, + "passcodeRequiredType": "numeric", + "passcodeRequired": true, + "podcastsBlocked": false, + "proximityBlockSetupToNewDevice": true, + "safariBlockAutofill": false, + "safariBlockJavaScript": false, + "safariBlockPopups": false, + "safariBlocked": false, + "safariCookieSettings": "browserDefault", + "safariManagedDomains": [], + "safariPasswordAutoFillDomains": [], + "safariRequireFraudWarning": true, + "screenCaptureBlocked": false, + "siriBlocked": false, + "siriBlockedWhenLocked": true, + "siriBlockUserGeneratedContent": false, + "siriRequireProfanityFilter": false, + "softwareUpdatesEnforcedDelayInDays": null, + "softwareUpdatesForceDelayed": false, + "spotlightBlockInternetResults": false, + "voiceDialingBlocked": true, + "wallpaperBlockModification": false, + "wiFiConnectOnlyToConfiguredNetworks": false, + "classroomForceRequestPermissionToLeaveClasses": false, + "keychainBlockCloudSync": true, + "pkiBlockOTAUpdates": false, + "privacyForceLimitAdTracking": false, + "enterpriseBookBlockBackup": true, + "enterpriseBookBlockMetadataSync": true, + "airPrintBlocked": false, + "airPrintBlockCredentialsStorage": true, + "airPrintForceTrustedTLS": true, + "airPrintBlockiBeaconDiscovery": true, + "filesNetworkDriveAccessBlocked": false, + "filesUsbDriveAccessBlocked": true, + "wifiPowerOnForced": false, + "blockSystemAppRemoval": false, + "vpnBlockCreation": false, + "appRemovalBlocked": false, + "usbRestrictedModeBlocked": false, + "passwordBlockAutoFill": false, + "passwordBlockProximityRequests": true, + "passwordBlockAirDropSharing": true, + "dateAndTimeForceSetAutomatically": false, + "contactsAllowManagedToUnmanagedWrite": true, + "contactsAllowUnmanagedToManagedRead": false, + "cellularBlockPersonalHotspotModification": false, + "continuousPathKeyboardBlocked": false, + "findMyDeviceInFindMyAppBlocked": false, + "findMyFriendsInFindMyAppBlocked": false, + "iTunesBlocked": false, + "sharedDeviceBlockTemporarySessions": false, + "appClipsBlocked": false, + "applePersonalizedAdsBlocked": false, + "nfcBlocked": false, + "autoUnlockBlocked": false, + "unpairedExternalBootToRecoveryAllowed": false, + "onDeviceOnlyDictationForced": true, + "wiFiConnectToAllowedNetworksOnlyForced": false, + "onDeviceOnlyTranslationForced": true, + "managedPasteboardRequired": true, + "kioskModeAppType": "notConfigured", + "appsSingleAppModeList": [], + "appsVisibilityList": [], + "compliantAppsList": [], + "networkUsageRules": [ + { + "cellularDataBlockWhenRoaming": false, + "cellularDataBlocked": false, + "managedApps": [] + }, + { + "cellularDataBlockWhenRoaming": false, + "cellularDataBlocked": false, + "managedApps": [] + } + ] +} diff --git a/Products/Intune/Config/PolicyCatalog/Baseline/android-fully-managed-compliance.json b/Products/Intune/Config/PolicyCatalog/Baseline/android-fully-managed-compliance.json new file mode 100644 index 0000000..7c1d1eb --- /dev/null +++ b/Products/Intune/Config/PolicyCatalog/Baseline/android-fully-managed-compliance.json @@ -0,0 +1,39 @@ +{ + "@odata.type": "#microsoft.graph.androidDeviceOwnerCompliancePolicy", + "description": "This is the recommended minimum security configuration for an enterprise mobile device owned by the organization.", + "displayName": "Android Fully Managed - Baseline security compliance", + "scheduledActionsForRule": [ + { + "ruleName": "PasswordRequired", + "scheduledActionConfigurations": [ + { + "actionType": "block", + "gracePeriodHours": 0, + "notificationTemplateId": "" + } + ] + } + ], + "deviceThreatProtectionEnabled": false, + "deviceThreatProtectionRequiredSecurityLevel": "unavailable", + "advancedThreatProtectionRequiredSecurityLevel": "unavailable", + "securityRequireSafetyNetAttestationBasicIntegrity": true, + "securityRequireSafetyNetAttestationCertifiedDevice": true, + "osMinimumVersion": "9.0", + "osMaximumVersion": null, + "minAndroidSecurityPatchLevel": null, + "passwordRequired": true, + "passwordMinimumLength": 6, + "passwordMinimumLetterCharacters": null, + "passwordMinimumLowerCaseCharacters": null, + "passwordMinimumNonLetterCharacters": null, + "passwordMinimumNumericCharacters": null, + "passwordMinimumSymbolCharacters": null, + "passwordMinimumUpperCaseCharacters": null, + "passwordRequiredType": "numericComplex", + "passwordMinutesOfInactivityBeforeLock": 5, + "passwordExpirationDays": null, + "passwordPreviousPasswordCountToBlock": null, + "storageRequireEncryption": true, + "securityRequireIntuneAppIntegrity": true +} diff --git a/Products/Intune/Config/PolicyCatalog/Baseline/android-fully-managed-configuration.json b/Products/Intune/Config/PolicyCatalog/Baseline/android-fully-managed-configuration.json new file mode 100644 index 0000000..bd1fb3a --- /dev/null +++ b/Products/Intune/Config/PolicyCatalog/Baseline/android-fully-managed-configuration.json @@ -0,0 +1,135 @@ +{ + "@odata.type": "#microsoft.graph.androidDeviceOwnerGeneralDeviceConfiguration", + "deviceManagementApplicabilityRuleOsEdition": null, + "deviceManagementApplicabilityRuleOsVersion": null, + "deviceManagementApplicabilityRuleDeviceMode": null, + "description": "This is the recommended minimum security configuration for an enterprise mobile device owned by the organization.", + "displayName": "Android Fully Managed - Baseline security configuration", + "accountsBlockModification": null, + "appsAllowInstallFromUnknownSources": null, + "appsAutoUpdatePolicy": "wiFiOnly", + "appsDefaultPermissionPolicy": null, + "appsRecommendSkippingFirstUseHints": null, + "bluetoothBlockConfiguration": null, + "bluetoothBlockContactSharing": null, + "cameraBlocked": null, + "cellularBlockWiFiTethering": null, + "certificateCredentialConfigurationDisabled": true, + "crossProfilePoliciesAllowCopyPaste": null, + "crossProfilePoliciesAllowDataSharing": null, + "crossProfilePoliciesShowWorkContactsInPersonalProfile": null, + "microsoftLauncherConfigurationEnabled": null, + "microsoftLauncherCustomWallpaperEnabled": null, + "microsoftLauncherCustomWallpaperImageUrl": null, + "microsoftLauncherCustomWallpaperAllowUserModification": null, + "microsoftLauncherFeedEnabled": null, + "microsoftLauncherFeedAllowUserModification": null, + "microsoftLauncherDockPresenceConfiguration": null, + "microsoftLauncherDockPresenceAllowUserModification": null, + "microsoftLauncherSearchBarPlacementConfiguration": null, + "enrollmentProfile": "notConfigured", + "dataRoamingBlocked": null, + "dateTimeConfigurationBlocked": null, + "factoryResetDeviceAdministratorEmails": [], + "factoryResetBlocked": true, + "globalProxy": null, + "googleAccountsBlocked": null, + "kioskCustomizationDeviceSettingsBlocked": null, + "kioskCustomizationPowerButtonActionsBlocked": null, + "kioskCustomizationStatusBar": "notConfigured", + "kioskCustomizationSystemErrorWarnings": null, + "kioskCustomizationSystemNavigation": "notConfigured", + "kioskModeScreenSaverConfigurationEnabled": null, + "kioskModeScreenSaverImageUrl": null, + "kioskModeScreenSaverDisplayTimeInSeconds": null, + "kioskModeScreenSaverStartDelayInSeconds": null, + "kioskModeScreenSaverDetectMediaDisabled": null, + "kioskModeWallpaperUrl": null, + "kioskModeExitCode": null, + "kioskModeVirtualHomeButtonEnabled": null, + "kioskModeVirtualHomeButtonType": null, + "kioskModeBluetoothConfigurationEnabled": null, + "kioskModeWiFiConfigurationEnabled": null, + "kioskModeFlashlightConfigurationEnabled": null, + "kioskModeMediaVolumeConfigurationEnabled": null, + "kioskModeShowDeviceInfo": null, + "kioskModeManagedSettingsEntryDisabled": null, + "kioskModeDebugMenuEasyAccessEnabled": null, + "kioskModeShowAppNotificationBadge": null, + "kioskModeScreenOrientation": null, + "kioskModeIconSize": null, + "kioskModeFolderIcon": null, + "kioskModeWifiAllowedSsids": [], + "kioskModeAppOrderEnabled": null, + "kioskModeAppsInFolderOrderedByName": null, + "kioskModeGridHeight": null, + "kioskModeGridWidth": null, + "kioskModeLockHomeScreen": null, + "kioskModeManagedHomeScreenAutoSignout": null, + "kioskModeManagedHomeScreenInactiveSignOutDelayInSeconds": null, + "kioskModeManagedHomeScreenInactiveSignOutNoticeInSeconds": null, + "kioskModeManagedHomeScreenPinComplexity": null, + "kioskModeManagedHomeScreenPinRequired": null, + "kioskModeManagedHomeScreenPinRequiredToResume": null, + "kioskModeManagedHomeScreenSignInBackground": null, + "kioskModeManagedHomeScreenSignInBrandingLogo": null, + "kioskModeManagedHomeScreenSignInEnabled": null, + "microphoneForceMute": null, + "networkEscapeHatchAllowed": null, + "nfcBlockOutgoingBeam": null, + "passwordBlockKeyguard": null, + "passwordBlockKeyguardFeatures": [], + "passwordExpirationDays": null, + "passwordMinimumLength": 6, + "passwordMinimumLetterCharacters": null, + "passwordMinimumLowerCaseCharacters": null, + "passwordMinimumNonLetterCharacters": null, + "passwordMinimumNumericCharacters": null, + "passwordMinimumSymbolCharacters": null, + "passwordMinimumUpperCaseCharacters": null, + "passwordMinutesOfInactivityBeforeScreenTimeout": 5, + "passwordPreviousPasswordCountToBlock": null, + "passwordRequiredType": "numericComplex", + "passwordSignInFailureCountBeforeFactoryReset": 10, + "playStoreMode": null, + "screenCaptureBlocked": null, + "securityDeveloperSettingsEnabled": null, + "securityRequireVerifyApps": true, + "statusBarBlocked": null, + "stayOnModes": [], + "storageAllowUsb": null, + "storageBlockExternalMedia": true, + "storageBlockUsbFileTransfer": true, + "systemUpdateWindowStartMinutesAfterMidnight": null, + "systemUpdateWindowEndMinutesAfterMidnight": null, + "systemUpdateInstallType": "automatic", + "systemWindowsBlocked": null, + "usersBlockAdd": null, + "usersBlockRemove": null, + "volumeBlockAdjustment": null, + "vpnAlwaysOnLockdownMode": false, + "vpnAlwaysOnPackageIdentifier": "", + "wifiBlockEditConfigurations": null, + "wifiBlockEditPolicyDefinedConfigurations": null, + "personalProfileAppsAllowInstallFromUnknownSources": null, + "personalProfileCameraBlocked": null, + "personalProfileScreenCaptureBlocked": null, + "personalProfilePlayStoreMode": null, + "workProfilePasswordExpirationDays": null, + "workProfilePasswordMinimumLength": 6, + "workProfilePasswordMinimumNumericCharacters": null, + "workProfilePasswordMinimumNonLetterCharacters": null, + "workProfilePasswordMinimumLetterCharacters": null, + "workProfilePasswordMinimumLowerCaseCharacters": null, + "workProfilePasswordMinimumUpperCaseCharacters": null, + "workProfilePasswordMinimumSymbolCharacters": null, + "workProfilePasswordPreviousPasswordCountToBlock": null, + "workProfilePasswordSignInFailureCountBeforeFactoryReset": 10, + "workProfilePasswordRequiredType": "numericComplex", + "azureAdSharedDeviceDataClearApps": [], + "kioskModeApps": [], + "kioskModeManagedFolders": [], + "kioskModeAppPositions": [], + "systemUpdateFreezePeriods": [], + "personalProfilePersonalApplications": [] +} diff --git a/Products/Intune/Config/PolicyCatalog/Baseline/android-work-profile-compliance.json b/Products/Intune/Config/PolicyCatalog/Baseline/android-work-profile-compliance.json new file mode 100644 index 0000000..2d93e6f --- /dev/null +++ b/Products/Intune/Config/PolicyCatalog/Baseline/android-work-profile-compliance.json @@ -0,0 +1,41 @@ +{ + "@odata.type": "#microsoft.graph.androidWorkProfileCompliancePolicy", + "description": "This compliance policy is the minimum security configuration for personal devices where users access work or school data. This configuration is applicable to most mobile users.", + "displayName": "Android Work Profile - Baseline security compliance", + "scheduledActionsForRule": [ + { + "ruleName": "PasswordRequired", + "scheduledActionConfigurations": [ + { + "actionType": "block", + "gracePeriodHours": 0, + "notificationTemplateId": "" + } + ] + } + ], + "passwordRequired": true, + "passwordMinimumLength": 6, + "passwordRequiredType": "numericComplex", + "passwordMinutesOfInactivityBeforeLock": 5, + "passwordExpirationDays": null, + "passwordPreviousPasswordBlockCount": null, + "passwordSignInFailureCountBeforeFactoryReset": null, + "securityPreventInstallAppsFromUnknownSources": true, + "securityDisableUsbDebugging": true, + "securityRequireVerifyApps": false, + "deviceThreatProtectionEnabled": false, + "deviceThreatProtectionRequiredSecurityLevel": "unavailable", + "advancedThreatProtectionRequiredSecurityLevel": "unavailable", + "securityBlockJailbrokenDevices": true, + "osMinimumVersion": "9.0", + "osMaximumVersion": null, + "minAndroidSecurityPatchLevel": null, + "storageRequireEncryption": true, + "securityRequireSafetyNetAttestationBasicIntegrity": true, + "securityRequireSafetyNetAttestationCertifiedDevice": true, + "securityRequireGooglePlayServices": true, + "securityRequireUpToDateSecurityProviders": true, + "securityRequireCompanyPortalAppIntegrity": true, + "securityRequiredAndroidSafetyNetEvaluationType": "hardwareBacked" +} diff --git a/Products/Intune/Config/PolicyCatalog/Baseline/android-work-profile-configuration.json b/Products/Intune/Config/PolicyCatalog/Baseline/android-work-profile-configuration.json new file mode 100644 index 0000000..f6cbb7f --- /dev/null +++ b/Products/Intune/Config/PolicyCatalog/Baseline/android-work-profile-configuration.json @@ -0,0 +1,51 @@ +{ + "@odata.type": "#microsoft.graph.androidWorkProfileGeneralDeviceConfiguration", + "deviceManagementApplicabilityRuleOsEdition": null, + "deviceManagementApplicabilityRuleOsVersion": null, + "deviceManagementApplicabilityRuleDeviceMode": null, + "description": "This device restriction configuration policy is the minimum security configuration for personally owned devices where users access work or school data. This configuration is applicable to most mobile users.", + "displayName": "Android Work Profile - Baseline security configuration", + "passwordBlockFaceUnlock": false, + "passwordBlockFingerprintUnlock": false, + "passwordBlockIrisUnlock": false, + "passwordBlockTrustAgents": false, + "passwordExpirationDays": null, + "passwordMinimumLength": 6, + "passwordMinutesOfInactivityBeforeScreenTimeout": 5, + "passwordPreviousPasswordBlockCount": null, + "passwordSignInFailureCountBeforeFactoryReset": 10, + "passwordRequiredType": "numericComplex", + "workProfileAllowAppInstallsFromUnknownSources": false, + "workProfileDataSharingType": "allowPersonalToWork", + "workProfileBlockNotificationsWhileDeviceLocked": false, + "workProfileBlockAddingAccounts": true, + "workProfileBluetoothEnableContactSharing": true, + "workProfileBlockScreenCapture": true, + "workProfileBlockCrossProfileCallerId": false, + "workProfileBlockCamera": false, + "workProfileBlockCrossProfileContactsSearch": false, + "workProfileBlockCrossProfileCopyPaste": true, + "workProfileDefaultAppPermissionPolicy": "deviceDefault", + "workProfilePasswordBlockFaceUnlock": false, + "workProfilePasswordBlockFingerprintUnlock": false, + "workProfilePasswordBlockIrisUnlock": false, + "workProfilePasswordBlockTrustAgents": false, + "workProfilePasswordExpirationDays": null, + "workProfilePasswordMinimumLength": 6, + "workProfilePasswordMinNumericCharacters": null, + "workProfilePasswordMinNonLetterCharacters": null, + "workProfilePasswordMinLetterCharacters": null, + "workProfilePasswordMinLowerCaseCharacters": null, + "workProfilePasswordMinUpperCaseCharacters": null, + "workProfilePasswordMinSymbolCharacters": null, + "workProfilePasswordMinutesOfInactivityBeforeScreenTimeout": 5, + "workProfilePasswordPreviousPasswordBlockCount": null, + "workProfilePasswordSignInFailureCountBeforeFactoryReset": 10, + "workProfilePasswordRequiredType": "numericComplex", + "workProfileRequirePassword": true, + "securityRequireVerifyApps": true, + "vpnAlwaysOnPackageIdentifier": null, + "vpnEnableAlwaysOnLockdownMode": false, + "workProfileAllowWidgets": true, + "workProfileBlockPersonalAppInstallsFromUnknownSources": false +} diff --git a/Products/Intune/Config/PolicyCatalog/Baseline/ios-compliance.json b/Products/Intune/Config/PolicyCatalog/Baseline/ios-compliance.json new file mode 100644 index 0000000..c104bf5 --- /dev/null +++ b/Products/Intune/Config/PolicyCatalog/Baseline/ios-compliance.json @@ -0,0 +1,36 @@ +{ + "@odata.type": "#microsoft.graph.iosCompliancePolicy", + "description": "This compliance policy is the minimum security configuration for personal and supervised devices where users access work or school data. This configuration is applicable to most mobile users.", + "displayName": "iOS - Baseline security compliance", + "scheduledActionsForRule": [ + { + "ruleName": "PasswordRequired", + "scheduledActionConfigurations": [ + { + "actionType": "block", + "gracePeriodHours": 0, + "notificationTemplateId": "" + } + ] + } + ], + "passcodeBlockSimple": true, + "passcodeExpirationDays": null, + "passcodeMinimumLength": 6, + "passcodeMinutesOfInactivityBeforeLock": 5, + "passcodeMinutesOfInactivityBeforeScreenTimeout": 5, + "passcodePreviousPasscodeBlockCount": null, + "passcodeMinimumCharacterSetCount": null, + "passcodeRequiredType": "numeric", + "passcodeRequired": true, + "osMinimumVersion": "14.8", + "osMaximumVersion": null, + "osMinimumBuildVersion": null, + "osMaximumBuildVersion": null, + "securityBlockJailbrokenDevices": true, + "deviceThreatProtectionEnabled": false, + "deviceThreatProtectionRequiredSecurityLevel": "unavailable", + "advancedThreatProtectionRequiredSecurityLevel": "unavailable", + "managedEmailProfileRequired": false, + "restrictedApps": [] +} diff --git a/Products/Intune/Config/PolicyCatalog/Baseline/ios-personal-configuration.json b/Products/Intune/Config/PolicyCatalog/Baseline/ios-personal-configuration.json new file mode 100644 index 0000000..ccddd1e --- /dev/null +++ b/Products/Intune/Config/PolicyCatalog/Baseline/ios-personal-configuration.json @@ -0,0 +1,169 @@ +{ + "name": "iOS Personal - Baseline security configuration", + "description": "This device restriction configuration policy is the minimum security configuration for personally owned devices where users access work or school data.", + "platforms": "iOS", + "technologies": "mdm,appleRemoteManagement", + "roleScopeTagIds": [ + "0" + ], + "settings": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationGroupSettingCollectionInstance", + "settingDefinitionId": "com.apple.applicationaccess_com.apple.applicationaccess", + "groupSettingCollectionValue": [ + { + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "com.apple.applicationaccess_allowenterpriseapptrust", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "com.apple.applicationaccess_allowenterpriseapptrust_true", + "children": [] + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "com.apple.applicationaccess_allowlockscreennotificationsview", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "com.apple.applicationaccess_allowlockscreennotificationsview_true", + "children": [] + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "com.apple.applicationaccess_allowlockscreentodayview", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "com.apple.applicationaccess_allowlockscreentodayview_true", + "children": [] + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "com.apple.applicationaccess_allowmanagedappscloudsync", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "com.apple.applicationaccess_allowmanagedappscloudsync_true", + "children": [] + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "com.apple.applicationaccess_allowuntrustedtlsprompt", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "com.apple.applicationaccess_allowuntrustedtlsprompt_true", + "children": [] + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "com.apple.applicationaccess_forceairdropunmanaged", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "com.apple.applicationaccess_forceairdropunmanaged_true", + "children": [] + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "com.apple.applicationaccess_forceencryptedbackup", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "com.apple.applicationaccess_forceencryptedbackup_true", + "children": [] + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "com.apple.applicationaccess_forcewatchwristdetection", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "com.apple.applicationaccess_forcewatchwristdetection_true", + "children": [] + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "com.apple.applicationaccess_safariforcefraudwarning", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "com.apple.applicationaccess_safariforcefraudwarning_true", + "children": [] + } + } + ] + } + ] + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationGroupSettingCollectionInstance", + "settingDefinitionId": "com.apple.mobiledevice.passwordpolicy_com.apple.mobiledevice.passwordpolicy", + "groupSettingCollectionValue": [ + { + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "com.apple.mobiledevice.passwordpolicy_allowsimple", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "com.apple.mobiledevice.passwordpolicy_allowsimple_true", + "children": [] + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance", + "settingDefinitionId": "com.apple.mobiledevice.passwordpolicy_maxfailedattempts", + "simpleSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationIntegerSettingValue", + "value": 11 + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance", + "settingDefinitionId": "com.apple.mobiledevice.passwordpolicy_maxgraceperiod", + "simpleSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationIntegerSettingValue", + "value": 5 + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance", + "settingDefinitionId": "com.apple.mobiledevice.passwordpolicy_maxinactivity", + "simpleSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationIntegerSettingValue", + "value": 5 + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance", + "settingDefinitionId": "com.apple.mobiledevice.passwordpolicy_minlength", + "simpleSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationIntegerSettingValue", + "value": 6 + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "com.apple.mobiledevice.passwordpolicy_requirealphanumeric", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "com.apple.mobiledevice.passwordpolicy_requirealphanumeric_false", + "children": [] + } + } + ] + } + ] + } + } + ], + "@odata.context": "https://graph.microsoft.com/beta/$metadata#deviceManagement/configurationPolicies/$entity" +} diff --git a/Products/Intune/Config/PolicyCatalog/Baseline/ios-supervised-configuration.json b/Products/Intune/Config/PolicyCatalog/Baseline/ios-supervised-configuration.json new file mode 100644 index 0000000..82efb8b --- /dev/null +++ b/Products/Intune/Config/PolicyCatalog/Baseline/ios-supervised-configuration.json @@ -0,0 +1,206 @@ +{ + "@odata.type": "#microsoft.graph.iosGeneralDeviceConfiguration", + "supportsScopeTags": true, + "deviceManagementApplicabilityRuleOsEdition": null, + "deviceManagementApplicabilityRuleOsVersion": null, + "deviceManagementApplicabilityRuleDeviceMode": null, + "description": "This device restriction configuration policy is the minimum security configuration for corporate owned supervised devices where users access work or school data.", + "displayName": "iOS Supervised - Baseline security configuration", + "accountBlockModification": false, + "activationLockAllowWhenSupervised": true, + "airDropBlocked": false, + "airDropForceUnmanagedDropTarget": true, + "airPlayForcePairingPasswordForOutgoingRequests": false, + "appleWatchBlockPairing": false, + "appleWatchForceWristDetection": true, + "appleNewsBlocked": false, + "appsVisibilityListType": "none", + "appStoreBlockAutomaticDownloads": false, + "appStoreBlocked": false, + "appStoreBlockInAppPurchases": false, + "appStoreBlockUIAppInstallation": false, + "appStoreRequirePassword": false, + "autoFillForceAuthentication": true, + "bluetoothBlockModification": false, + "cameraBlocked": false, + "cellularBlockDataRoaming": false, + "cellularBlockGlobalBackgroundFetchWhileRoaming": false, + "cellularBlockPerAppDataModification": false, + "cellularBlockPersonalHotspot": false, + "cellularBlockPlanModification": false, + "cellularBlockVoiceRoaming": false, + "certificatesBlockUntrustedTlsCertificates": true, + "classroomAppBlockRemoteScreenObservation": false, + "classroomAppForceUnpromptedScreenObservation": false, + "classroomForceAutomaticallyJoinClasses": false, + "classroomForceUnpromptedAppAndDeviceLock": false, + "compliantAppListType": "none", + "configurationProfileBlockChanges": false, + "definitionLookupBlocked": false, + "deviceBlockEnableRestrictions": false, + "deviceBlockEraseContentAndSettings": false, + "deviceBlockNameModification": false, + "diagnosticDataBlockSubmission": false, + "diagnosticDataBlockSubmissionModification": false, + "documentsBlockManagedDocumentsInUnmanagedApps": false, + "documentsBlockUnmanagedDocumentsInManagedApps": false, + "emailInDomainSuffixes": [], + "enterpriseAppBlockTrust": true, + "enterpriseAppBlockTrustModification": false, + "esimBlockModification": false, + "faceTimeBlocked": false, + "findMyFriendsBlocked": false, + "gamingBlockGameCenterFriends": false, + "gamingBlockMultiplayer": false, + "gameCenterBlocked": false, + "hostPairingBlocked": false, + "iBooksStoreBlocked": false, + "iBooksStoreBlockErotica": false, + "iCloudBlockActivityContinuation": false, + "iCloudBlockBackup": false, + "iCloudBlockDocumentSync": false, + "iCloudBlockManagedAppsSync": true, + "iCloudBlockPhotoLibrary": false, + "iCloudBlockPhotoStreamSync": false, + "iCloudBlockSharedPhotoStream": false, + "iCloudRequireEncryptedBackup": true, + "iTunesBlockExplicitContent": false, + "iTunesBlockMusicService": false, + "iTunesBlockRadio": false, + "keyboardBlockAutoCorrect": false, + "keyboardBlockDictation": false, + "keyboardBlockPredictive": false, + "keyboardBlockShortcuts": false, + "keyboardBlockSpellCheck": false, + "kioskModeAllowAssistiveSpeak": false, + "kioskModeAllowAssistiveTouchSettings": false, + "kioskModeAllowAutoLock": false, + "kioskModeBlockAutoLock": false, + "kioskModeAllowColorInversionSettings": false, + "kioskModeAllowRingerSwitch": false, + "kioskModeBlockRingerSwitch": false, + "kioskModeAllowScreenRotation": false, + "kioskModeBlockScreenRotation": false, + "kioskModeAllowSleepButton": false, + "kioskModeBlockSleepButton": false, + "kioskModeAllowTouchscreen": false, + "kioskModeBlockTouchscreen": false, + "kioskModeEnableVoiceControl": false, + "kioskModeAllowVoiceControlModification": false, + "kioskModeAllowVoiceOverSettings": false, + "kioskModeAllowVolumeButtons": false, + "kioskModeBlockVolumeButtons": false, + "kioskModeAllowZoomSettings": false, + "kioskModeAppStoreUrl": null, + "kioskModeBuiltInAppId": null, + "kioskModeRequireAssistiveTouch": false, + "kioskModeRequireColorInversion": false, + "kioskModeRequireMonoAudio": false, + "kioskModeRequireVoiceOver": false, + "kioskModeRequireZoom": false, + "kioskModeManagedAppId": null, + "lockScreenBlockControlCenter": false, + "lockScreenBlockNotificationView": true, + "lockScreenBlockPassbook": false, + "lockScreenBlockTodayView": true, + "mediaContentRatingAustralia": null, + "mediaContentRatingCanada": null, + "mediaContentRatingFrance": null, + "mediaContentRatingGermany": null, + "mediaContentRatingIreland": null, + "mediaContentRatingJapan": null, + "mediaContentRatingNewZealand": null, + "mediaContentRatingUnitedKingdom": null, + "mediaContentRatingUnitedStates": null, + "mediaContentRatingApps": "allAllowed", + "messagesBlocked": false, + "notificationsBlockSettingsModification": false, + "passcodeBlockFingerprintUnlock": false, + "passcodeBlockFingerprintModification": false, + "passcodeBlockModification": false, + "passcodeBlockSimple": true, + "passcodeExpirationDays": null, + "passcodeMinimumLength": 6, + "passcodeMinutesOfInactivityBeforeLock": 5, + "passcodeMinutesOfInactivityBeforeScreenTimeout": 5, + "passcodeMinimumCharacterSetCount": null, + "passcodePreviousPasscodeBlockCount": null, + "passcodeSignInFailureCountBeforeWipe": 10, + "passcodeRequiredType": "numeric", + "passcodeRequired": true, + "podcastsBlocked": false, + "proximityBlockSetupToNewDevice": true, + "safariBlockAutofill": false, + "safariBlockJavaScript": false, + "safariBlockPopups": false, + "safariBlocked": false, + "safariCookieSettings": "browserDefault", + "safariManagedDomains": [], + "safariPasswordAutoFillDomains": [], + "safariRequireFraudWarning": true, + "screenCaptureBlocked": false, + "siriBlocked": false, + "siriBlockedWhenLocked": true, + "siriBlockUserGeneratedContent": false, + "siriRequireProfanityFilter": false, + "softwareUpdatesEnforcedDelayInDays": null, + "softwareUpdatesForceDelayed": false, + "spotlightBlockInternetResults": false, + "voiceDialingBlocked": true, + "wallpaperBlockModification": false, + "wiFiConnectOnlyToConfiguredNetworks": false, + "classroomForceRequestPermissionToLeaveClasses": false, + "keychainBlockCloudSync": true, + "pkiBlockOTAUpdates": false, + "privacyForceLimitAdTracking": false, + "enterpriseBookBlockBackup": false, + "enterpriseBookBlockMetadataSync": false, + "airPrintBlocked": false, + "airPrintBlockCredentialsStorage": true, + "airPrintForceTrustedTLS": true, + "airPrintBlockiBeaconDiscovery": true, + "filesNetworkDriveAccessBlocked": false, + "filesUsbDriveAccessBlocked": false, + "wifiPowerOnForced": false, + "blockSystemAppRemoval": false, + "vpnBlockCreation": false, + "appRemovalBlocked": false, + "usbRestrictedModeBlocked": false, + "passwordBlockAutoFill": false, + "passwordBlockProximityRequests": true, + "passwordBlockAirDropSharing": true, + "dateAndTimeForceSetAutomatically": false, + "contactsAllowManagedToUnmanagedWrite": false, + "contactsAllowUnmanagedToManagedRead": false, + "cellularBlockPersonalHotspotModification": false, + "continuousPathKeyboardBlocked": false, + "findMyDeviceInFindMyAppBlocked": false, + "findMyFriendsInFindMyAppBlocked": false, + "iTunesBlocked": false, + "sharedDeviceBlockTemporarySessions": false, + "appClipsBlocked": false, + "applePersonalizedAdsBlocked": false, + "nfcBlocked": false, + "autoUnlockBlocked": false, + "unpairedExternalBootToRecoveryAllowed": false, + "onDeviceOnlyDictationForced": false, + "wiFiConnectToAllowedNetworksOnlyForced": false, + "onDeviceOnlyTranslationForced": false, + "managedPasteboardRequired": false, + "kioskModeAppType": "notConfigured", + "appsSingleAppModeList": [], + "appsVisibilityList": [], + "compliantAppsList": [], + "networkUsageRules": [ + { + "cellularDataBlockWhenRoaming": false, + "cellularDataBlocked": false, + "managedApps": [] + }, + { + "cellularDataBlockWhenRoaming": false, + "cellularDataBlocked": false, + "managedApps": [] + } + ] +} diff --git a/Products/Intune/Config/PolicyCatalog/SOURCE.md b/Products/Intune/Config/PolicyCatalog/SOURCE.md new file mode 100644 index 0000000..2cba912 --- /dev/null +++ b/Products/Intune/Config/PolicyCatalog/SOURCE.md @@ -0,0 +1,36 @@ +# Intune policy catalog source record + +The 19 JSON payloads in this directory were normalized from an existing Intune +implementation for use as candidate configuration data. + +The source archive linked its mobile policies to Microsoft's Intune protection +and configuration levels, and linked its Windows policies to the +[Intune ACSC Windows Hardening Guidelines](https://github.com/microsoft/Intune-ACSC-Windows-Hardening-Guidelines). + +Normalization made these repository-safe changes: + +- Removed root-level exported object IDs, creation timestamps, modification + timestamps, creation source, priority metadata, setting counts, and version + fields. +- Added the Settings Catalog `@odata.context` discriminator to the iOS + Personal baseline, Windows Edge hardening, and Windows security baseline + payloads. The source script skipped those three files because it could not + map them to a write command. +- Corrected the iOS Personal Settings Catalog root property from `displayName` + to the required `name`. +- Removed a placeholder Factory Reset Protection account from the Android + Fully Managed Advanced payload. +- Renamed files to stable lowercase names without spaces. +- Preserved nested setting-definition IDs and template references required to + describe Settings Catalog content. +- Recorded the original and normalized SHA-256 digest for each payload in + `catalog.psd1`. Normalized digests use canonical LF line endings so the same + reviewed content verifies on Windows, macOS, and Linux. + +The source archive did not contain Android or iOS app protection payloads. It +contained app protection command mappings only, so this catalog does not supply +the payloads required for a future app protection write. + +All entries remain `ApplyStatus = 'Blocked'`. Importing this catalog does not +authorize Microsoft Graph writes, assignment, tenant-wide targeting, adoption, +replacement, or deletion. diff --git a/Products/Intune/Config/PolicyCatalog/Windows/windows-all-hardening.json b/Products/Intune/Config/PolicyCatalog/Windows/windows-all-hardening.json new file mode 100644 index 0000000..186afa3 --- /dev/null +++ b/Products/Intune/Config/PolicyCatalog/Windows/windows-all-hardening.json @@ -0,0 +1,3928 @@ +{ + "@odata.context": "https://graph.microsoft.com/beta/$metadata#deviceManagement/configurationPolicies/$entity", + "description": "Refer to https://github.com/microsoft/Intune-ACSC-Windows-Hardening-Guidelines", + "name": "Windows - Hardening Guidelines", + "platforms": "windows10", + "roleScopeTagIds": [ + "0" + ], + "technologies": "mdm", + "templateReference": { + "templateId": "", + "templateFamily": "none", + "templateDisplayName": null, + "templateDisplayVersion": null + }, + "settings": [ + { + "id": "0", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "user_vendor_msft_policy_config_admx_controlpaneldisplay_cpl_personalization_enablescreensaver", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "user_vendor_msft_policy_config_admx_controlpaneldisplay_cpl_personalization_enablescreensaver_1", + "children": [] + } + } + }, + { + "id": "1", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "user_vendor_msft_policy_config_admx_controlpaneldisplay_cpl_personalization_screensaverissecure", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "user_vendor_msft_policy_config_admx_controlpaneldisplay_cpl_personalization_screensaverissecure_1", + "children": [] + } + } + }, + { + "id": "2", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_devicelock_preventenablinglockscreencamera", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_devicelock_preventenablinglockscreencamera_1", + "children": [] + } + } + }, + { + "id": "3", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_devicelock_preventlockscreenslideshow", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_devicelock_preventlockscreenslideshow_1", + "children": [] + } + } + }, + { + "id": "4", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "user_vendor_msft_policy_config_admx_controlpaneldisplay_cpl_personalization_screensavertimeout", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "user_vendor_msft_policy_config_admx_controlpaneldisplay_cpl_personalization_screensavertimeout_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance", + "settingDefinitionId": "user_vendor_msft_policy_config_admx_controlpaneldisplay_cpl_personalization_screensavertimeout_screensavertimeoutfreqspin", + "settingInstanceTemplateReference": null, + "simpleSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationIntegerSettingValue", + "settingValueTemplateReference": null, + "value": 900 + } + } + ] + } + } + }, + { + "id": "5", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_mssecurityguide_applyuacrestrictionstolocalaccountsonnetworklogon", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_mssecurityguide_applyuacrestrictionstolocalaccountsonnetworklogon_1", + "children": [] + } + } + }, + { + "id": "6", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_mssecurityguide_configuresmbv1clientdriver", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_mssecurityguide_configuresmbv1clientdriver_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_mssecurityguide_configuresmbv1clientdriver_pol_secguide_smb1clientdriver", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_mssecurityguide_configuresmbv1clientdriver_pol_secguide_smb1clientdriver_4", + "children": [] + } + } + ] + } + } + }, + { + "id": "7", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_mssecurityguide_configuresmbv1server", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_mssecurityguide_configuresmbv1server_0", + "children": [] + } + } + }, + { + "id": "8", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_mssecurityguide_enablestructuredexceptionhandlingoverwriteprotection", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_mssecurityguide_enablestructuredexceptionhandlingoverwriteprotection_1", + "children": [] + } + } + }, + { + "id": "9", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_msslegacy_ipv6sourceroutingprotectionlevel", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_msslegacy_ipv6sourceroutingprotectionlevel_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_msslegacy_ipv6sourceroutingprotectionlevel_disableipsourceroutingipv6", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_msslegacy_ipv6sourceroutingprotectionlevel_disableipsourceroutingipv6_2", + "children": [] + } + } + ] + } + } + }, + { + "id": "10", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_msslegacy_ipsourceroutingprotectionlevel", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_msslegacy_ipsourceroutingprotectionlevel_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_msslegacy_ipsourceroutingprotectionlevel_disableipsourcerouting", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_msslegacy_ipsourceroutingprotectionlevel_disableipsourcerouting_2", + "children": [] + } + } + ] + } + } + }, + { + "id": "11", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_msslegacy_allowicmpredirectstooverrideospfgeneratedroutes", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_msslegacy_allowicmpredirectstooverrideospfgeneratedroutes_0", + "children": [] + } + } + }, + { + "id": "12", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_msslegacy_allowthecomputertoignorenetbiosnamereleaserequestsexceptfromwinsservers", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_msslegacy_allowthecomputertoignorenetbiosnamereleaserequestsexceptfromwinsservers_1", + "children": [] + } + } + }, + { + "id": "13", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_dnsclient_turn_off_multicast", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_dnsclient_turn_off_multicast_1", + "children": [] + } + } + }, + { + "id": "14", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_connectivity_prohibitinstallationandconfigurationofnetworkbridge", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_connectivity_prohibitinstallationandconfigurationofnetworkbridge_1", + "children": [] + } + } + }, + { + "id": "15", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_networkconnections_nc_showsharedaccessui", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_networkconnections_nc_showsharedaccessui_1", + "children": [] + } + } + }, + { + "id": "16", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_networkconnections_nc_forcetunneling", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_networkconnections_nc_forcetunneling_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_networkconnections_nc_forcetunneling_stateselect", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_networkconnections_nc_forcetunneling_stateselect_enabled", + "children": [] + } + } + ] + } + } + }, + { + "id": "17", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_connectivity_hardeneduncpaths", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_connectivity_hardeneduncpaths_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationGroupSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_connectivity_hardeneduncpaths_pol_hardenedpaths", + "settingInstanceTemplateReference": null, + "groupSettingCollectionValue": [ + { + "settingValueTemplateReference": null, + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_connectivity_hardeneduncpaths_pol_hardenedpaths_key", + "settingInstanceTemplateReference": null, + "simpleSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "\\\\*\\NETLOGON" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_connectivity_hardeneduncpaths_pol_hardenedpaths_value", + "settingInstanceTemplateReference": null, + "simpleSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "RequireMutualAuthentication=1,RequireIntegrity=1" + } + } + ] + }, + { + "settingValueTemplateReference": null, + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_connectivity_hardeneduncpaths_pol_hardenedpaths_key", + "settingInstanceTemplateReference": null, + "simpleSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "\\\\*\\SYSVOL" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_connectivity_hardeneduncpaths_pol_hardenedpaths_value", + "settingInstanceTemplateReference": null, + "simpleSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "RequireMutualAuthentication=1,RequireIntegrity=1" + } + } + ] + } + ] + } + ] + } + } + }, + { + "id": "18", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_windowsconnectionmanager_prohitconnectiontonondomainnetworkswhenconnectedtodomainauthenticatednetwork", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_windowsconnectionmanager_prohitconnectiontonondomainnetworkswhenconnectedtodomainauthenticatednetwork_1", + "children": [] + } + } + }, + { + "id": "19", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "user_vendor_msft_policy_config_admx_wpn_notoastnotification", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "user_vendor_msft_policy_config_admx_wpn_notoastnotification_1", + "children": [] + } + } + }, + { + "id": "20", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_auditsettings_includecmdline", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_auditsettings_includecmdline_1", + "children": [] + } + } + }, + { + "id": "21", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_credentialsdelegation_remotehostallowsdelegationofnonexportablecredentials", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_credentialsdelegation_remotehostallowsdelegationofnonexportablecredentials_1", + "children": [] + } + } + }, + { + "id": "22", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_deviceinstallation_preventinstallationofmatchingdeviceids", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_deviceinstallation_preventinstallationofmatchingdeviceids_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_deviceinstallation_preventinstallationofmatchingdeviceids_deviceinstall_ids_deny_retroactive", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_deviceinstallation_preventinstallationofmatchingdeviceids_deviceinstall_ids_deny_retroactive_1", + "children": [] + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_deviceinstallation_preventinstallationofmatchingdeviceids_deviceinstall_ids_deny_list", + "settingInstanceTemplateReference": null, + "simpleSettingCollectionValue": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "PCI\\CC_0C0010" + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "PCI\\CC_0C0A" + } + ] + } + ] + } + } + }, + { + "id": "23", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_deviceinstallation_preventinstallationofmatchingdevicesetupclasses", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_deviceinstallation_preventinstallationofmatchingdevicesetupclasses_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_deviceinstallation_preventinstallationofmatchingdevicesetupclasses_deviceinstall_classes_deny_retroactive", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_deviceinstallation_preventinstallationofmatchingdevicesetupclasses_deviceinstall_classes_deny_retroactive_1", + "children": [] + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_deviceinstallation_preventinstallationofmatchingdevicesetupclasses_deviceinstall_classes_deny_list", + "settingInstanceTemplateReference": null, + "simpleSettingCollectionValue": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "{d48179be-ec20-11d1-b6b8-00c04fa372a7}" + } + ] + } + ] + } + } + }, + { + "id": "24", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_system_bootstartdriverinitialization", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_system_bootstartdriverinitialization_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_system_bootstartdriverinitialization_selectdriverloadpolicy", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_system_bootstartdriverinitialization_selectdriverloadpolicy_1", + "children": [] + } + } + ] + } + } + }, + { + "id": "25", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_grouppolicy_cse_registry", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_grouppolicy_cse_registry_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_grouppolicy_cse_registry_cse_nobackground10", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_grouppolicy_cse_registry_cse_nobackground10_0", + "children": [] + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_grouppolicy_cse_registry_cse_nochanges10", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_grouppolicy_cse_registry_cse_nochanges10_1", + "children": [] + } + } + ] + } + } + }, + { + "id": "26", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_grouppolicy_cse_security", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_grouppolicy_cse_security_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_grouppolicy_cse_security_cse_nobackground11", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_grouppolicy_cse_security_cse_nobackground11_0", + "children": [] + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_grouppolicy_cse_security_cse_nochanges11", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_grouppolicy_cse_security_cse_nochanges11_1", + "children": [] + } + } + ] + } + } + }, + { + "id": "27", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_grouppolicy_disablebackgroundpolicy", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_grouppolicy_disablebackgroundpolicy_0", + "children": [] + } + } + }, + { + "id": "28", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_grouppolicy_disablelgpoprocessing", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_grouppolicy_disablelgpoprocessing_1", + "children": [] + } + } + }, + { + "id": "29", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_grouppolicy_rsoplogging", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_grouppolicy_rsoplogging_1", + "children": [] + } + } + }, + { + "id": "30", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_icm_shellnousestoreopenwith_2", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_icm_shellnousestoreopenwith_2_1", + "children": [] + } + } + }, + { + "id": "31", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_credentialproviders_allowdomaindelaylock", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_credentialproviders_allowdomaindelaylock_0", + "children": [] + } + } + }, + { + "id": "32", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_windowslogon_dontdisplaynetworkselectionui", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_windowslogon_dontdisplaynetworkselectionui_1", + "children": [] + } + } + }, + { + "id": "33", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_logon_disableexplorerrunlegacy_2", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_logon_disableexplorerrunlegacy_2_1", + "children": [] + } + } + }, + { + "id": "34", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_logon_disableexplorerrunoncelegacy_2", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_logon_disableexplorerrunoncelegacy_2_1", + "children": [] + } + } + }, + { + "id": "35", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_windowslogon_enumeratelocalusersondomainjoinedcomputers", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_windowslogon_enumeratelocalusersondomainjoinedcomputers_0", + "children": [] + } + } + }, + { + "id": "36", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_logon_run_2", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_logon_run_2_0", + "children": [] + } + } + }, + { + "id": "37", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_windowslogon_disablelockscreenappnotifications", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_windowslogon_disablelockscreenappnotifications_1", + "children": [] + } + } + }, + { + "id": "38", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_credentialproviders_blockpicturepassword", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_credentialproviders_blockpicturepassword_1", + "children": [] + } + } + }, + { + "id": "39", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_credentialproviders_allowpinlogon", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_credentialproviders_allowpinlogon_0", + "children": [] + } + } + }, + { + "id": "40", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_power_allowstandbystateswhensleepingonbattery", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_power_allowstandbystateswhensleepingonbattery_0", + "children": [] + } + } + }, + { + "id": "41", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_power_allowstandbywhensleepingpluggedin", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_power_allowstandbywhensleepingpluggedin_0", + "children": [] + } + } + }, + { + "id": "42", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_power_requirepasswordwhencomputerwakesonbattery", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_power_requirepasswordwhencomputerwakesonbattery_1", + "children": [] + } + } + }, + { + "id": "43", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_power_requirepasswordwhencomputerwakespluggedin", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_power_requirepasswordwhencomputerwakespluggedin_1", + "children": [] + } + } + }, + { + "id": "44", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_power_hibernatetimeoutonbattery", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_power_hibernatetimeoutonbattery_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_power_hibernatetimeoutonbattery_enterdchibernatetimeout", + "settingInstanceTemplateReference": null, + "simpleSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationIntegerSettingValue", + "settingValueTemplateReference": null, + "value": 0 + } + } + ] + } + } + }, + { + "id": "45", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_power_hibernatetimeoutpluggedin", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_power_hibernatetimeoutpluggedin_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_power_hibernatetimeoutpluggedin_enterachibernatetimeout", + "settingInstanceTemplateReference": null, + "simpleSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationIntegerSettingValue", + "settingValueTemplateReference": null, + "value": 0 + } + } + ] + } + } + }, + { + "id": "46", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_power_standbytimeoutonbattery", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_power_standbytimeoutonbattery_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_power_standbytimeoutonbattery_enterdcstandbytimeout", + "settingInstanceTemplateReference": null, + "simpleSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationIntegerSettingValue", + "settingValueTemplateReference": null, + "value": 0 + } + } + ] + } + } + }, + { + "id": "47", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_power_standbytimeoutpluggedin", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_power_standbytimeoutpluggedin_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_power_standbytimeoutpluggedin_enteracstandbytimeout", + "settingInstanceTemplateReference": null, + "simpleSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationIntegerSettingValue", + "settingValueTemplateReference": null, + "value": 0 + } + } + ] + } + } + }, + { + "id": "48", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "user_vendor_msft_policy_config_admx_shellcommandpromptregedittools_disableregedit", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "user_vendor_msft_policy_config_admx_shellcommandpromptregedittools_disableregedit_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "user_vendor_msft_policy_config_admx_shellcommandpromptregedittools_disableregedit_disableregeditmode", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "user_vendor_msft_policy_config_admx_shellcommandpromptregedittools_disableregedit_disableregeditmode_2", + "children": [] + } + } + ] + } + } + }, + { + "id": "49", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "user_vendor_msft_policy_config_admx_shellcommandpromptregedittools_disablecmd", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "user_vendor_msft_policy_config_admx_shellcommandpromptregedittools_disablecmd_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "user_vendor_msft_policy_config_admx_shellcommandpromptregedittools_disablecmd_disablecmdscripts", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "user_vendor_msft_policy_config_admx_shellcommandpromptregedittools_disablecmd_disablecmdscripts_1", + "children": [] + } + } + ] + } + } + }, + { + "id": "50", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remoteassistance_unsolicitedremoteassistance", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_remoteassistance_unsolicitedremoteassistance_0", + "children": [] + } + } + }, + { + "id": "51", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remoteassistance_solicitedremoteassistance", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_remoteassistance_solicitedremoteassistance_0", + "children": [] + } + } + }, + { + "id": "52", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remoteprocedurecall_restrictunauthenticatedrpcclients", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_remoteprocedurecall_restrictunauthenticatedrpcclients_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remoteprocedurecall_restrictunauthenticatedrpcclients_rpcrestrictremoteclientslist", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_remoteprocedurecall_restrictunauthenticatedrpcclients_rpcrestrictremoteclientslist_1", + "children": [] + } + } + ] + } + } + }, + { + "id": "53", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_removablestorage_removablestorageclasses_denyall_access_2", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_removablestorage_removablestorageclasses_denyall_access_2_1", + "children": [] + } + } + }, + { + "id": "54", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_removablestorage_cdanddvd_denyexecute_access_2", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_removablestorage_cdanddvd_denyexecute_access_2_1", + "children": [] + } + } + }, + { + "id": "55", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_removablestorage_cdanddvd_denyread_access_2", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_removablestorage_cdanddvd_denyread_access_2_0", + "children": [] + } + } + }, + { + "id": "56", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_removablestorage_cdanddvd_denywrite_access_2", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_removablestorage_cdanddvd_denywrite_access_2_1", + "children": [] + } + } + }, + { + "id": "57", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_removablestorage_customclasses_denyread_access_2", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_removablestorage_customclasses_denyread_access_2_0", + "children": [] + } + } + }, + { + "id": "58", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_removablestorage_floppydrives_denyexecute_access_2", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_removablestorage_floppydrives_denyexecute_access_2_1", + "children": [] + } + } + }, + { + "id": "59", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_removablestorage_floppydrives_denyread_access_2", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_removablestorage_floppydrives_denyread_access_2_0", + "children": [] + } + } + }, + { + "id": "60", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_removablestorage_floppydrives_denywrite_access_2", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_removablestorage_floppydrives_denywrite_access_2_1", + "children": [] + } + } + }, + { + "id": "61", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_removablestorage_removabledisks_denyexecute_access_2", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_removablestorage_removabledisks_denyexecute_access_2_1", + "children": [] + } + } + }, + { + "id": "62", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_removablestorage_removabledisks_denyread_access_2", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_removablestorage_removabledisks_denyread_access_2_0", + "children": [] + } + } + }, + { + "id": "63", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_removablestorage_tapedrives_denyexecute_access_2", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_removablestorage_tapedrives_denyexecute_access_2_1", + "children": [] + } + } + }, + { + "id": "64", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_removablestorage_tapedrives_denyread_access_2", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_removablestorage_tapedrives_denyread_access_2_0", + "children": [] + } + } + }, + { + "id": "65", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_removablestorage_tapedrives_denywrite_access_2", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_removablestorage_tapedrives_denywrite_access_2_1", + "children": [] + } + } + }, + { + "id": "66", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_removablestorage_wpddevices_denyread_access_2", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_removablestorage_wpddevices_denyread_access_2_0", + "children": [] + } + } + }, + { + "id": "67", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_removablestorage_wpddevices_denywrite_access_2", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_removablestorage_wpddevices_denywrite_access_2_1", + "children": [] + } + } + }, + { + "id": "68", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_msdt_msdtsupportprovider", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_msdt_msdtsupportprovider_0", + "children": [] + } + } + }, + { + "id": "69", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_appcompat_appcompatturnoffprograminventory", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_appcompat_appcompatturnoffprograminventory_1", + "children": [] + } + } + }, + { + "id": "70", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_appcompat_appcompatturnoffuseractionrecord", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_appcompat_appcompatturnoffuseractionrecord_1", + "children": [] + } + } + }, + { + "id": "71", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "user_vendor_msft_policy_config_attachmentmanager_donotpreservezoneinformation", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "user_vendor_msft_policy_config_attachmentmanager_donotpreservezoneinformation_0", + "children": [] + } + } + }, + { + "id": "72", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "user_vendor_msft_policy_config_attachmentmanager_hidezoneinfomechanism", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "user_vendor_msft_policy_config_attachmentmanager_hidezoneinfomechanism_1", + "children": [] + } + } + }, + { + "id": "73", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_autoplay_disallowautoplayfornonvolumedevices", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_autoplay_disallowautoplayfornonvolumedevices_1", + "children": [] + } + } + }, + { + "id": "74", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_autoplay_setdefaultautorunbehavior", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_autoplay_setdefaultautorunbehavior_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_autoplay_setdefaultautorunbehavior_noautorun_dropdown", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_autoplay_setdefaultautorunbehavior_noautorun_dropdown_1", + "children": [] + } + } + ] + } + } + }, + { + "id": "75", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_autoplay_turnoffautoplay", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_autoplay_turnoffautoplay_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_autoplay_turnoffautoplay_autorun_box", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_autoplay_turnoffautoplay_autorun_box_255", + "children": [] + } + } + ] + } + } + }, + { + "id": "76", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_credentialsui_disablepasswordreveal", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_credentialsui_disablepasswordreveal_1", + "children": [] + } + } + }, + { + "id": "77", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_credentialsui_enumerateadministrators", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_credentialsui_enumerateadministrators_0", + "children": [] + } + } + }, + { + "id": "78", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_credui_nolocalpasswordresetquestions", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_credui_nolocalpasswordresetquestions_1", + "children": [] + } + } + }, + { + "id": "79", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_credui_enablesecurecredentialprompting", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_credui_enablesecurecredentialprompting_1", + "children": [] + } + } + }, + { + "id": "80", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_eventlogservice_specifymaximumfilesizeapplicationlog", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_eventlogservice_specifymaximumfilesizeapplicationlog_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_eventlogservice_specifymaximumfilesizeapplicationlog_channel_logmaxsize", + "settingInstanceTemplateReference": null, + "simpleSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationIntegerSettingValue", + "settingValueTemplateReference": null, + "value": 65536 + } + } + ] + } + } + }, + { + "id": "81", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_eventlogservice_specifymaximumfilesizesecuritylog", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_eventlogservice_specifymaximumfilesizesecuritylog_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_eventlogservice_specifymaximumfilesizesecuritylog_channel_logmaxsize", + "settingInstanceTemplateReference": null, + "simpleSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationIntegerSettingValue", + "settingValueTemplateReference": null, + "value": 2097152 + } + } + ] + } + } + }, + { + "id": "82", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_eventlogservice_specifymaximumfilesizesystemlog", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_eventlogservice_specifymaximumfilesizesystemlog_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_eventlogservice_specifymaximumfilesizesystemlog_channel_logmaxsize", + "settingInstanceTemplateReference": null, + "simpleSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationIntegerSettingValue", + "settingValueTemplateReference": null, + "value": 65536 + } + } + ] + } + } + }, + { + "id": "83", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_windowsexplorer_enablesmartscreen", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_windowsexplorer_enablesmartscreen_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_windowsexplorer_enablesmartscreen_enablesmartscreendropdown", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_windowsexplorer_enablesmartscreen_enablesmartscreendropdown_block", + "children": [] + } + } + ] + } + } + }, + { + "id": "84", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "user_vendor_msft_policy_config_admx_windowsexplorer_nocdburning", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "user_vendor_msft_policy_config_admx_windowsexplorer_nocdburning_1", + "children": [] + } + } + }, + { + "id": "85", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "user_vendor_msft_policy_config_admx_windowsexplorer_nosecuritytab", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "user_vendor_msft_policy_config_admx_windowsexplorer_nosecuritytab_1", + "children": [] + } + } + }, + { + "id": "86", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_windowsexplorer_showhibernateoption", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_windowsexplorer_showhibernateoption_0", + "children": [] + } + } + }, + { + "id": "87", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_windowsexplorer_showsleepoption", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_windowsexplorer_showsleepoption_0", + "children": [] + } + } + }, + { + "id": "88", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_fileexplorer_turnoffdataexecutionpreventionforexplorer", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_fileexplorer_turnoffdataexecutionpreventionforexplorer_0", + "children": [] + } + } + }, + { + "id": "89", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_fileexplorer_turnoffheapterminationoncorruption", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_fileexplorer_turnoffheapterminationoncorruption_0", + "children": [] + } + } + }, + { + "id": "90", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_windowsexplorer_shellprotocolprotectedmodetitle_2", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_windowsexplorer_shellprotocolprotectedmodetitle_2_0", + "children": [] + } + } + }, + { + "id": "91", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_sharing_disablehomegroup", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_sharing_disablehomegroup_1", + "children": [] + } + } + }, + { + "id": "92", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_sensors_disablelocationscripting_2", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_sensors_disablelocationscripting_2_1", + "children": [] + } + } + }, + { + "id": "93", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_locationprovideradm_disablewindowslocationprovider_1", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_locationprovideradm_disablewindowslocationprovider_1_1", + "children": [] + } + } + }, + { + "id": "94", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_msapolicy_microsoftaccount_disableuserauth", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_msapolicy_microsoftaccount_disableuserauth_1", + "children": [] + } + } + }, + { + "id": "95", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_spynet_localsettingoverridespynetreporting", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_spynet_localsettingoverridespynetreporting_0", + "children": [] + } + } + }, + { + "id": "96", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_disableblockatfirstseen", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_disableblockatfirstseen_1", + "children": [] + } + } + }, + { + "id": "97", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_spynetreporting", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_spynetreporting_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_spynetreporting_spynetreporting", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_spynetreporting_spynetreporting_2", + "children": [] + } + } + ] + } + } + }, + { + "id": "98", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_quarantine_purgeitemsafterdelay", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_quarantine_purgeitemsafterdelay_0", + "children": [] + } + } + }, + { + "id": "99", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_realtimeprotection_disableioavprotection", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_realtimeprotection_disableioavprotection_1", + "children": [] + } + } + }, + { + "id": "100", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_disablerealtimemonitoring", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_disablerealtimemonitoring_0", + "children": [] + } + } + }, + { + "id": "101", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_realtimeprotection_disablebehaviormonitoring", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_realtimeprotection_disablebehaviormonitoring_1", + "children": [] + } + } + }, + { + "id": "102", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_realtimeprotection_disablescanonrealtimeenable", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_realtimeprotection_disablescanonrealtimeenable_1", + "children": [] + } + } + }, + { + "id": "103", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_scan_allowpause", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_scan_allowpause_0", + "children": [] + } + } + }, + { + "id": "104", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_scan_disablearchivescanning", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_scan_disablearchivescanning_1", + "children": [] + } + } + }, + { + "id": "105", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_scan_disablepackedexescanning", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_scan_disablepackedexescanning_1", + "children": [] + } + } + }, + { + "id": "106", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_scan_disableremovabledrivescanning", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_scan_disableremovabledrivescanning_1", + "children": [] + } + } + }, + { + "id": "107", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_scan_disableemailscanning", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_scan_disableemailscanning_1", + "children": [] + } + } + }, + { + "id": "108", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_scan_disableheuristics", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_scan_disableheuristics_1", + "children": [] + } + } + }, + { + "id": "109", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_disableantispywaredefender", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_disableantispywaredefender_0", + "children": [] + } + } + }, + { + "id": "110", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "user_vendor_msft_policy_config_admx_sharing_noinplacesharing", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "user_vendor_msft_policy_config_admx_sharing_noinplacesharing_1", + "children": [] + } + } + }, + { + "id": "111", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_terminalserver_ts_server_auth", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_terminalserver_ts_server_auth_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_terminalserver_ts_server_auth_ts_server_auth_level", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_terminalserver_ts_server_auth_ts_server_auth_level_1", + "children": [] + } + } + ] + } + } + }, + { + "id": "112", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remotedesktopservices_donotallowpasswordsaving", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_remotedesktopservices_donotallowpasswordsaving_1", + "children": [] + } + } + }, + { + "id": "113", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remotedesktopservices_allowuserstoconnectremotely", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_remotedesktopservices_allowuserstoconnectremotely_0", + "children": [] + } + } + }, + { + "id": "114", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_terminalserver_ts_forcible_logoff", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_terminalserver_ts_forcible_logoff_1", + "children": [] + } + } + }, + { + "id": "115", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_terminalserver_ts_client_clipboard", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_terminalserver_ts_client_clipboard_1", + "children": [] + } + } + }, + { + "id": "116", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remotedesktopservices_donotallowdriveredirection", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_remotedesktopservices_donotallowdriveredirection_1", + "children": [] + } + } + }, + { + "id": "117", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remotedesktopservices_promptforpassworduponconnection", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_remotedesktopservices_promptforpassworduponconnection_1", + "children": [] + } + } + }, + { + "id": "118", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_terminalserver_ts_tscc_permissions_policy", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_terminalserver_ts_tscc_permissions_policy_1", + "children": [] + } + } + }, + { + "id": "119", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remotedesktopservices_requiresecurerpccommunication", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_remotedesktopservices_requiresecurerpccommunication_1", + "children": [] + } + } + }, + { + "id": "120", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_terminalserver_ts_security_layer_policy", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_terminalserver_ts_security_layer_policy_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_terminalserver_ts_security_layer_policy_ts_security_layer", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_terminalserver_ts_security_layer_policy_ts_security_layer_2", + "children": [] + } + } + ] + } + } + }, + { + "id": "121", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_terminalserver_ts_user_authentication_policy", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_terminalserver_ts_user_authentication_policy_1", + "children": [] + } + } + }, + { + "id": "122", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remotedesktopservices_clientconnectionencryptionlevel", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_remotedesktopservices_clientconnectionencryptionlevel_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remotedesktopservices_clientconnectionencryptionlevel_ts_encryption_level", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_remotedesktopservices_clientconnectionencryptionlevel_ts_encryption_level_3", + "children": [] + } + } + ] + } + } + }, + { + "id": "123", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_disableenclosuredownloading", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_internetexplorer_disableenclosuredownloading_1", + "children": [] + } + } + }, + { + "id": "124", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_soundrec_soundrec_diableapplication_titletext_2", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_soundrec_soundrec_diableapplication_titletext_2_1", + "children": [] + } + } + }, + { + "id": "125", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_windowsstore_removewindowsstore_2", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_windowsstore_removewindowsstore_2_1", + "children": [] + } + } + }, + { + "id": "126", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_winlogon_softwaresasgeneration", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_winlogon_softwaresasgeneration_0", + "children": [] + } + } + }, + { + "id": "127", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_windowslogon_allowautomaticrestartsignon", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_windowslogon_allowautomaticrestartsignon_0", + "children": [] + } + } + }, + { + "id": "128", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_windowspowershell_turnonpowershellscriptblocklogging", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_windowspowershell_turnonpowershellscriptblocklogging_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_windowspowershell_turnonpowershellscriptblocklogging_enablescriptblockinvocationlogging", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_windowspowershell_turnonpowershellscriptblocklogging_enablescriptblockinvocationlogging_0", + "children": [] + } + } + ] + } + } + }, + { + "id": "129", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_powershellexecutionpolicy_enablescripts", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_powershellexecutionpolicy_enablescripts_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_powershellexecutionpolicy_enablescripts_executionpolicy", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_admx_powershellexecutionpolicy_enablescripts_executionpolicy_allsigned", + "children": [] + } + } + ] + } + } + }, + { + "id": "130", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remotemanagement_allowbasicauthentication_client", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_remotemanagement_allowbasicauthentication_client_0", + "children": [] + } + } + }, + { + "id": "131", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remotemanagement_allowunencryptedtraffic_client", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_remotemanagement_allowunencryptedtraffic_client_0", + "children": [] + } + } + }, + { + "id": "132", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remotemanagement_disallowdigestauthentication", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_remotemanagement_disallowdigestauthentication_1", + "children": [] + } + } + }, + { + "id": "133", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remotemanagement_allowbasicauthentication_service", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_remotemanagement_allowbasicauthentication_service_0", + "children": [] + } + } + }, + { + "id": "134", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remotemanagement_allowunencryptedtraffic_service", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_remotemanagement_allowunencryptedtraffic_service_0", + "children": [] + } + } + }, + { + "id": "135", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remotemanagement_disallowstoringofrunascredentials", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_remotemanagement_disallowstoringofrunascredentials_1", + "children": [] + } + } + }, + { + "id": "136", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remoteshell_allowremoteshellaccess", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_remoteshell_allowremoteshellaccess_0", + "children": [] + } + } + }, + { + "id": "137", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_accountlogonlogoff_auditaccountlockout", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_audit_accountlogonlogoff_auditaccountlockout_2", + "children": [] + } + } + }, + { + "id": "138", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_accountlogonlogoff_auditgroupmembership", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_audit_accountlogonlogoff_auditgroupmembership_1", + "children": [] + } + } + }, + { + "id": "139", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_accountlogonlogoff_auditlogoff", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_audit_accountlogonlogoff_auditlogoff_1", + "children": [] + } + } + }, + { + "id": "140", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_accountlogonlogoff_auditlogon", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_audit_accountlogonlogoff_auditlogon_3", + "children": [] + } + } + }, + { + "id": "141", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_accountmanagement_auditcomputeraccountmanagement", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_audit_accountmanagement_auditcomputeraccountmanagement_3", + "children": [] + } + } + }, + { + "id": "142", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_accountmanagement_auditotheraccountmanagementevents", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_audit_accountmanagement_auditotheraccountmanagementevents_3", + "children": [] + } + } + }, + { + "id": "143", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_policychange_auditpolicychange", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_audit_policychange_auditpolicychange_3", + "children": [] + } + } + }, + { + "id": "144", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_objectaccess_auditfileshare", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_audit_objectaccess_auditfileshare_3", + "children": [] + } + } + }, + { + "id": "145", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_accountlogonlogoff_auditotherlogonlogoffevents", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_audit_accountlogonlogoff_auditotherlogonlogoffevents_3", + "children": [] + } + } + }, + { + "id": "146", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_accountmanagement_auditsecuritygroupmanagement", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_audit_accountmanagement_auditsecuritygroupmanagement_3", + "children": [] + } + } + }, + { + "id": "147", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_accountlogonlogoff_auditspeciallogon", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_audit_accountlogonlogoff_auditspeciallogon_3", + "children": [] + } + } + }, + { + "id": "148", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_accountmanagement_audituseraccountmanagement", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_audit_accountmanagement_audituseraccountmanagement_3", + "children": [] + } + } + }, + { + "id": "149", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_detailedtracking_auditprocesscreation", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_audit_detailedtracking_auditprocesscreation_1", + "children": [] + } + } + }, + { + "id": "150", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_detailedtracking_auditprocesstermination", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_audit_detailedtracking_auditprocesstermination_1", + "children": [] + } + } + }, + { + "id": "151", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_objectaccess_auditfilesystem", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_audit_objectaccess_auditfilesystem_3", + "children": [] + } + } + }, + { + "id": "152", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_objectaccess_auditkernelobject", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_audit_objectaccess_auditkernelobject_3", + "children": [] + } + } + }, + { + "id": "153", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_objectaccess_auditotherobjectaccessevents", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_audit_objectaccess_auditotherobjectaccessevents_3", + "children": [] + } + } + }, + { + "id": "154", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_objectaccess_auditregistry", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_audit_objectaccess_auditregistry_3", + "children": [] + } + } + }, + { + "id": "155", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_policychange_auditotherpolicychangeevents", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_audit_policychange_auditotherpolicychangeevents_3", + "children": [] + } + } + }, + { + "id": "156", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_system_auditsystemintegrity", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_audit_system_auditsystemintegrity_3", + "children": [] + } + } + }, + { + "id": "157", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_browser_allowdevelopertools", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_browser_allowdevelopertools_0", + "children": [] + } + } + }, + { + "id": "158", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_browser_allowdonottrack", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_browser_allowdonottrack_1", + "children": [] + } + } + }, + { + "id": "159", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_browser_allowflash", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_browser_allowflash_0", + "children": [] + } + } + }, + { + "id": "160", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_browser_allowpasswordmanager", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_browser_allowpasswordmanager_0", + "children": [] + } + } + }, + { + "id": "161", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_browser_allowpopups", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_browser_allowpopups_1", + "children": [] + } + } + }, + { + "id": "162", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_browser_allowsmartscreen", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_browser_allowsmartscreen_1", + "children": [] + } + } + }, + { + "id": "163", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_browser_preventaccesstoaboutflagsinmicrosoftedge", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_browser_preventaccesstoaboutflagsinmicrosoftedge_1", + "children": [] + } + } + }, + { + "id": "164", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_browser_preventsmartscreenpromptoverride", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_browser_preventsmartscreenpromptoverride_1", + "children": [] + } + } + }, + { + "id": "165", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_browser_preventsmartscreenpromptoverrideforfiles", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_browser_preventsmartscreenpromptoverrideforfiles_1", + "children": [] + } + } + }, + { + "id": "166", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_cloudblocklevel", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_defender_cloudblocklevel_2", + "children": [] + } + } + }, + { + "id": "167", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_cloudextendedtimeout", + "settingInstanceTemplateReference": null, + "simpleSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationIntegerSettingValue", + "settingValueTemplateReference": null, + "value": 50 + } + } + }, + { + "id": "168", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_enablenetworkprotection", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_defender_enablenetworkprotection_1", + "children": [] + } + } + }, + { + "id": "169", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_submitsamplesconsent", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_defender_submitsamplesconsent_1", + "children": [] + } + } + }, + { + "id": "170", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_deviceguard_configuresystemguardlaunch", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_deviceguard_configuresystemguardlaunch_1", + "children": [] + } + } + }, + { + "id": "171", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_deviceguard_requireplatformsecurityfeatures", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_deviceguard_requireplatformsecurityfeatures_3", + "children": [] + } + } + }, + { + "id": "172", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_experience_allowcortana", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_experience_allowcortana_0", + "children": [] + } + } + }, + { + "id": "173", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "user_vendor_msft_policy_config_experience_allowwindowsspotlight", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "user_vendor_msft_policy_config_experience_allowwindowsspotlight_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "user_vendor_msft_policy_config_experience_allowthirdpartysuggestionsinwindowsspotlight", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "user_vendor_msft_policy_config_experience_allowthirdpartysuggestionsinwindowsspotlight_0", + "children": [] + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_experience_allowwindowsconsumerfeatures", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_experience_allowwindowsconsumerfeatures_0", + "children": [] + } + } + ] + } + } + }, + { + "id": "174", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_experience_showlockonusertile", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_experience_showlockonusertile_1", + "children": [] + } + } + }, + { + "id": "175", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_lanmanworkstation_enableinsecureguestlogons", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_lanmanworkstation_enableinsecureguestlogons_0", + "children": [] + } + } + }, + { + "id": "176", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_accounts_blockmicrosoftaccounts", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_accounts_blockmicrosoftaccounts_3", + "children": [] + } + } + }, + { + "id": "177", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_accounts_enableadministratoraccountstatus", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_accounts_enableadministratoraccountstatus_0", + "children": [] + } + } + }, + { + "id": "178", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_accounts_enableguestaccountstatus", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_accounts_enableguestaccountstatus_0", + "children": [] + } + } + }, + { + "id": "179", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_accounts_limitlocalaccountuseofblankpasswordstoconsolelogononly", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_accounts_limitlocalaccountuseofblankpasswordstoconsolelogononly_1", + "children": [] + } + } + }, + { + "id": "180", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_devices_preventusersfrominstallingprinterdriverswhenconnectingtosharedprinters", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_devices_preventusersfrominstallingprinterdriverswhenconnectingtosharedprinters_1", + "children": [] + } + } + }, + { + "id": "181", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_interactivelogon_donotrequirectrlaltdel", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_interactivelogon_donotrequirectrlaltdel_0", + "children": [] + } + } + }, + { + "id": "182", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_interactivelogon_machineinactivitylimit_v2", + "settingInstanceTemplateReference": null, + "simpleSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationIntegerSettingValue", + "settingValueTemplateReference": null, + "value": 900 + } + } + }, + { + "id": "183", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_microsoftnetworkclient_digitallysigncommunicationsalways", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_microsoftnetworkclient_digitallysigncommunicationsalways_1", + "children": [] + } + } + }, + { + "id": "184", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_microsoftnetworkclient_digitallysigncommunicationsifserveragrees", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_microsoftnetworkclient_digitallysigncommunicationsifserveragrees_1", + "children": [] + } + } + }, + { + "id": "185", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_microsoftnetworkclient_sendunencryptedpasswordtothirdpartysmbservers", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_microsoftnetworkclient_sendunencryptedpasswordtothirdpartysmbservers_0", + "children": [] + } + } + }, + { + "id": "186", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_microsoftnetworkserver_digitallysigncommunicationsalways", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_microsoftnetworkserver_digitallysigncommunicationsalways_1", + "children": [] + } + } + }, + { + "id": "187", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_microsoftnetworkserver_digitallysigncommunicationsifclientagrees", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_microsoftnetworkserver_digitallysigncommunicationsifclientagrees_1", + "children": [] + } + } + }, + { + "id": "188", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networkaccess_donotallowanonymousenumerationofsamaccounts", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networkaccess_donotallowanonymousenumerationofsamaccounts_1", + "children": [] + } + } + }, + { + "id": "189", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networkaccess_donotallowanonymousenumerationofsamaccountsandshares", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networkaccess_donotallowanonymousenumerationofsamaccountsandshares_1", + "children": [] + } + } + }, + { + "id": "190", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networkaccess_restrictanonymousaccesstonamedpipesandshares", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networkaccess_restrictanonymousaccesstonamedpipesandshares_1", + "children": [] + } + } + }, + { + "id": "191", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networkaccess_restrictclientsallowedtomakeremotecallstosam", + "settingInstanceTemplateReference": null, + "simpleSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "O:BAG:BAD:(A;;RC;;;BA)" + } + } + }, + { + "id": "192", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networksecurity_allowlocalsystemtousecomputeridentityforntlm", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networksecurity_allowlocalsystemtousecomputeridentityforntlm_1", + "children": [] + } + } + }, + { + "id": "193", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networksecurity_allowpku2uauthenticationrequests", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networksecurity_allowpku2uauthenticationrequests_0", + "children": [] + } + } + }, + { + "id": "194", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networksecurity_donotstorelanmanagerhashvalueonnextpasswordchange", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networksecurity_donotstorelanmanagerhashvalueonnextpasswordchange_1", + "children": [] + } + } + }, + { + "id": "195", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networksecurity_lanmanagerauthenticationlevel", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networksecurity_lanmanagerauthenticationlevel_5", + "children": [] + } + } + }, + { + "id": "196", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networksecurity_minimumsessionsecurityforntlmsspbasedclients", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networksecurity_minimumsessionsecurityforntlmsspbasedclients_537395200", + "children": [] + } + } + }, + { + "id": "197", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networksecurity_minimumsessionsecurityforntlmsspbasedservers", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networksecurity_minimumsessionsecurityforntlmsspbasedservers_537395200", + "children": [] + } + } + }, + { + "id": "198", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_allowuiaccessapplicationstopromptforelevation", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_allowuiaccessapplicationstopromptforelevation_0", + "children": [] + } + } + }, + { + "id": "199", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_behavioroftheelevationpromptforadministrators", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_behavioroftheelevationpromptforadministrators_1", + "children": [] + } + } + }, + { + "id": "200", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_behavioroftheelevationpromptforstandardusers", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_behavioroftheelevationpromptforstandardusers_0", + "children": [] + } + } + }, + { + "id": "201", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_detectapplicationinstallationsandpromptforelevation", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_detectapplicationinstallationsandpromptforelevation_1", + "children": [] + } + } + }, + { + "id": "202", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_onlyelevateuiaccessapplicationsthatareinstalledinsecurelocations", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_onlyelevateuiaccessapplicationsthatareinstalledinsecurelocations_1", + "children": [] + } + } + }, + { + "id": "203", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_runalladministratorsinadminapprovalmode", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_runalladministratorsinadminapprovalmode_1", + "children": [] + } + } + }, + { + "id": "204", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_switchtothesecuredesktopwhenpromptingforelevation", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_switchtothesecuredesktopwhenpromptingforelevation_1", + "children": [] + } + } + }, + { + "id": "205", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_useadminapprovalmode", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_useadminapprovalmode_1", + "children": [] + } + } + }, + { + "id": "206", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_virtualizefileandregistrywritefailurestoperuserlocations", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_virtualizefileandregistrywritefailurestoperuserlocations_1", + "children": [] + } + } + }, + { + "id": "207", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_applicationmanagement_allowgamedvr", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_applicationmanagement_allowgamedvr_0", + "children": [] + } + } + }, + { + "id": "208", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_applicationmanagement_msiallowusercontroloverinstall", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_applicationmanagement_msiallowusercontroloverinstall_0", + "children": [] + } + } + }, + { + "id": "209", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_applicationmanagement_msialwaysinstallwithelevatedprivileges", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_applicationmanagement_msialwaysinstallwithelevatedprivileges_0", + "children": [] + } + } + }, + { + "id": "210", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "user_vendor_msft_policy_config_applicationmanagement_msialwaysinstallwithelevatedprivileges", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "user_vendor_msft_policy_config_applicationmanagement_msialwaysinstallwithelevatedprivileges_0", + "children": [] + } + } + }, + { + "id": "211", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge_downloadrestrictions", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge_downloadrestrictions_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge_downloadrestrictions_downloadrestrictions", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge_downloadrestrictions_downloadrestrictions_2", + "children": [] + } + } + ] + } + } + }, + { + "id": "212", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge_configuredonottrack", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge_configuredonottrack_1", + "children": [] + } + } + }, + { + "id": "213", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge~contentsettings_defaultpopupssetting", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge~contentsettings_defaultpopupssetting_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge~contentsettings_defaultpopupssetting_defaultpopupssetting", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge~contentsettings_defaultpopupssetting_defaultpopupssetting_2", + "children": [] + } + } + ] + } + } + }, + { + "id": "214", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edgev83diff~policy~microsoft_edge_dnsoverhttpsmode", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edgev83diff~policy~microsoft_edge_dnsoverhttpsmode_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edgev83diff~policy~microsoft_edge_dnsoverhttpsmode_dnsoverhttpsmode", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edgev83diff~policy~microsoft_edge_dnsoverhttpsmode_dnsoverhttpsmode_off", + "children": [] + } + } + ] + } + } + }, + { + "id": "215", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge_developertoolsavailability", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge_developertoolsavailability_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge_developertoolsavailability_developertoolsavailability", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge_developertoolsavailability_developertoolsavailability_2", + "children": [] + } + } + ] + } + } + }, + { + "id": "216", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edgev80diff~policy~microsoft_edge_dnsinterceptionchecksenabled", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edgev80diff~policy~microsoft_edge_dnsinterceptionchecksenabled_0", + "children": [] + } + } + }, + { + "id": "217", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge~passwordmanager_passwordmanagerenabled", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge~passwordmanager_passwordmanagerenabled_0", + "children": [] + } + } + }, + { + "id": "218", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge~smartscreen_smartscreenenabled", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge~smartscreen_smartscreenenabled_1", + "children": [] + } + } + }, + { + "id": "219", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge~smartscreen_preventsmartscreenpromptoverride", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge~smartscreen_preventsmartscreenpromptoverride_1", + "children": [] + } + } + }, + { + "id": "220", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge~smartscreen_preventsmartscreenpromptoverrideforfiles", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge~smartscreen_preventsmartscreenpromptoverrideforfiles_1", + "children": [] + } + } + }, + { + "id": "221", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_power_turnoffhybridsleeponbattery", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_power_turnoffhybridsleeponbattery_1", + "children": [] + } + } + }, + { + "id": "222", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_power_turnoffhybridsleeppluggedin", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_power_turnoffhybridsleeppluggedin_1", + "children": [] + } + } + }, + { + "id": "223", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_search_allowindexingencryptedstoresoritems", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_search_allowindexingencryptedstoresoritems_0", + "children": [] + } + } + }, + { + "id": "224", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_search_donotusewebresults", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_search_donotusewebresults_0", + "children": [] + } + } + }, + { + "id": "225", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_smartscreen_preventoverrideforfilesinshell", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_smartscreen_preventoverrideforfilesinshell_1", + "children": [] + } + } + }, + { + "id": "226", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_storage_removablediskdenywriteaccess", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_storage_removablediskdenywriteaccess_1", + "children": [] + } + } + }, + { + "id": "227", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_system_allowlocation", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_system_allowlocation_0", + "children": [] + } + } + }, + { + "id": "228", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_system_allowtelemetry", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_system_allowtelemetry_0", + "children": [] + } + } + }, + { + "id": "229", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_system_disableonedrivefilesync", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_system_disableonedrivefilesync_1", + "children": [] + } + } + }, + { + "id": "230", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_accesscredentialmanagerastrustedcaller", + "settingInstanceTemplateReference": null, + "simpleSettingCollectionValue": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "\\\"\\\"" + } + ] + } + }, + { + "id": "231", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_accessfromnetwork", + "settingInstanceTemplateReference": null, + "simpleSettingCollectionValue": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "BUILTIN\\Remote Desktop Users" + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "BUILTIN\\Administrators" + } + ] + } + }, + { + "id": "232", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_actaspartoftheoperatingsystem", + "settingInstanceTemplateReference": null, + "simpleSettingCollectionValue": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "\\\"\\\"" + } + ] + } + }, + { + "id": "233", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_allowlocallogon", + "settingInstanceTemplateReference": null, + "simpleSettingCollectionValue": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "BUILTIN\\Users" + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "BUILTIN\\Administrators" + } + ] + } + }, + { + "id": "234", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_backupfilesanddirectories", + "settingInstanceTemplateReference": null, + "simpleSettingCollectionValue": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "BUILTIN\\Administrators" + } + ] + } + }, + { + "id": "235", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_createglobalobjects", + "settingInstanceTemplateReference": null, + "simpleSettingCollectionValue": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "NT AUTHORITY\\SERVICE" + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "NT AUTHORITY\\NETWORK SERVICE" + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "NT AUTHORITY\\LOCAL SERVICE" + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "BUILTIN\\Administrators" + } + ] + } + }, + { + "id": "236", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_createpagefile", + "settingInstanceTemplateReference": null, + "simpleSettingCollectionValue": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "BUILTIN\\Administrators" + } + ] + } + }, + { + "id": "237", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_createpermanentsharedobjects", + "settingInstanceTemplateReference": null, + "simpleSettingCollectionValue": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "\\\"\\\"" + } + ] + } + }, + { + "id": "238", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_createtoken", + "settingInstanceTemplateReference": null, + "simpleSettingCollectionValue": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "\\\"\\\"" + } + ] + } + }, + { + "id": "239", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_debugprograms", + "settingInstanceTemplateReference": null, + "simpleSettingCollectionValue": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "BUILTIN\\Administrators" + } + ] + } + }, + { + "id": "240", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_denyaccessfromnetwork", + "settingInstanceTemplateReference": null, + "simpleSettingCollectionValue": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "NT AUTHORITY\\Local account" + } + ] + } + }, + { + "id": "241", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_denyremotedesktopserviceslogon", + "settingInstanceTemplateReference": null, + "simpleSettingCollectionValue": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "NT AUTHORITY\\Local account" + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "BUILTIN\\Administrators" + } + ] + } + }, + { + "id": "242", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_enabledelegation", + "settingInstanceTemplateReference": null, + "simpleSettingCollectionValue": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "\\\"\\\"" + } + ] + } + }, + { + "id": "243", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_impersonateclient", + "settingInstanceTemplateReference": null, + "simpleSettingCollectionValue": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "NT AUTHORITY\\SERVICE" + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "NT AUTHORITY\\NETWORK SERVICE" + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "NT AUTHORITY\\LOCAL SERVICE" + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "BUILTIN\\Administrators" + } + ] + } + }, + { + "id": "244", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_loadunloaddevicedrivers", + "settingInstanceTemplateReference": null, + "simpleSettingCollectionValue": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "BUILTIN\\Administrators" + } + ] + } + }, + { + "id": "245", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_lockmemory", + "settingInstanceTemplateReference": null, + "simpleSettingCollectionValue": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "\\\"\\\"" + } + ] + } + }, + { + "id": "246", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_manageauditingandsecuritylog", + "settingInstanceTemplateReference": null, + "simpleSettingCollectionValue": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "BUILTIN\\Administrators" + } + ] + } + }, + { + "id": "247", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_managevolume", + "settingInstanceTemplateReference": null, + "simpleSettingCollectionValue": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "BUILTIN\\Administrators" + } + ] + } + }, + { + "id": "248", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_modifyfirmwareenvironment", + "settingInstanceTemplateReference": null, + "simpleSettingCollectionValue": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "BUILTIN\\Administrators" + } + ] + } + }, + { + "id": "249", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_profilesingleprocess", + "settingInstanceTemplateReference": null, + "simpleSettingCollectionValue": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "BUILTIN\\Administrators" + } + ] + } + }, + { + "id": "250", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_remoteshutdown", + "settingInstanceTemplateReference": null, + "simpleSettingCollectionValue": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "BUILTIN\\Administrators" + } + ] + } + }, + { + "id": "251", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_restorefilesanddirectories", + "settingInstanceTemplateReference": null, + "simpleSettingCollectionValue": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "BUILTIN\\Administrators" + } + ] + } + }, + { + "id": "252", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_takeownership", + "settingInstanceTemplateReference": null, + "simpleSettingCollectionValue": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "settingValueTemplateReference": null, + "value": "BUILTIN\\Administrators" + } + ] + } + }, + { + "id": "253", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_wifi_allowautoconnecttowifisensehotspots", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_wifi_allowautoconnecttowifisensehotspots_0", + "children": [] + } + } + }, + { + "id": "254", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_windowsdefendersecuritycenter_disallowexploitprotectionoverride", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_windowsdefendersecuritycenter_disallowexploitprotectionoverride_1", + "children": [] + } + } + }, + { + "id": "255", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_windowsinkworkspace_allowwindowsinkworkspace", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_windowsinkworkspace_allowwindowsinkworkspace_1", + "children": [] + } + } + } + ] +} diff --git a/Products/Intune/Config/PolicyCatalog/Windows/windows-asr-audit.json b/Products/Intune/Config/PolicyCatalog/Windows/windows-asr-audit.json new file mode 100644 index 0000000..90f4fae --- /dev/null +++ b/Products/Intune/Config/PolicyCatalog/Windows/windows-asr-audit.json @@ -0,0 +1,181 @@ +{ + "@odata.context": "https://graph.microsoft.com/beta/$metadata#deviceManagement/configurationPolicies/$entity", + "name": "Windows - Attack Surface Reduction (Audit)", + "description": "Implements the following controls for Microsoft Office: block creating child processes, block creating executable content, block injecting code into other processes. Refer to https://github.com/microsoft/Intune-ACSC-Windows-Hardening-Guidelines", + "settings": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationGroupSettingCollectionInstance", + "groupSettingCollectionValue": [ + { + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "fa9393ac-b3b8-4f0a-a219-68971d67f9a6" + }, + "value": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockcredentialstealingfromwindowslocalsecurityauthoritysubsystem_audit" + }, + "settingDefinitionId": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockcredentialstealingfromwindowslocalsecurityauthoritysubsystem", + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "050319a0-403d-4b00-8e47-ff7c45b239f9" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockadobereaderfromcreatingchildprocesses", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "children": [], + "value": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockadobereaderfromcreatingchildprocesses_audit" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockofficeapplicationsfrominjectingcodeintootherprocesses", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "children": [], + "value": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockofficeapplicationsfrominjectingcodeintootherprocesses_audit" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockofficeapplicationsfromcreatingexecutablecontent", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "children": [], + "value": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockofficeapplicationsfromcreatingexecutablecontent_audit" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockofficecommunicationappfromcreatingchildprocesses", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "children": [], + "value": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockofficecommunicationappfromcreatingchildprocesses_audit" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockallofficeapplicationsfromcreatingchildprocesses", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "children": [], + "value": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockallofficeapplicationsfromcreatingchildprocesses_audit" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockwin32apicallsfromofficemacros", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "children": [], + "value": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockwin32apicallsfromofficemacros_audit" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockexecutionofpotentiallyobfuscatedscripts", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "children": [], + "value": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockexecutionofpotentiallyobfuscatedscripts_audit" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockexecutablefilesrunningunlesstheymeetprevalenceagetrustedlistcriterion", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "children": [], + "value": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockexecutablefilesrunningunlesstheymeetprevalenceagetrustedlistcriterion_audit" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockjavascriptorvbscriptfromlaunchingdownloadedexecutablecontent", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "children": [], + "value": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockjavascriptorvbscriptfromlaunchingdownloadedexecutablecontent_audit" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockwebshellcreationforservers", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "children": [], + "value": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockwebshellcreationforservers_audit" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockprocesscreationsfrompsexecandwmicommands", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "children": [], + "value": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockprocesscreationsfrompsexecandwmicommands_audit" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockabuseofexploitedvulnerablesigneddrivers", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "children": [], + "value": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockabuseofexploitedvulnerablesigneddrivers_audit" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockuseofcopiedorimpersonatedsystemtools", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "children": [], + "value": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockuseofcopiedorimpersonatedsystemtools_audit" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockpersistencethroughwmieventsubscription", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "children": [], + "value": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockpersistencethroughwmieventsubscription_audit" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockuntrustedunsignedprocessesthatrunfromusb", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "children": [], + "value": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockuntrustedunsignedprocessesthatrunfromusb_audit" + } + } + ] + } + ], + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "19600663-e264-4c02-8f55-f2983216d6d7" + }, + "settingDefinitionId": "device_vendor_msft_policy_config_defender_attacksurfacereductionrules" + } + } + ], + "roleScopeTagIds": [ + "0" + ], + "platforms": "windows10", + "technologies": "mdm,microsoftSense", + "templateReference": { + "templateId": "e8c053d6-9f95-42b1-a7f1-ebfd71c67a4b_1" + } +} diff --git a/Products/Intune/Config/PolicyCatalog/Windows/windows-compliance.json b/Products/Intune/Config/PolicyCatalog/Windows/windows-compliance.json new file mode 100644 index 0000000..c55d47a --- /dev/null +++ b/Products/Intune/Config/PolicyCatalog/Windows/windows-compliance.json @@ -0,0 +1,36 @@ +{ + "displayName": "Windows - Compliance Policy", + "roleScopeTagIds": [ + "0" + ], + "@odata.type": "#microsoft.graph.windows10CompliancePolicy", + "scheduledActionsForRule": [ + { + "ruleName": "PasswordRequired", + "scheduledActionConfigurations": [ + { + "actionType": "block", + "gracePeriodHours": 0, + "notificationTemplateId": "", + "notificationMessageCCList": [] + } + ] + } + ], + "deviceThreatProtectionRequiredSecurityLevel": "unavailable", + "passwordRequiredType": "alphanumeric", + "bitLockerEnabled": true, + "passwordRequired": true, + "passwordBlockSimple": true, + "passwordMinimumCharacterSetCount": 2, + "passwordMinutesOfInactivityBeforeLock": 15, + "storageRequireEncryption": true, + "activeFirewallRequired": true, + "tpmRequired": true, + "antivirusRequired": true, + "antiSpywareRequired": true, + "defenderEnabled": true, + "signatureOutOfDate": true, + "rtpEnabled": true, + "deviceThreatProtectionEnabled": false +} diff --git a/Products/Intune/Config/PolicyCatalog/Windows/windows-edge-hardening.json b/Products/Intune/Config/PolicyCatalog/Windows/windows-edge-hardening.json new file mode 100644 index 0000000..d2be1c8 --- /dev/null +++ b/Products/Intune/Config/PolicyCatalog/Windows/windows-edge-hardening.json @@ -0,0 +1,208 @@ +{ + "name": "Windows - Microsoft Edge Hardening Guidelines", + "description": "Refer to https://github.com/microsoft/Intune-ACSC-Windows-Hardening-Guidelines", + "platforms": "windows10", + "technologies": "mdm", + "settings": [ + { + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_enablenetworkprotection", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "children": [], + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_defender_enablenetworkprotection_1" + } + } + }, + { + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edgev78diff~policy~microsoft_edge_adssettingforintrusiveadssites", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edgev78diff~policy~microsoft_edge_adssettingforintrusiveadssites_adssettingforintrusiveadssites", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "children": [], + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edgev78diff~policy~microsoft_edge_adssettingforintrusiveadssites_adssettingforintrusiveadssites_2" + } + } + ], + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edgev78diff~policy~microsoft_edge_adssettingforintrusiveadssites_1" + } + } + }, + { + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge_downloadrestrictions", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge_downloadrestrictions_downloadrestrictions", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "children": [], + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge_downloadrestrictions_downloadrestrictions_2" + } + } + ], + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge_downloadrestrictions_1" + } + } + }, + { + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge_configuredonottrack", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "children": [], + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge_configuredonottrack_1" + } + } + }, + { + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge~contentsettings_defaultpopupssetting", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge~contentsettings_defaultpopupssetting_defaultpopupssetting", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "children": [], + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge~contentsettings_defaultpopupssetting_defaultpopupssetting_2" + } + } + ], + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge~contentsettings_defaultpopupssetting_1" + } + } + }, + { + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edgev83diff~policy~microsoft_edge_dnsoverhttpsmode", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edgev83diff~policy~microsoft_edge_dnsoverhttpsmode_dnsoverhttpsmode", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "children": [], + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edgev83diff~policy~microsoft_edge_dnsoverhttpsmode_dnsoverhttpsmode_off" + } + } + ], + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edgev83diff~policy~microsoft_edge_dnsoverhttpsmode_1" + } + } + }, + { + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge_developertoolsavailability", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge_developertoolsavailability_developertoolsavailability", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "children": [], + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge_developertoolsavailability_developertoolsavailability_2" + } + } + ], + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge_developertoolsavailability_1" + } + } + }, + { + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edgev80diff~policy~microsoft_edge_dnsinterceptionchecksenabled", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "children": [], + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edgev80diff~policy~microsoft_edge_dnsinterceptionchecksenabled_0" + } + } + }, + { + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge~passwordmanager_passwordmanagerenabled", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "children": [], + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge~passwordmanager_passwordmanagerenabled_0" + } + } + }, + { + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge~smartscreen_smartscreenenabled", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "children": [], + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge~smartscreen_smartscreenenabled_1" + } + } + }, + { + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge~smartscreen_preventsmartscreenpromptoverride", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "children": [], + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge~smartscreen_preventsmartscreenpromptoverride_1" + } + } + }, + { + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge~smartscreen_preventsmartscreenpromptoverrideforfiles", + "settingInstanceTemplateReference": null, + "choiceSettingValue": { + "children": [], + "settingValueTemplateReference": null, + "value": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge~smartscreen_preventsmartscreenpromptoverrideforfiles_1" + } + } + } + ], + "@odata.context": "https://graph.microsoft.com/beta/$metadata#deviceManagement/configurationPolicies/$entity" +} diff --git a/Products/Intune/Config/PolicyCatalog/Windows/windows-security-baseline.json b/Products/Intune/Config/PolicyCatalog/Windows/windows-security-baseline.json new file mode 100644 index 0000000..7c65416 --- /dev/null +++ b/Products/Intune/Config/PolicyCatalog/Windows/windows-security-baseline.json @@ -0,0 +1,7510 @@ +{ + "name": "Windows - Security Baseline", + "description": "Refer to https://github.com/microsoft/Intune-ACSC-Windows-Hardening-Guidelines", + "platforms": "windows10", + "technologies": "mdm", + "roleScopeTagIds": [ + "0" + ], + "settings": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_devicelock_preventenablinglockscreencamera", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_devicelock_preventenablinglockscreencamera_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "85f42e96-fbc8-44f7-8cdf-83d645e215e0" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "7c7dc01b-fbad-4476-bbd9-97d6fdd6f557" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_devicelock_preventlockscreenslideshow", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_devicelock_preventlockscreenslideshow_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "d26fde39-5e4d-4f2f-8c49-641033edbd60" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "d4af93ed-8a54-4ce3-8468-e53486d58657" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_mssecurityguide_applyuacrestrictionstolocalaccountsonnetworklogon", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_mssecurityguide_applyuacrestrictionstolocalaccountsonnetworklogon_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "443c4209-0973-41b3-bccb-bbbc3d5a81a3" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "a6c2cdd6-2c52-4548-b9c7-a1921fcf4717" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_mssecurityguide_configuresmbv1clientdriver", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_mssecurityguide_configuresmbv1clientdriver_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_mssecurityguide_configuresmbv1clientdriver_pol_secguide_smb1clientdriver", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_mssecurityguide_configuresmbv1clientdriver_pol_secguide_smb1clientdriver_4", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "7b3823db-9ce0-4c46-8282-da2de3396551" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "5d3a1cbc-ce3e-4937-899b-81a7615b4982" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "56202f91-eee8-4c14-b3e9-c4b7e72c28d8" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "4edbc00f-ea59-4c76-83db-be2f300d1c00" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_mssecurityguide_configuresmbv1server", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_mssecurityguide_configuresmbv1server_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "860c2e23-7eaa-4735-97fb-058eae5b8719" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "3582c437-7f7d-41da-be48-f6cc875d74dc" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_mssecurityguide_enablestructuredexceptionhandlingoverwriteprotection", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_mssecurityguide_enablestructuredexceptionhandlingoverwriteprotection_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "3f96c091-58d3-456f-9e4b-9fd18e950f9e" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "861c39fc-9eef-4070-982d-6c062a28ca11" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_mssecurityguide_wdigestauthentication", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_mssecurityguide_wdigestauthentication_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "bbe0fb65-31ca-4f87-9d2d-cdc95ade5b48" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "96885e58-34ab-468d-8444-d0f91fbf10d0" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_msslegacy_ipv6sourceroutingprotectionlevel", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_msslegacy_ipv6sourceroutingprotectionlevel_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_msslegacy_ipv6sourceroutingprotectionlevel_disableipsourceroutingipv6", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_msslegacy_ipv6sourceroutingprotectionlevel_disableipsourceroutingipv6_2", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "f4e884b8-1fb3-4352-a72d-0866307b9dbe" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "087f2d64-b645-45ff-b820-8b071e321cde" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "a2ff7ae1-ed74-4c1c-89a0-e22febeb44cd" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "d26001a0-5e2d-4f45-94f6-a8fb15b016d8" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_msslegacy_ipsourceroutingprotectionlevel", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_msslegacy_ipsourceroutingprotectionlevel_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_msslegacy_ipsourceroutingprotectionlevel_disableipsourcerouting", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_msslegacy_ipsourceroutingprotectionlevel_disableipsourcerouting_2", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "3df42274-1de8-47a4-9943-bc94e0222758" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "fc9ef55d-dd87-432e-9ab5-8cb709e3beee" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "a01e5ce1-378d-4a51-97ee-54568cb4b157" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "3e5238aa-9ccc-45ed-8d94-a5a13c9ead63" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_msslegacy_allowicmpredirectstooverrideospfgeneratedroutes", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_msslegacy_allowicmpredirectstooverrideospfgeneratedroutes_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "c621460a-7722-47da-9a92-3abb93fab0be" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "e8b666cf-ae2f-48d8-bb52-0003244da18a" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_msslegacy_allowthecomputertoignorenetbiosnamereleaserequestsexceptfromwinsservers", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_msslegacy_allowthecomputertoignorenetbiosnamereleaserequestsexceptfromwinsservers_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "1a9b1e9b-b9ab-485a-b5dd-93377b1b5ee8" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "ec6ff1d8-f6f0-4476-b2ba-a300402ca625" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_dnsclient_turn_off_multicast", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_admx_dnsclient_turn_off_multicast_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "dca2e64b-118f-48f5-af56-1e5f26e4e496" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "c2beaa88-fa59-4f55-99bf-a87e83d0e40e" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_networkconnections_nc_showsharedaccessui", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_admx_networkconnections_nc_showsharedaccessui_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "658d04ad-6c76-440d-b30c-0c3304f421ec" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "fd9e16e7-3362-4849-bdd9-347f0eb2f013" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_windowsconnectionmanager_prohitconnectiontonondomainnetworkswhenconnectedtodomainauthenticatednetwork", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_windowsconnectionmanager_prohitconnectiontonondomainnetworkswhenconnectedtodomainauthenticatednetwork_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "eef724ca-465a-43bf-9b7e-fa18e3ec128a" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "787c60ff-a23f-4843-985f-5a537c0508cf" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_printers_configureredirectionguardpolicy", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_printers_configureredirectionguardpolicy_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_printers_configureredirectionguardpolicy_redirectionguardpolicy_enum", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_printers_configureredirectionguardpolicy_redirectionguardpolicy_enum_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "a055ad15-a86a-4c10-91a3-bf4e5cf9aefe" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "ec55a527-ce66-42eb-a449-9966e63b822f" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "84dcb49d-06e7-469a-9119-2b2d8c82d9f3" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "8203e137-23e9-4060-92e8-ecd45dd6e63e" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_printers_configurerpcconnectionpolicy", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_printers_configurerpcconnectionpolicy_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_printers_configurerpcconnectionpolicy_rpcconnectionauthentication_enum", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_printers_configurerpcconnectionpolicy_rpcconnectionauthentication_enum_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "9b2bd846-f46b-4c88-941f-7e835e98ec97" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "f06e3c57-3493-4b72-ac34-71ac856eb52a" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_printers_configurerpcconnectionpolicy_rpcconnectionprotocol_enum", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_printers_configurerpcconnectionpolicy_rpcconnectionprotocol_enum_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "a64aba40-2334-4e0e-ba5e-c2568be53204" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "6bb9775f-7b64-414d-93ad-d612270ef817" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "911bc668-aca7-40f8-920e-f70b35e052b2" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "83bda0a1-db1b-4fcb-ad7a-ab5b1635bb80" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_printers_configurerpclistenerpolicy", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_printers_configurerpclistenerpolicy_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_printers_configurerpclistenerpolicy_rpclistenerprotocols_enum", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_printers_configurerpclistenerpolicy_rpclistenerprotocols_enum_5", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "09bd0f47-8b2d-4518-8328-785345a22221" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "0c505b3a-b1f3-4963-948a-e45ec4e04ee7" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_printers_configurerpclistenerpolicy_rpcauthenticationprotocol_enum", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_printers_configurerpclistenerpolicy_rpcauthenticationprotocol_enum_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "394ffdbf-1a51-4e2e-988d-5b187344dbc4" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "79479349-3107-47ac-942e-0153ee655a2e" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "7d8ffd7f-4abc-4e2d-9ae9-5f47103fda44" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "49a5e54e-685c-4c33-898d-d06db872bc67" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_printers_configurerpctcpport", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_printers_configurerpctcpport_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_printers_configurerpctcpport_rpctcpport", + "simpleSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationIntegerSettingValue", + "value": 0, + "settingValueTemplateReference": { + "settingValueTemplateId": "51da7e44-cf39-4880-b3b2-8b40a4e4bb5e" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "c579c06a-37c2-41e8-989d-ee7645fbb4c8" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "1cde5600-1862-41f4-90c5-d1f5b5bdbac3" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "ae93831c-e0a2-4481-a5a0-35ac7ca5d582" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_printers_restrictdriverinstallationtoadministrators", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_printers_restrictdriverinstallationtoadministrators_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "843603d7-5624-45d4-8990-81da10cb97a6" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "66b2729a-5232-4bb3-9106-7cf17f29df14" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_printers_configurecopyfilespolicy", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_printers_configurecopyfilespolicy_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_printers_configurecopyfilespolicy_copyfilespolicy_enum", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_printers_configurecopyfilespolicy_copyfilespolicy_enum_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "e6b871f6-75c1-43ba-85f0-d3f25b68f3d1" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "89cb9227-10a6-49b5-9fb6-b6c03996feac" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "8052e4ca-bb3e-42f3-aeff-bedccd112567" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "333f196c-6067-46a0-84a8-e343f297ce24" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "user_vendor_msft_policy_config_admx_wpn_nolockscreentoastnotification", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "user_vendor_msft_policy_config_admx_wpn_nolockscreentoastnotification_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "69133b32-d460-4f1e-a573-ca09aef142c9" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "8fa65f3d-a89a-4a03-a4e8-af26f55cd8bf" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_credssp_allowencryptionoracle", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_admx_credssp_allowencryptionoracle_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_credssp_allowencryptionoracle_allowencryptionoracledrop", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_admx_credssp_allowencryptionoracle_allowencryptionoracledrop_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "66a96d0c-3a4f-4e25-b443-423c0aa42788" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "d9e03c33-c6c1-4644-982f-41046fd4fc5d" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "a4e0fbd3-202c-4874-9a8b-d4b56050ad99" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "952d951e-92af-4eda-9424-26c06308dda3" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_credentialsdelegation_remotehostallowsdelegationofnonexportablecredentials", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_credentialsdelegation_remotehostallowsdelegationofnonexportablecredentials_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "63588f93-c0ec-4561-af47-f6437b12f843" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "1159aa66-0335-4f49-b6ca-29a3453f0d64" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_system_bootstartdriverinitialization", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_system_bootstartdriverinitialization_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_system_bootstartdriverinitialization_selectdriverloadpolicy", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_system_bootstartdriverinitialization_selectdriverloadpolicy_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "87dc517d-962a-4a3c-b2c4-1ae62cc0a1f3" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "3cb3af79-0fb0-4502-b7bc-bb6f8e64512c" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "6abbd0b9-89ca-4370-a56d-a276ca1223c5" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "e92a938f-2152-4ca4-bae4-bc51ff1901fe" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_grouppolicy_cse_registry", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_admx_grouppolicy_cse_registry_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_grouppolicy_cse_registry_cse_nobackground10", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_admx_grouppolicy_cse_registry_cse_nobackground10_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "054d3037-4453-4b5e-9ff4-df14e0e0d9e8" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "125b38b9-28db-4504-94db-93e8df2ce458" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_grouppolicy_cse_registry_cse_nochanges10", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_admx_grouppolicy_cse_registry_cse_nochanges10_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "bf3ac28e-cdde-4d28-8ab6-e309fe50cb48" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "f88923bd-3c73-4af6-b6eb-b7175587454e" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "7fdb5bc9-1885-40ce-84af-a9e2d9a1f31f" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "97e260c3-1b68-4bac-8103-88f7254766bc" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_connectivity_disabledownloadingofprintdriversoverhttp", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_connectivity_disabledownloadingofprintdriversoverhttp_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "dce564b3-dc1e-443c-8691-13f79ee8da6d" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "198f553e-44c0-4bbd-8e70-63988284c63b" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_connectivity_disableinternetdownloadforwebpublishingandonlineorderingwizards", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_connectivity_disableinternetdownloadforwebpublishingandonlineorderingwizards_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "2406d8a3-b02a-474b-8e6e-c860ccbb06e7" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "c3f1d038-0d63-4f17-bc59-16b8dad092a1" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localsecurityauthority_allowcustomsspsaps", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_localsecurityauthority_allowcustomsspsaps_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "e36a4034-4355-4ec6-b45c-c9cb49730bc2" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "fb1c0fdf-250a-4aa2-999e-066b3bee7f02" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_power_allowstandbystateswhensleepingonbattery", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_power_allowstandbystateswhensleepingonbattery_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "5daa5658-5e48-4d10-a9a5-d35e22d769e2" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "0e1d6304-83e8-47b5-8cd2-8d40cc88e48c" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_power_allowstandbywhensleepingpluggedin", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_power_allowstandbywhensleepingpluggedin_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "6904d36c-a0b7-40d5-a52d-189f75edf716" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "77e56e66-27e4-4218-81cf-a1d4f90f8b52" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_power_requirepasswordwhencomputerwakesonbattery", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_power_requirepasswordwhencomputerwakesonbattery_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "5c65ba39-635f-4c54-813a-9fc104f559c1" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "245d8497-6014-4a16-a6d4-64732b56eac7" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_power_requirepasswordwhencomputerwakespluggedin", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_power_requirepasswordwhencomputerwakespluggedin_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "6a551346-13ad-45d6-8841-50f77f2f7265" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "266b7149-095b-4ae2-b13a-d47bc581d4dc" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remoteassistance_solicitedremoteassistance", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_remoteassistance_solicitedremoteassistance_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "ad2dce5e-9555-473c-9052-1567fae10387" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "fac14fa0-103a-4ac3-8e78-0eba8559ffb9" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remoteprocedurecall_restrictunauthenticatedrpcclients", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_remoteprocedurecall_restrictunauthenticatedrpcclients_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remoteprocedurecall_restrictunauthenticatedrpcclients_rpcrestrictremoteclientslist", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_remoteprocedurecall_restrictunauthenticatedrpcclients_rpcrestrictremoteclientslist_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "a7211876-0e46-44df-8b35-9cccf902617e" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "ef7fe777-bad6-4cb9-98ff-b2ee40141f47" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "67061c0f-16dc-44fe-8c71-afdb9aede19c" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "556583b0-f6db-4cd3-b66f-2282141a0a24" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_appruntime_allowmicrosoftaccountstobeoptional", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_appruntime_allowmicrosoftaccountstobeoptional_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "0baf3b8b-2d74-4d5b-8b68-bdeb2dad0c9d" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "648d002e-2881-443b-9456-7b130057afa1" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_autoplay_disallowautoplayfornonvolumedevices", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_autoplay_disallowautoplayfornonvolumedevices_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "f7b27b50-11e5-4af5-b46b-5df979746881" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "efbfe7ed-ee10-4b85-8425-41e303a28f9d" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_autoplay_setdefaultautorunbehavior", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_autoplay_setdefaultautorunbehavior_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_autoplay_setdefaultautorunbehavior_noautorun_dropdown", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_autoplay_setdefaultautorunbehavior_noautorun_dropdown_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "cc7f585d-7c0b-4a43-bb65-8e2e68d392d3" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "90dedabe-7632-4239-954f-0930cf9829dc" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "8d3d8a9c-98c3-480f-94f8-3ff56f5439b5" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "40d3aaba-a55b-4aaf-b45b-2632b337ac98" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_autoplay_turnoffautoplay", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_autoplay_turnoffautoplay_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_autoplay_turnoffautoplay_autorun_box", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_autoplay_turnoffautoplay_autorun_box_255", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "455f1e10-b6ef-4099-8970-79d12e01a444" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "0884a2bc-e6a4-4199-b403-79957c3012f8" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "8bd1f894-315a-49cb-90a4-c5071e3a8160" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "ed920709-7063-4b8e-94cc-591f0c7de67a" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_bitlocker_fixeddrivesrequireencryption", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_bitlocker_fixeddrivesrequireencryption_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "169be857-bf3f-4731-91cb-691650717fc9" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "a9811db0-8881-4422-baa4-df016a507284" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_bitlocker_removabledrivesrequireencryption", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_bitlocker_removabledrivesrequireencryption_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_bitlocker_removabledrivesrequireencryption_rdvcrossorg", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_bitlocker_removabledrivesrequireencryption_rdvcrossorg_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "d5c30693-e1be-47a7-9495-23d5fbfc841d" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "4b58ddd6-5d02-42b0-ab35-8aa8f4477a99" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "45973ed9-4c0e-4fce-b5a0-09c8892f23db" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "4f1f73d5-7336-4672-8c9c-8043f18ee1fe" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_credentialsui_enumerateadministrators", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_credentialsui_enumerateadministrators_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "7cc94336-6190-4baa-9cd1-b1c95ff9ef0e" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "bec232a1-f08c-4f0a-94b4-019561750e12" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_windowsexplorer_enablesmartscreen", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_admx_windowsexplorer_enablesmartscreen_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_windowsexplorer_enablesmartscreen_enablesmartscreendropdown", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_admx_windowsexplorer_enablesmartscreen_enablesmartscreendropdown_block", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "c22f6198-8333-4266-8154-b2dbc26884ef" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "67226836-e0e1-4a56-bc4b-939abe5c77c1" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "6756fdf5-ea8c-4413-894d-a7bd63ef2470" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "4de272f0-1776-4c55-87f3-7fec1614a512" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_fileexplorer_turnoffdataexecutionpreventionforexplorer", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_fileexplorer_turnoffdataexecutionpreventionforexplorer_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "8d94c445-724f-4b99-a69d-82890d1a8b06" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "885e38ff-1d91-46d3-a89e-5137b8f0697c" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_fileexplorer_turnoffheapterminationoncorruption", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_fileexplorer_turnoffheapterminationoncorruption_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "8b382fb6-1d53-4024-914d-7a6fe0607b4d" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "d9d2278a-f585-496f-b23f-63b7089b242f" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_allowsoftwarewhensignatureisinvalid", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_allowsoftwarewhensignatureisinvalid_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "7fe32b7a-ab5a-486f-be8a-56ba2fd2185b" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "d0b9190a-5327-4b20-a300-2cb541c7f230" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_checkservercertificaterevocation", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_checkservercertificaterevocation_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "039ba3e4-2022-418e-95a5-830a49349783" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "e34785c4-1ba6-4bc3-88dd-b046a2252196" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_checksignaturesondownloadedprograms", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_checksignaturesondownloadedprograms_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "35d2582a-85d8-43c9-aa54-42391e9814d9" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "e38ed687-3528-4838-889f-69e911851a45" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_donotallowactivexcontrolsinprotectedmode", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_donotallowactivexcontrolsinprotectedmode_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "b0262585-c286-4bb4-8944-eb2c0e708a68" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "739e98f9-d569-46d9-8198-8da2e39106b2" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_disableencryptionsupport", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_disableencryptionsupport_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_disableencryptionsupport_advanced_wininetprotocoloptions", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_disableencryptionsupport_advanced_wininetprotocoloptions_2560", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "48ac1015-e007-4a29-b620-20651699c76d" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "71ea2cc9-2f65-4d8a-981b-13036bc1c8da" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "3e133440-3881-48f5-8734-823bcd216bda" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "e81ea4c8-aa61-4db6-8e61-a0424dcae124" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_disableprocessesinenhancedprotectedmode", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_disableprocessesinenhancedprotectedmode_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "3f69b8b9-8308-4976-8d64-1e1f7de8e155" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "809ed266-09b4-46b0-bf66-e36b6999a781" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_allowenhancedprotectedmode", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_allowenhancedprotectedmode_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "83410c6a-7c24-49b6-a25b-7a4da7a5c9f7" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "31a0047a-aa14-4889-90d1-5d5947fcbb4c" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowaccesstodatasources", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowaccesstodatasources_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowaccesstodatasources_iz_partname1406", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowaccesstodatasources_iz_partname1406_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "d1da06d7-da3d-4c24-81c9-f40a62469c0d" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "59b86f96-bcdd-4fd8-aa73-1077a1c24fd3" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "c2db104a-02c3-478f-bc39-d4e8c4677f46" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "c6b1f0a1-ce57-4635-a469-7c129fbad863" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowcopypasteviascript", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowcopypasteviascript_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowcopypasteviascript_iz_partname1407", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowcopypasteviascript_iz_partname1407_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "52bad3e0-3aa4-4dc1-8f95-0ca76643f6d6" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "12a94c7f-f345-4eea-aefc-f176f1576b8a" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "b9466cf2-acd9-4650-b2d8-350ac55704ec" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "8dc80818-0328-46ee-aac2-d5f9add4f05a" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowdraganddropcopyandpastefiles", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowdraganddropcopyandpastefiles_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowdraganddropcopyandpastefiles_iz_partname1802", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowdraganddropcopyandpastefiles_iz_partname1802_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "547a19be-0b80-400e-862c-3a500cfca60f" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "ec2aa58b-640b-4d83-b81d-d93b069e20e9" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "c5d5e31a-c9a4-4dd5-aa12-c73b7d12b117" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "7f6be888-4368-48e1-a81c-bfba283f5159" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowloadingofxamlfiles", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowloadingofxamlfiles_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowloadingofxamlfiles_iz_partname2402", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowloadingofxamlfiles_iz_partname2402_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "8cff938b-bfd1-432a-865a-a3480d3c8cc2" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "e508521d-a837-4a9f-ba6a-5632dc30763a" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "a1a063f6-655b-4ad2-b54e-ec66f65e8e28" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "91b1f6cf-e265-40f0-a31f-81fdeec11ea0" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowonlyapproveddomainstouseactivexcontrols", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowonlyapproveddomainstouseactivexcontrols_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowonlyapproveddomainstouseactivexcontrols_iz_partname120b", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowonlyapproveddomainstouseactivexcontrols_iz_partname120b_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "120060f5-4646-4c9f-9600-6713f559be87" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "5a50d633-f2e3-4408-af07-cb4fd7e8da69" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "711d3883-c522-4ff1-8a5c-bd7fbc91c736" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "04aa177c-def8-4a90-aa5e-213a5f74b6a4" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowonlyapproveddomainstousetdcactivexcontrol", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowonlyapproveddomainstousetdcactivexcontrol_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowonlyapproveddomainstousetdcactivexcontrol_iz_partname120c", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowonlyapproveddomainstousetdcactivexcontrol_iz_partname120c_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "d6f1cdcd-1ba8-45bb-8103-34167cc19e3a" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "dc4f83a4-d0f3-459f-b6f6-319908793199" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "47615484-9039-49bc-9e29-96e428080e11" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "aa226371-0e5f-4b98-8817-823ab183e645" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowscriptinitiatedwindows", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowscriptinitiatedwindows_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowscriptinitiatedwindows_iz_partname2102", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowscriptinitiatedwindows_iz_partname2102_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "91f35b27-99da-4689-9046-910247bc3d0f" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "908f06f9-4cef-4889-a726-778a3819f31b" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "2c34fec4-8fd9-4745-8b5b-0d2168acb778" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "ad6f576b-5ced-4979-877b-7c5a3ee0e74b" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowscriptingofinternetexplorerwebbrowsercontrols", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowscriptingofinternetexplorerwebbrowsercontrols_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowscriptingofinternetexplorerwebbrowsercontrols_iz_partname1206", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowscriptingofinternetexplorerwebbrowsercontrols_iz_partname1206_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "81ddb101-6ab5-44f2-b6b4-c71aaae5f172" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "7e4da519-137a-4643-b216-a585a6672604" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "0567cafe-c9f4-4c1e-bbbb-4c727f1e49f4" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "1e6590ea-e35c-4331-a53b-8f775c05f154" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowscriptlets", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowscriptlets_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowscriptlets_iz_partname1209", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowscriptlets_iz_partname1209_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "c0dedee9-31e6-4e34-8423-5ea3f011dfb7" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "8738d71f-5c2d-43b7-bc53-e73b02114f04" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "a415b0b5-9806-4fdf-8a8c-73e098b3265f" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "f251b0c9-3936-491d-a654-f529249965ed" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowupdatestostatusbarviascript", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowupdatestostatusbarviascript_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowupdatestostatusbarviascript_iz_partname2103", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowupdatestostatusbarviascript_iz_partname2103_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "f8cc1fe5-4663-459f-8932-b08d700c3040" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "c6367acc-4406-46bf-9bce-292184d95f0a" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "b73e36e2-9f42-4174-b15b-e83cbe023a0b" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "091809e5-92a8-4b25-8b3a-f03ad1d0a87c" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowvbscripttorunininternetexplorer", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowvbscripttorunininternetexplorer_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowvbscripttorunininternetexplorer_iz_partname140c", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowvbscripttorunininternetexplorer_iz_partname140c_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "67ab9121-6656-4b90-9d69-2831589a0070" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "71ddb41b-feb1-440a-8e5c-0129bb596e85" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "fb5c43ef-5e11-45c0-9274-e7308c757a77" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "4421ce4b-8b3e-4ac3-ac6b-63f2a0b8c768" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowautomaticpromptingforfiledownloads", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowautomaticpromptingforfiledownloads_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowautomaticpromptingforfiledownloads_iz_partname2200", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowautomaticpromptingforfiledownloads_iz_partname2200_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "6836b580-7956-4602-810f-527f2cdb0b24" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "88c834ea-925e-4013-bc1d-67b49498317a" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "65adb8ec-0181-43f1-88e4-4eecb702a39a" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "a8031520-8439-489b-b59f-55a302939069" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzonedonotrunantimalwareagainstactivexcontrols", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzonedonotrunantimalwareagainstactivexcontrols_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzonedonotrunantimalwareagainstactivexcontrols_iz_partname270c", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzonedonotrunantimalwareagainstactivexcontrols_iz_partname270c_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "5ad7bda3-a2f0-48a5-be7f-56568aaaac16" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "a77be816-a14b-4649-abf1-7087fe594aa8" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "c9bad151-d482-4440-bbb0-8d53f4360ded" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "be011321-a19b-495a-bcad-43b8b09d7f8b" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzonedownloadsignedactivexcontrols", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzonedownloadsignedactivexcontrols_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzonedownloadsignedactivexcontrols_iz_partname1001", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzonedownloadsignedactivexcontrols_iz_partname1001_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "6aea53c3-fd24-46b1-9326-677d32db0176" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "29f00797-3a94-435b-a99d-3a6917669d77" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "cb2dc912-814c-4cc3-a13c-cdf223997ed4" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "bbc60f19-cf2c-4d2d-8bf6-babec3e168be" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzonedownloadunsignedactivexcontrols", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzonedownloadunsignedactivexcontrols_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzonedownloadunsignedactivexcontrols_iz_partname1004", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzonedownloadunsignedactivexcontrols_iz_partname1004_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "c5fd96ca-9d7c-4039-814a-d02efec66c93" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "06276ff1-74c6-4c10-9d7a-0378d479873a" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "48ac368b-b8cd-4293-bc1a-5657f73b5164" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "c038671e-7af4-468d-b24a-a42d4b91e600" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneenabledraggingofcontentfromdifferentdomainsacrosswindows", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneenabledraggingofcontentfromdifferentdomainsacrosswindows_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneenabledraggingofcontentfromdifferentdomainsacrosswindows_iz_partname2709", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneenabledraggingofcontentfromdifferentdomainsacrosswindows_iz_partname2709_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "810ebed5-0102-4bc2-a37c-24f3e4306412" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "7fbd9d52-aa71-4c78-834c-05d8fc274522" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "57ab1599-33dc-49a2-9a04-8326a5e50dba" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "9e47de7a-af27-4a34-8f17-274b24e1de2e" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneenabledraggingofcontentfromdifferentdomainswithinwindows", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneenabledraggingofcontentfromdifferentdomainswithinwindows_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneenabledraggingofcontentfromdifferentdomainswithinwindows_iz_partname2708", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneenabledraggingofcontentfromdifferentdomainswithinwindows_iz_partname2708_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "0d1ed23c-cf89-4467-b65e-2b035a670ca0" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "0faa395b-e9d7-45f7-9a0d-9f82b233fefb" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "1ebd8964-7859-4f33-b3d1-98f9bb4b7d90" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "676d54cf-a876-4fb5-9884-769f9eefe503" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneincludelocalpathwhenuploadingfilestoserver", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneincludelocalpathwhenuploadingfilestoserver_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneincludelocalpathwhenuploadingfilestoserver_iz_partname160a", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneincludelocalpathwhenuploadingfilestoserver_iz_partname160a_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "c3310fee-4848-442b-80e2-f9a19669ddd3" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "dd2e5b34-c2f6-46c6-8c51-4b9aec69efb1" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "4780274c-6ff2-4085-9cb1-656ef35663ac" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "56c24b5b-8961-42cf-9a60-da556bbb4f70" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneinitializeandscriptactivexcontrols", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneinitializeandscriptactivexcontrols_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneinitializeandscriptactivexcontrols_iz_partname1201", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneinitializeandscriptactivexcontrols_iz_partname1201_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "7370acd9-0b38-453f-b76f-2f5fffd93026" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "53734adf-a34a-4836-9f06-f0beaa63e18a" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "2d277658-8856-465d-a522-533d22c2f3f2" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "c32b4069-ef87-4afb-b7a0-635360659726" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzonejavapermissions", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzonejavapermissions_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzonejavapermissions_iz_partname1c00", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzonejavapermissions_iz_partname1c00_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "d9d258e9-e8f0-4668-b184-2572e77a065e" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "002aaffa-e83e-4fe1-90a1-f08146e21c3f" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "49390a1a-0c78-41fe-9c42-2bd33187e770" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "70d20b6f-9771-487f-a89b-d23efcfd65cf" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzonelaunchingapplicationsandfilesiniframe", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzonelaunchingapplicationsandfilesiniframe_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzonelaunchingapplicationsandfilesiniframe_iz_partname1804", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzonelaunchingapplicationsandfilesiniframe_iz_partname1804_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "410a2838-f766-4bd3-960a-6486431c37bc" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "41ad518b-4826-4853-87cf-4d3776929169" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "5203ab41-4307-4fa0-a382-dfc948e5dd45" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "9f1bf0f5-b2fd-44a5-b65a-16fa65cb821f" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzonelogonoptions", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzonelogonoptions_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzonelogonoptions_iz_partname1a00", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzonelogonoptions_iz_partname1a00_65536", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "9ada1109-3bb2-4ff6-86ed-0421bb3be807" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "58331549-d7b9-4f60-99b4-ae209e2e610e" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "b32fe460-1e34-4e85-bcfc-8d67c980d318" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "311bc017-5814-48c5-a18c-c6fef4af856f" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzonenavigatewindowsandframes", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzonenavigatewindowsandframes_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzonenavigatewindowsandframes_iz_partname1607", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzonenavigatewindowsandframes_iz_partname1607_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "edfa7403-345e-4c12-a0c4-5d2502954576" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "f36476b7-2ebd-4627-8b94-8c35569336bf" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "7215158d-7031-44b9-ab4b-b9f79cfe4056" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "bb4d0d65-bcfb-4add-b18e-249ee1eec80f" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallownetframeworkreliantcomponents", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallownetframeworkreliantcomponents_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallownetframeworkreliantcomponents_iz_partname2004", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallownetframeworkreliantcomponents_iz_partname2004_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "9a03335e-7702-4742-91fe-feabfecddbb1" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "99e8ee0a-0929-45ee-a5b3-712924d83b0a" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "269af8d1-4b15-4c10-b8ee-1804846e371e" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "a827f535-a9df-415b-a519-280986d8492a" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzonerunnetframeworkreliantcomponentssignedwithauthenticode", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzonerunnetframeworkreliantcomponentssignedwithauthenticode_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzonerunnetframeworkreliantcomponentssignedwithauthenticode_iz_partname2001", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzonerunnetframeworkreliantcomponentssignedwithauthenticode_iz_partname2001_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "2d3340d0-9030-4069-99a1-682e7ecd2836" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "5b4dc9cf-2edc-4c22-9000-b3d6aad7a6b6" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "0d9ba0aa-b207-4057-962e-2bfb5c2a11bf" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "dac78f54-2d36-43e7-909c-76dcc4d809b7" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneshowsecuritywarningforpotentiallyunsafefiles", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneshowsecuritywarningforpotentiallyunsafefiles_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneshowsecuritywarningforpotentiallyunsafefiles_iz_partname1806", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneshowsecuritywarningforpotentiallyunsafefiles_iz_partname1806_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "13281e96-eac1-4ee3-a637-05df5d84ad78" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "aba8a9c1-ae36-46ab-99de-13ab130b9103" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "147681cd-370d-4529-b48d-e335de643288" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "59ceceb4-f666-4c9d-a635-0fa0e5f7de23" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneenablecrosssitescriptingfilter", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneenablecrosssitescriptingfilter_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneenablecrosssitescriptingfilter_iz_partname1409", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneenablecrosssitescriptingfilter_iz_partname1409_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "e4d562c4-b02c-40ad-9441-0341ffd714fc" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "437a9087-58ac-4199-9c1f-23dffdbeae0a" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "3d694d7d-83f2-4137-a3ee-c31bea163a50" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "69116d59-944e-4ac1-b531-047f615cf42a" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneenableprotectedmode", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneenableprotectedmode_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneenableprotectedmode_iz_partname2500", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneenableprotectedmode_iz_partname2500_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "4f276201-0203-4705-9076-b0dd28e5eb76" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "8162d57d-797d-4dce-888d-05efd3573b88" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "54f3d270-abf7-4319-b5f0-d20688dab3fd" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "929221b4-90f9-4da6-9942-fa8ce8f0c062" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowsmartscreenie", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowsmartscreenie_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowsmartscreenie_iz_partname2301", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowsmartscreenie_iz_partname2301_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "ea0641f1-488b-4b92-bcd3-61c0277c0059" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "733425f8-5dad-493d-83cf-0d504eebaf21" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "5566b159-7423-4a4f-9ece-97fe890cf17b" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "ede75ec7-1ccb-44c0-8839-3d7767bf2efa" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneusepopupblocker", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneusepopupblocker_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneusepopupblocker_iz_partname1809", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneusepopupblocker_iz_partname1809_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "c0a21c62-3c5c-4463-812c-71f1f6525ffc" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "bdaef8a0-e974-4e15-8738-b523dfa071de" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "d2e2c64c-b4d1-4013-9a53-02c2e8b8689f" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "ca77b6ca-4c2a-49d7-88cf-f1fb755b7952" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowuserdatapersistence", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowuserdatapersistence_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowuserdatapersistence_iz_partname1606", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowuserdatapersistence_iz_partname1606_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "87462eca-0071-494a-af30-8282552b113b" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "0d53efeb-421a-43c7-bf1f-c26cf5ae4e15" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "5de3d7cf-96f0-483a-a30a-9ecd545864d2" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "37684896-d0be-4175-a1ba-b129be49f982" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowlessprivilegedsites", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowlessprivilegedsites_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowlessprivilegedsites_iz_partname2101", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_internetzoneallowlessprivilegedsites_iz_partname2101_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "9521428f-3ecb-4c29-bd96-e777807bd57b" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "e9c38257-e7c3-4258-ad19-a709f5d5e0a5" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "c85f17be-79d1-463e-8f33-2f0942df31d7" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "0032304c-7943-48f0-8fbc-c3dc9a2b18ae" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_intranetzonedonotrunantimalwareagainstactivexcontrols", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_intranetzonedonotrunantimalwareagainstactivexcontrols_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_intranetzonedonotrunantimalwareagainstactivexcontrols_iz_partname270c", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_intranetzonedonotrunantimalwareagainstactivexcontrols_iz_partname270c_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "58644e4a-0136-43ed-a974-e6b9a30ea111" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "944d7592-bbd2-4ea7-8984-30a9b6fbb8d6" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "a429b1e3-5fec-4f9f-ae80-2344e1aec40f" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "5fd4e6bc-399d-4fd7-abe2-5801f1a97ade" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_intranetzoneinitializeandscriptactivexcontrols", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_intranetzoneinitializeandscriptactivexcontrols_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_intranetzoneinitializeandscriptactivexcontrols_iz_partname1201", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_intranetzoneinitializeandscriptactivexcontrols_iz_partname1201_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "02c9b9eb-9727-43c4-94f1-7fcbb4f8ac98" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "9a0bc393-6569-4fad-a0f5-fa2db6351946" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "15b3f944-8ea7-4a91-9e81-5a085eee639a" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "5536f9cb-5357-42bf-b210-4216f3a12510" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_intranetzonejavapermissions", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_intranetzonejavapermissions_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_intranetzonejavapermissions_iz_partname1c00", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_intranetzonejavapermissions_iz_partname1c00_65536", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "c79f70b9-1f52-47f2-9f83-41cb91819e40" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "cc44f5cf-62e0-4a96-82ee-c54c7ded3c6c" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "ad96cdfb-4e5a-41d3-a8c6-7ce978bddbc4" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "a5376071-8f90-4e19-b9f2-d706fd7a9a78" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_localmachinezonedonotrunantimalwareagainstactivexcontrols", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_localmachinezonedonotrunantimalwareagainstactivexcontrols_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_localmachinezonedonotrunantimalwareagainstactivexcontrols_iz_partname270c", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_localmachinezonedonotrunantimalwareagainstactivexcontrols_iz_partname270c_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "4f04d520-7219-4a79-863b-aa617c3540d7" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "df4962d1-aa51-4068-89bc-c903cc75dfcc" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "f57345be-ec3a-4dce-9242-63d803819411" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "2e8f4997-df75-4e06-9689-08285954a0f1" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_localmachinezonejavapermissions", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_localmachinezonejavapermissions_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_localmachinezonejavapermissions_iz_partname1c00", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_localmachinezonejavapermissions_iz_partname1c00_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "612b8d8b-c258-449a-bc1a-21d36d0564e2" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "bfb830d9-dea5-4500-9f37-7e688c76d4b4" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "965862f1-33b7-4984-91aa-fbb239151f74" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "b94eaaa4-8fa1-4b36-afbd-634e5181a370" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_lockeddowninternetzoneallowsmartscreenie", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_lockeddowninternetzoneallowsmartscreenie_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_lockeddowninternetzoneallowsmartscreenie_iz_partname2301", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_lockeddowninternetzoneallowsmartscreenie_iz_partname2301_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "55941ab7-39b7-4fd6-b332-3c47cfd14fe1" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "826fd16f-b6fb-4d70-a60a-342e32c53894" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "b4b62305-14c1-48bc-a17e-06616cea4890" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "bdccfd04-faca-4ede-be52-d35bb657019a" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_lockeddownintranetjavapermissions", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_lockeddownintranetjavapermissions_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_lockeddownintranetjavapermissions_iz_partname1c00", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_lockeddownintranetjavapermissions_iz_partname1c00_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "2b4181a0-f52f-4b89-ba4b-2d55809f76da" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "a2735a91-baa4-47fb-896c-7312d8f9f5d5" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "98678104-6cbe-45a1-b343-4a376c2fad76" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "a92395d0-bb80-4039-91f9-bb44740ae884" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_lockeddownlocalmachinezonejavapermissions", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_lockeddownlocalmachinezonejavapermissions_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_lockeddownlocalmachinezonejavapermissions_iz_partname1c00", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_lockeddownlocalmachinezonejavapermissions_iz_partname1c00_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "32892824-8139-4ced-9b29-c528d5a9fafd" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "c9996b31-8f52-413d-941b-4e8a5f181754" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "7bd13187-8bd1-40f4-b587-849c83788782" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "8b713db8-b4c7-4f74-bb31-fdbab2d2adc5" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_lockeddownrestrictedsiteszonejavapermissions", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_lockeddownrestrictedsiteszonejavapermissions_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_lockeddownrestrictedsiteszonejavapermissions_iz_partname1c00", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_lockeddownrestrictedsiteszonejavapermissions_iz_partname1c00_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "0fa970e5-e867-42f5-a5c3-bead7dff3e28" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "b8d8f6ca-66dc-422d-a364-10cb99cfe114" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "bfe81b50-13f0-4527-a919-ba2bb7566d9a" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "19dab1ba-57c4-489f-86ec-382ed28d42c1" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_lockeddownrestrictedsiteszoneallowsmartscreenie", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_lockeddownrestrictedsiteszoneallowsmartscreenie_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_lockeddownrestrictedsiteszoneallowsmartscreenie_iz_partname2301", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_lockeddownrestrictedsiteszoneallowsmartscreenie_iz_partname2301_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "213326b3-5261-4c19-882a-a3f67f77d3a8" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "890e2205-b3b9-4335-8568-fc982d09f418" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "5bced3dd-7ae8-49eb-a016-68afdecf2fc1" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "68a07bc2-2264-410c-ac8a-0469cfe482a5" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_lockeddowntrustedsiteszonejavapermissions", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_lockeddowntrustedsiteszonejavapermissions_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_lockeddowntrustedsiteszonejavapermissions_iz_partname1c00", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_lockeddowntrustedsiteszonejavapermissions_iz_partname1c00_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "91e92cf2-daa4-47ae-ac34-3e1ea10b119d" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "53bb83a6-085a-4f0b-b836-2b81caded53b" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "fd8ac3aa-b848-43d6-af0f-b463e099ac75" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "f53ad448-47ad-4ca2-8d9e-aabb48945abe" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowaccesstodatasources", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowaccesstodatasources_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowaccesstodatasources_iz_partname1406", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowaccesstodatasources_iz_partname1406_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "b76ac21f-7463-4db4-b811-e7c7f8bc7be9" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "a19d7e52-59fc-48e4-aba5-3c138f9bf2f2" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "262b755a-779e-4148-a08b-ca17265234de" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "9b6ccc84-885e-4780-9c5d-7c16cda02e6a" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowactivescripting", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowactivescripting_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowactivescripting_iz_partname1400", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowactivescripting_iz_partname1400_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "765db41c-63ff-4a07-b1f9-075147d99b0b" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "e598fa77-758c-40b5-a4f1-a878252d86f2" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "5479325c-4a9f-42af-8d77-41586a7c3d27" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "7aebae7a-8c1f-4515-aafb-7e41b2c8c9f0" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowbinaryandscriptbehaviors", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowbinaryandscriptbehaviors_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowbinaryandscriptbehaviors_iz_partname2000", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowbinaryandscriptbehaviors_iz_partname2000_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "cb7600b8-8428-49f7-a44f-0d20c726792b" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "6854249c-7cae-4be8-a6f1-7712b4b8907c" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "68d2f053-948e-40b5-943a-9960772d7f9c" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "c719f1ec-221c-4076-b5df-294f7b85c436" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowcopypasteviascript", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowcopypasteviascript_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowcopypasteviascript_iz_partname1407", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowcopypasteviascript_iz_partname1407_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "8a7d1140-971f-410b-9db2-695a674545b2" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "132040d2-3151-450d-8b4e-dd39ab164668" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "70bc3c9a-21d6-4821-9ae0-a5aa5391d002" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "09d5e2b3-1a71-4b7f-90e0-e9d4b3267696" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowdraganddropcopyandpastefiles", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowdraganddropcopyandpastefiles_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowdraganddropcopyandpastefiles_iz_partname1802", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowdraganddropcopyandpastefiles_iz_partname1802_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "e3a47052-3fbc-458c-8aef-ce4d66ef1f6d" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "09d7e1b1-7fa3-4869-9051-56aceba5977a" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "3cedb909-b22d-4fb4-b686-12a25d5af05f" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "963218e6-077b-4faa-a2fc-54522b2dea2c" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowfiledownloads", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowfiledownloads_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowfiledownloads_iz_partname1803", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowfiledownloads_iz_partname1803_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "097edf65-dac6-4f2e-98bb-2fe28c00118d" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "548fcd3f-8c26-49ec-8b52-bc23794a6f6e" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "6731bba7-a542-4294-b126-822d6a930814" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "b14aa58f-0afd-4c1b-8e7a-d1b2062986d1" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowloadingofxamlfiles", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowloadingofxamlfiles_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowloadingofxamlfiles_iz_partname2402", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowloadingofxamlfiles_iz_partname2402_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "cf5ba088-1354-41a8-8ef5-76f47dd2acca" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "82666f14-4f26-4313-902d-d26483883428" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "b88a01ff-cc4e-4443-a0c5-614752b6cf26" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "bbe6d765-2897-4c3e-a835-806cd28083d4" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowmetarefresh", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowmetarefresh_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowmetarefresh_iz_partname1608", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowmetarefresh_iz_partname1608_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "4b925ad1-9eda-43e0-8acc-7a6f590924b6" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "3401186f-56d2-4f4c-a2fa-46d12e507655" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "8c7eca32-fd5a-4160-98de-4e9d44fa9cc9" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "e35d4493-f226-4cdd-b84e-4e7e9025c7a0" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowonlyapproveddomainstouseactivexcontrols", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowonlyapproveddomainstouseactivexcontrols_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowonlyapproveddomainstouseactivexcontrols_iz_partname120b", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowonlyapproveddomainstouseactivexcontrols_iz_partname120b_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "1b442d16-b9df-4eba-abbe-2d8d4ba4be5f" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "c5288979-1038-49d5-bd75-5a76790599c4" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "4de25904-da66-4034-877a-ff89b70cd8ae" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "6e4c63e9-43a4-43e2-805c-ae1ddc203019" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowonlyapproveddomainstousetdcactivexcontrol", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowonlyapproveddomainstousetdcactivexcontrol_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowonlyapproveddomainstousetdcactivexcontrol_iz_partname120c", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowonlyapproveddomainstousetdcactivexcontrol_iz_partname120c_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "83cc898d-b73d-4a23-99e4-22f4ec6b9ee7" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "e689f1b2-9f98-4694-ab77-df5883a0f2fb" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "745259c0-31ae-41dd-8045-162722fc2733" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "aab332c1-d04f-4a31-a31c-25f76e39b1fa" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowscriptinitiatedwindows", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowscriptinitiatedwindows_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowscriptinitiatedwindows_iz_partname2102", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowscriptinitiatedwindows_iz_partname2102_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "a1399f75-c0a8-4bd1-904d-4e8e1e2c3e81" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "e751bb6e-0d47-4132-9cb5-b4f86f8d6d9e" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "8284fa71-87fb-4a25-8381-5ffe82767e6a" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "39b4b896-17ce-42be-93bd-a06e8d7b7959" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowscriptingofinternetexplorerwebbrowsercontrols", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowscriptingofinternetexplorerwebbrowsercontrols_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowscriptingofinternetexplorerwebbrowsercontrols_iz_partname1206", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowscriptingofinternetexplorerwebbrowsercontrols_iz_partname1206_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "d000cebf-2589-43af-8e93-73a5766b77c5" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "0d0653f8-03c9-41f7-9d9b-81d261102d90" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "12244b61-79ac-4550-b595-6d6e45888897" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "68fdd4d5-a580-40bb-9bff-379d2efddf81" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowscriptlets", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowscriptlets_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowscriptlets_iz_partname1209", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowscriptlets_iz_partname1209_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "9b6baf2c-dd0a-4715-bb8b-8c219ea463ca" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "6fd157c4-80d6-49a2-ad98-7f397781191d" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "3c3248a8-3200-4b91-9c3a-dad3b9ee250f" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "0390be1a-0e76-4d74-b253-f6ffc52b1ce0" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowupdatestostatusbarviascript", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowupdatestostatusbarviascript_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowupdatestostatusbarviascript_iz_partname2103", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowupdatestostatusbarviascript_iz_partname2103_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "fa091853-68bc-45c9-bf78-37edc4bb11b0" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "3be78f1f-97ea-4137-b5fb-109dd02050ff" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "6e04de9b-0e2b-47d1-92bc-79623bd917f7" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "436e5374-50f5-4c1a-a6b7-f2bb8cf33ac4" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowvbscripttorunininternetexplorer", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowvbscripttorunininternetexplorer_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowvbscripttorunininternetexplorer_iz_partname140c", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowvbscripttorunininternetexplorer_iz_partname140c_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "5ab7d09f-6d53-4f2a-9f05-7b61995606c4" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "8acdec26-60f0-4d2a-abf1-75d4c4babd81" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "f3317757-2c12-4cdc-841e-73dbde4d4440" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "53eb2c56-16cc-4b1f-bdcc-4e4050e6b21b" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowautomaticpromptingforfiledownloads", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowautomaticpromptingforfiledownloads_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowautomaticpromptingforfiledownloads_iz_partname2200", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowautomaticpromptingforfiledownloads_iz_partname2200_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "a1dace27-4bcc-4830-92b3-bf5dd8424dc6" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "d7649346-ff10-4c2b-8247-cec23e6e8840" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "371af241-e12d-45cc-88f9-b75e71f1a2ff" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "0ca72068-0e51-4ad5-bb2f-a60313a27209" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonedonotrunantimalwareagainstactivexcontrols", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonedonotrunantimalwareagainstactivexcontrols_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonedonotrunantimalwareagainstactivexcontrols_iz_partname270c", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonedonotrunantimalwareagainstactivexcontrols_iz_partname270c_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "0369a044-5207-4a09-a873-11be52f62f02" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "c5cbd77b-11bf-4496-8a07-6662c0e675ce" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "e48fe752-6160-47eb-ad0c-ec9000bb95a0" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "a9224fe1-b058-44bc-b0d5-b23c23d6d215" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonedownloadsignedactivexcontrols", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonedownloadsignedactivexcontrols_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonedownloadsignedactivexcontrols_iz_partname1001", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonedownloadsignedactivexcontrols_iz_partname1001_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "a9c3bc30-9b92-41c4-b596-2279c49e8882" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "6423e3b2-873a-4f65-8626-4f6a561977a7" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "e6c70358-5898-4438-9fce-e82cebc390dd" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "7495da73-4392-4983-a98d-bfb2539275a3" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonedownloadunsignedactivexcontrols", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonedownloadunsignedactivexcontrols_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonedownloadunsignedactivexcontrols_iz_partname1004", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonedownloadunsignedactivexcontrols_iz_partname1004_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "399ecdf3-eb0e-4272-936d-8f12cfc74d9b" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "b804c6fd-f817-44bd-adac-0a41601b97b8" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "9b79a0af-4ebc-432b-8765-0049084e8a74" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "a7e1e9a0-63a7-4f66-914e-8a704f7f7912" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneenabledraggingofcontentfromdifferentdomainsacrosswindows", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneenabledraggingofcontentfromdifferentdomainsacrosswindows_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneenabledraggingofcontentfromdifferentdomainsacrosswindows_iz_partname2709", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneenabledraggingofcontentfromdifferentdomainsacrosswindows_iz_partname2709_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "39af09f7-1b99-44ce-8173-3cdd1bc96197" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "5d93c570-1448-4a02-ae43-f662af49a815" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "2581f05f-6907-42a9-9c73-005dea804fba" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "3a853807-2fdc-4716-a575-91719db0326b" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneenabledraggingofcontentfromdifferentdomainswithinwindows", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneenabledraggingofcontentfromdifferentdomainswithinwindows_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneenabledraggingofcontentfromdifferentdomainswithinwindows_iz_partname2708", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneenabledraggingofcontentfromdifferentdomainswithinwindows_iz_partname2708_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "71949821-b2cd-494e-b2cb-b021ca455254" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "d3ad60fd-135c-4ca3-8cac-d9b94b25aa18" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "75adda9a-9d8d-4fce-b250-1d729d354a44" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "eaf2f4f7-e1f6-4f0c-ad14-06e47f317532" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneincludelocalpathwhenuploadingfilestoserver", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneincludelocalpathwhenuploadingfilestoserver_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneincludelocalpathwhenuploadingfilestoserver_iz_partname160a", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneincludelocalpathwhenuploadingfilestoserver_iz_partname160a_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "88ca478f-a8fc-4ade-a306-0b519063d36d" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "6cbd1d20-27c0-458a-955a-e374da09f0b2" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "821ae1d2-f621-493c-bda4-57def81ca18d" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "93948498-b331-4a0f-b405-05abb8e3db34" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneinitializeandscriptactivexcontrols", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneinitializeandscriptactivexcontrols_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneinitializeandscriptactivexcontrols_iz_partname1201", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneinitializeandscriptactivexcontrols_iz_partname1201_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "e4097c63-ab59-4be0-8806-31efd5bd468a" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "1a8679e5-5913-4917-9337-6fb51290251b" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "73d7aafe-78fe-4711-9cc7-04b357aa05f3" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "8ed0c074-a56d-4ad3-88e5-0f37907a8cba" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonejavapermissions", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonejavapermissions_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonejavapermissions_iz_partname1c00", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonejavapermissions_iz_partname1c00_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "7067ab10-d618-4793-8e4f-dc13272def9f" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "f705a832-f9a7-4159-9570-38eae9df3f54" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "e846b109-0d0c-4b23-baf4-260fa888cfe7" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "309be167-14f6-408d-b48b-433721a04f89" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonelaunchingapplicationsandfilesiniframe", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonelaunchingapplicationsandfilesiniframe_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonelaunchingapplicationsandfilesiniframe_iz_partname1804", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonelaunchingapplicationsandfilesiniframe_iz_partname1804_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "47fe919d-2bde-4185-934b-4036e301c914" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "28eca1f4-f1a3-43e5-b89d-1caa90c442f1" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "2a6a230a-c68d-4152-8958-a59b1cf2dd19" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "10275972-63d9-4130-9879-e2b0436d6064" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonelogonoptions", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonelogonoptions_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonelogonoptions_iz_partname1a00", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonelogonoptions_iz_partname1a00_196608", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "b8b9e74b-b47c-40aa-bf77-b9c6c4c1c00d" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "82f0bce3-d595-4c71-ade7-1ef1985d0fe8" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "d750b7c6-db93-49c1-b6cd-1d22f2774e80" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "c4def5f4-8569-4928-bdba-a08066539474" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonenavigatewindowsandframes", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonenavigatewindowsandframes_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonenavigatewindowsandframes_iz_partname1607", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonenavigatewindowsandframes_iz_partname1607_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "ca9ccb7f-a19c-4add-af33-8a2ef81f2918" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "7fb0fbcc-1e58-440f-91ca-c1b03d288206" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "6c697855-be4c-44e3-95c4-7cc73059a6b8" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "af28aee7-df91-418e-9982-e1180ed0c407" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallownetframeworkreliantcomponents", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallownetframeworkreliantcomponents_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallownetframeworkreliantcomponents_iz_partname2004", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallownetframeworkreliantcomponents_iz_partname2004_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "bf2fc6e8-c818-4190-a4ea-07cd8f02944f" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "dae08163-c39f-4a57-8dcd-c691f20e57b3" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "4c3ab6f7-a3b0-4d9d-a6f1-c643e5014f72" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "1fcc183c-b504-47f6-b1ae-5966e508dd48" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonerunnetframeworkreliantcomponentssignedwithauthenticode", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonerunnetframeworkreliantcomponentssignedwithauthenticode_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonerunnetframeworkreliantcomponentssignedwithauthenticode_iz_partname2001", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonerunnetframeworkreliantcomponentssignedwithauthenticode_iz_partname2001_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "c56706e0-57c9-4b21-96ba-10f49cdbd540" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "f07e428b-73f6-42b8-b1d6-b6d9101c2b2d" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "9f7bc83c-26a2-49d3-a5be-f399ff422708" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "8a8864a5-ff59-4733-b3e4-9326ecc3c4d1" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonerunactivexcontrolsandplugins", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonerunactivexcontrolsandplugins_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonerunactivexcontrolsandplugins_iz_partname1200", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonerunactivexcontrolsandplugins_iz_partname1200_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "95963388-dfe9-4543-bdff-786dfbda8a58" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "694e187a-2917-4780-908b-06d1b9815243" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "8f53543f-f9a2-46f3-a8b8-b97dfdd61912" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "f088c7d0-35f8-4bf7-82d1-530d421aa5fe" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonescriptactivexcontrolsmarkedsafeforscripting", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonescriptactivexcontrolsmarkedsafeforscripting_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonescriptactivexcontrolsmarkedsafeforscripting_iz_partname1405", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonescriptactivexcontrolsmarkedsafeforscripting_iz_partname1405_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "708f7841-66f3-4140-b0fc-741c26a9df20" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "fb531896-2084-4b9a-af3e-59a2fbba5673" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "1c8b7dea-d3d2-4580-a490-6ef62b743e05" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "af76dbb5-c8cc-4277-a74a-8d71901cfe0b" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonescriptingofjavaapplets", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonescriptingofjavaapplets_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonescriptingofjavaapplets_iz_partname1402", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszonescriptingofjavaapplets_iz_partname1402_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "aac69b8c-ff12-4e36-8608-78281f59e434" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "d2c8ff40-d9d8-4c61-876e-0d33006ba3b3" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "52f1e115-8c4c-4b46-bb80-0d8bb25a40f3" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "73b958a7-df60-41bb-8ce5-1b0ef135f803" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneshowsecuritywarningforpotentiallyunsafefiles", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneshowsecuritywarningforpotentiallyunsafefiles_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneshowsecuritywarningforpotentiallyunsafefiles_iz_partname1806", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneshowsecuritywarningforpotentiallyunsafefiles_iz_partname1806_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "6bb372ea-531e-40cb-b38d-9c6cc2ad2803" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "463e58ce-5ffd-486b-bd64-20315d860d3d" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "80a8ce02-cc45-4fc1-9ab0-b4af9b0249f6" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "fa4b5f3f-ea66-4e47-afae-3ae88d2515fc" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneenablecrosssitescriptingfilter", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneenablecrosssitescriptingfilter_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneenablecrosssitescriptingfilter_iz_partname1409", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneenablecrosssitescriptingfilter_iz_partname1409_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "7cad257a-221f-4da5-b3c4-b02d0f33f7e8" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "3c9f52ea-c434-4015-92ee-447291a1d7eb" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "3d4cd6a3-5e90-4802-8ef3-b1ffd6198bca" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "2629e187-7cc4-4545-b243-a367b0d62ce8" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneturnonprotectedmode", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneturnonprotectedmode_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneturnonprotectedmode_iz_partname2500", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneturnonprotectedmode_iz_partname2500_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "5932392c-6abc-44e1-ad1f-e099a988ac29" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "90eeadd1-fdef-4285-8f9c-5862e5eaee0a" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "34661614-9d0e-4a9e-bfb1-e26ee4e7e7a0" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "8b37038d-16b1-4f36-a2b2-e8a4dd7aadab" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowsmartscreenie", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowsmartscreenie_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowsmartscreenie_iz_partname2301", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowsmartscreenie_iz_partname2301_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "dfd03a1d-179e-419b-927a-77fc06c5ca43" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "fa303478-4c36-4a32-8379-3011c936a271" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "d70c46a8-9db1-412d-9555-dca4f65b9b33" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "d7cce09c-8013-4bdc-904e-f9b64170a692" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneusepopupblocker", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneusepopupblocker_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneusepopupblocker_iz_partname1809", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneusepopupblocker_iz_partname1809_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "71fb3287-3287-4ed4-8bb2-6cbb7f22cf09" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "3c1bb95f-3080-44ad-b726-50050641c852" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "0459e083-5bb0-41e9-a670-65e4377ece40" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "e34db23b-544b-428c-816f-174dfab6fbf5" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowuserdatapersistence", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowuserdatapersistence_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowuserdatapersistence_iz_partname1606", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowuserdatapersistence_iz_partname1606_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "2176bd03-8567-4e85-bb7a-615e150fdcf3" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "5ca857ef-b380-4b19-b80c-9e65f28acb55" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "189d21c1-f375-470f-9389-488031fb279d" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "3b886701-de22-4a79-92c5-d76a761cc890" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowlessprivilegedsites", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowlessprivilegedsites_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowlessprivilegedsites_iz_partname2101", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictedsiteszoneallowlessprivilegedsites_iz_partname2101_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "f119831c-e5f9-4400-9b32-ec6d50753278" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "62dce577-3b62-4613-b7b5-2383286e4a78" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "f2663f5d-9bce-490d-b78b-67744773a2f2" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "4f5355da-3a54-4c33-b75e-a6a82d0899c4" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_trustedsiteszonedonotrunantimalwareagainstactivexcontrols", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_trustedsiteszonedonotrunantimalwareagainstactivexcontrols_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_trustedsiteszonedonotrunantimalwareagainstactivexcontrols_iz_partname270c", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_trustedsiteszonedonotrunantimalwareagainstactivexcontrols_iz_partname270c_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "a5c30789-4d9e-4aa9-9ec9-9577c7756b61" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "04091c20-1a96-4f2d-903b-e3a7bb8665db" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "3c9f1eb5-6f20-4676-b494-80e843303eb0" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "2b34cfca-bc11-4e3d-b6cd-5b784c6bf243" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_trustedsiteszoneinitializeandscriptactivexcontrols", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_trustedsiteszoneinitializeandscriptactivexcontrols_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_trustedsiteszoneinitializeandscriptactivexcontrols_iz_partname1201", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_trustedsiteszoneinitializeandscriptactivexcontrols_iz_partname1201_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "ba4803ed-3c10-49d6-a3de-16b14190e10c" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "f1e8e261-b9cb-4bc5-bea2-c86ec0cb83ef" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "f6a0d5e2-5646-4bec-a042-e0a275def3b8" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "08804c0e-c275-47dd-b83e-98c792645c27" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_trustedsiteszonejavapermissions", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_trustedsiteszonejavapermissions_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_trustedsiteszonejavapermissions_iz_partname1c00", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_trustedsiteszonejavapermissions_iz_partname1c00_65536", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "0fb45999-72c8-4e1f-9b62-724c9a15eb57" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "c78fe183-5412-4bba-b353-0387354f4812" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "eea707a1-68a9-4d3a-a9bb-f00647d6938d" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "63cea4c5-8324-4bfe-94a0-b6872af4f8b3" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_includeallnetworkpaths", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_includeallnetworkpaths_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "e262d738-3267-4028-9abf-9d0f97e3c42e" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "c163c8af-c747-4dd8-a649-6bc40e10ebed" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_allowcertificateaddressmismatchwarning", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_allowcertificateaddressmismatchwarning_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "e5174eff-04c1-4292-8b3e-cc20e2d9c6e5" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "16802e3e-0dbf-4a5a-b57e-aaf9bf8656fc" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_disableignoringcertificateerrors", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_disableignoringcertificateerrors_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "5fbd9783-077a-4898-9fcc-7b48e9829b73" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "73e1e690-3fa1-4d3a-ac93-7bbcccf28bb1" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_removerunthistimebuttonforoutdatedactivexcontrols", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_removerunthistimebuttonforoutdatedactivexcontrols_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "f9e7126e-70d3-43d5-8a5b-9f15ba09dc14" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "596bb1b1-8b72-48c3-b1b2-bff757c410af" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_donotblockoutdatedactivexcontrols", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_donotblockoutdatedactivexcontrols_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "daec2c1d-c93e-484f-9458-9b63a4eef5b5" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "b090600b-5375-48fe-98a0-24c89287336b" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_consistentmimehandlinginternetexplorerprocesses", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_consistentmimehandlinginternetexplorerprocesses_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "a74036d3-be24-4273-8aa4-8c99c71b4d1e" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "e57fc662-e951-4b5b-b70d-1280044fb454" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_mimesniffingsafetyfeatureinternetexplorerprocesses", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_mimesniffingsafetyfeatureinternetexplorerprocesses_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "2394d7bc-52c9-4ce7-aab7-d3795cfd5861" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "b9291719-ba52-4fba-857a-07d06cbef4fe" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_mkprotocolsecurityrestrictioninternetexplorerprocesses", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_mkprotocolsecurityrestrictioninternetexplorerprocesses_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "d3892737-9972-46c8-b41f-64de3bf4af56" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "71722130-7b6a-471d-9240-7dfe8bb6cfb6" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_notificationbarinternetexplorerprocesses", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_notificationbarinternetexplorerprocesses_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "fa05de82-de67-4bff-9a11-fe45f1fa73d3" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "2073355c-ab74-4311-8d6c-0a5312fb95af" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_protectionfromzoneelevationinternetexplorerprocesses", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_protectionfromzoneelevationinternetexplorerprocesses_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "98194b78-041a-44ad-9b6f-b38a61d4f113" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "6cf6555c-abb9-45d3-a622-e22b03f53d8b" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictactivexinstallinternetexplorerprocesses", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictactivexinstallinternetexplorerprocesses_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "0be41807-26c8-452a-b91c-39b6f3a1b66d" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "83038d03-0599-474a-abef-bd6366407a14" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_restrictfiledownloadinternetexplorerprocesses", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_restrictfiledownloadinternetexplorerprocesses_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "a38ba1e3-e4ba-4216-9987-8ff506069f92" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "9b2b8a5d-dcd6-42ab-b0a0-565d5126ad20" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_scriptedwindowsecurityrestrictionsinternetexplorerprocesses", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_scriptedwindowsecurityrestrictionsinternetexplorerprocesses_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "92915bcd-a58b-4864-aeb6-759cd477635c" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "22821ee1-a859-4017-97e5-1443d1ebb066" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_allowfallbacktossl3", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_allowfallbacktossl3_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_allowfallbacktossl3_advanced_enablessl3fallbackoptions", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_allowfallbacktossl3_advanced_enablessl3fallbackoptions_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "5ccbb7e2-16fb-450a-b675-6bc29aca2099" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "6d500aad-d40b-4157-bed9-5f4606a9ce1d" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "a862747b-5da6-4dc1-9d80-ff4cf72fae4b" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "65fceea0-2e6d-4a8e-b264-86005ee05e17" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_disablebypassofsmartscreenwarnings", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_disablebypassofsmartscreenwarnings_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "c97c541b-ede6-4cc2-9f65-93fe5e6fb72a" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "7f070617-9e7d-43ac-8e9d-3b81a0633ba6" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_disablebypassofsmartscreenwarningsaboutuncommonfiles", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_disablebypassofsmartscreenwarningsaboutuncommonfiles_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "3213fc39-d48e-427e-a9ce-ebe9c4197eed" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "89fb78bb-1587-46df-bfc0-73a0461ab091" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_preventmanagingsmartscreenfilter", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_preventmanagingsmartscreenfilter_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_preventmanagingsmartscreenfilter_ie9safetyfilteroptions", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_preventmanagingsmartscreenfilter_ie9safetyfilteroptions_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "2e793442-86eb-4fc0-9635-6750e098ed00" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "efa03991-6324-46a2-a03e-9bc0741e1327" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "84f9ec32-01ef-4c69-ad6d-5ccdfdda39c4" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "4247f266-ca83-48cb-9f02-8c3f57d5ec13" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_preventperuserinstallationofactivexcontrols", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_preventperuserinstallationofactivexcontrols_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "2cb158ce-c2c4-490d-94f2-38362746a919" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "20ac809a-d5d1-4f54-a47d-882e5f20b6dc" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_donotallowuserstoaddsites", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_donotallowuserstoaddsites_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "2d5ac56a-6b63-463d-9092-0a374fd76eed" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "548969ab-4493-4267-9038-9e5b9cfa86b7" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_donotallowuserstochangepolicies", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_donotallowuserstochangepolicies_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "7101d91f-57b5-491c-bca2-80c4644fe122" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "38d3f791-c451-4680-9aa5-b17ce7cbbb88" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_securityzonesuseonlymachinesettings", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_securityzonesuseonlymachinesettings_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "64edca64-ddb8-4b11-b9f8-78256dccbfd0" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "7b214d48-622f-45eb-9199-0024499f07d8" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_specifyuseofactivexinstallerservice", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_specifyuseofactivexinstallerservice_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "baf0d1b0-b761-458e-866f-059818abf827" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "66c45224-8e3c-45ed-a3bd-dcaaa5a4d951" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_disablecrashdetection", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_disablecrashdetection_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "8cde1bab-d2bf-433c-a862-313fc6748665" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "687adce3-3978-4bcb-bc4b-d81c69d4e9f9" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_disablesecuritysettingscheck", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_disablesecuritysettingscheck_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "02d04991-d5da-428d-8896-0231902b7efe" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "bd34f766-d988-489e-b606-0630d84fbb0c" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "user_vendor_msft_policy_config_internetexplorer_allowautocomplete", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "user_vendor_msft_policy_config_internetexplorer_allowautocomplete_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "7ee03af0-88f3-4c1a-a936-dc56792d0c69" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "8a72456c-17dd-4791-bb90-0a0d9c131b75" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_disableblockatfirstseen", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_disableblockatfirstseen_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "5a33385a-9bda-4cc5-8fd3-5dd2a28d2af5" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "fc87eb74-b6b8-4732-9ad8-d47b3a6492d1" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_realtimeprotection_disablescanonrealtimeenable", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_realtimeprotection_disablescanonrealtimeenable_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "95343ebc-4808-43d4-b131-221f1c314757" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "25280726-7d2c-42fc-a01b-544fc5149302" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_scan_disablepackedexescanning", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_scan_disablepackedexescanning_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "59f5f1ec-a6eb-4686-b798-d9bc2b5c42ce" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "2be5c530-92c6-4b79-97a2-1fbce9535d0b" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_disableroutinelytakingaction", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_admx_microsoftdefenderantivirus_disableroutinelytakingaction_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "985e3126-ff93-414d-8fc0-db63deb10eb5" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "6deba92c-1066-4f53-971d-58543b0911c6" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remotedesktopservices_donotallowpasswordsaving", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_remotedesktopservices_donotallowpasswordsaving_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "90e3214f-8279-4eff-b62a-31bbda83ed73" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "eda46bc6-1380-413f-9901-87cfc7d7a551" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remotedesktopservices_donotallowdriveredirection", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_remotedesktopservices_donotallowdriveredirection_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "d83e0604-6098-4738-9c1e-3761af108acf" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "52f31423-9103-4c09-ad37-ace83400dda5" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remotedesktopservices_promptforpassworduponconnection", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_remotedesktopservices_promptforpassworduponconnection_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "3810b97b-b9ca-4781-b175-4c2d5df58abd" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "8d0927d1-6c9a-4538-9153-02f6b5ce13be" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remotedesktopservices_requiresecurerpccommunication", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_remotedesktopservices_requiresecurerpccommunication_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "686d2d0b-017d-4382-8425-2dd1aeb75ed8" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "5004e80c-44c0-408b-b5f7-38220c5a0813" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remotedesktopservices_clientconnectionencryptionlevel", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_remotedesktopservices_clientconnectionencryptionlevel_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remotedesktopservices_clientconnectionencryptionlevel_ts_encryption_level", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_remotedesktopservices_clientconnectionencryptionlevel_ts_encryption_level_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "c762aaae-7ee2-413c-bd15-32881aefd894" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "7af7dc9e-399a-42d6-8bed-8433c638baa9" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "553be5f0-b7f4-4a27-98a5-d64e4da809cc" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "d5c15cda-08d9-4f5c-bd90-839637072811" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_internetexplorer_disableenclosuredownloading", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_internetexplorer_disableenclosuredownloading_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "615264e2-0d23-4e59-960b-36c1c583f287" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "57446061-8492-404c-990f-e9feac9e7d6d" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_windowslogon_enablemprnotifications", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_windowslogon_enablemprnotifications_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "05132456-58ca-4286-bdee-d91690ea0b52" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "e058c985-52de-451e-b9a3-5ddbd1bbe928" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_windowslogon_allowautomaticrestartsignon", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_windowslogon_allowautomaticrestartsignon_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "6fc2ec8f-c78f-4fe2-828a-a4efc4ea7439" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "dbaf35c4-57a8-4fbf-8b27-ef018da9c3c3" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_windowspowershell_turnonpowershellscriptblocklogging", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_windowspowershell_turnonpowershellscriptblocklogging_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_windowspowershell_turnonpowershellscriptblocklogging_enablescriptblockinvocationlogging", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_windowspowershell_turnonpowershellscriptblocklogging_enablescriptblockinvocationlogging_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "28e501f8-b6e4-4329-92c3-bc1d35bb6837" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "a9740d73-a2f0-4833-aa90-99d27f765d15" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "f12c5491-af5b-4f2e-8b4c-2dc6189f80bc" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "497a4950-f928-47d4-b775-3ecb8320857c" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remotemanagement_allowbasicauthentication_client", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_remotemanagement_allowbasicauthentication_client_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "42109ef0-0789-442d-b2fc-c8662f61d87b" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "85687185-476b-4c04-8016-e37a015f3c1c" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remotemanagement_allowunencryptedtraffic_client", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_remotemanagement_allowunencryptedtraffic_client_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "7afbbf93-8750-4f44-85f5-cb4ac9ee1ea2" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "9d4cbd9f-7b3c-4a20-90df-e7230404bf2e" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remotemanagement_disallowdigestauthentication", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_remotemanagement_disallowdigestauthentication_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "a750d6ec-6834-4751-82f4-413bbcd9cded" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "9722555a-e415-44ba-8b73-79bf10fbd593" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remotemanagement_allowbasicauthentication_service", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_remotemanagement_allowbasicauthentication_service_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "ace993b8-d41d-4dbb-94dc-aece7bcddcd2" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "c5e81f9c-8480-4a17-8d27-efc0da463060" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remotemanagement_allowunencryptedtraffic_service", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_remotemanagement_allowunencryptedtraffic_service_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "4d0a125c-0ec7-4b8f-9395-10fd158387ea" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "d7988f48-757a-4740-a90c-ca363945629f" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_remotemanagement_disallowstoringofrunascredentials", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_remotemanagement_disallowstoringofrunascredentials_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "721b0f94-cc8d-4481-8987-8b68361ec12d" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "44ffc847-3686-4e09-9058-4f8c28c7f739" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_accountlogon_auditcredentialvalidation", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_audit_accountlogon_auditcredentialvalidation_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "619fa42d-a4f5-4aab-8918-16fe787f4a8f" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "32b3de29-b99f-4afb-a6cd-ee582df0cb7e" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_accountlogonlogoff_auditaccountlockout", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_audit_accountlogonlogoff_auditaccountlockout_2", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "b07ffcbc-125a-4ebc-a93b-ebf640278b88" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "9b369aa9-fec8-4b08-b189-16ec1b34e773" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_accountlogonlogoff_auditgroupmembership", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_audit_accountlogonlogoff_auditgroupmembership_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "a5b6d386-b4dd-4e01-ab13-1ebce3c3109c" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "3a0f15a0-2ed7-418c-a79b-8a898235312e" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_accountlogonlogoff_auditlogon", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_audit_accountlogonlogoff_auditlogon_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "9b62ab4c-9b5c-4d73-8433-a5be073f8e34" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "1d721324-c445-4c14-94bf-0875d9053951" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_policychange_auditauthenticationpolicychange", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_audit_policychange_auditauthenticationpolicychange_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "07941b97-c730-4d01-af0a-01266cbb8770" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "543b3def-6f0a-4cf6-8241-95c34d6cf20d" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_policychange_auditpolicychange", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_audit_policychange_auditpolicychange_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "8bfee613-27d2-4bb7-9dd3-042b9f360761" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "8a74951b-ee66-42c9-8534-e3f06d51122a" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_objectaccess_auditfileshare", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_audit_objectaccess_auditfileshare_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "a95fbf07-2e6f-4548-a55e-0948315552a5" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "64a2a8ef-6031-49a2-8e3b-0e01415f98f6" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_accountlogonlogoff_auditotherlogonlogoffevents", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_audit_accountlogonlogoff_auditotherlogonlogoffevents_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "49454f07-262e-499d-b771-0a6c374d6579" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "b23df640-0e12-4e95-b154-ad848f49af02" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_accountmanagement_auditsecuritygroupmanagement", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_audit_accountmanagement_auditsecuritygroupmanagement_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "587cf162-4bc1-4df3-a0b3-b04b34720427" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "16dfd341-ad92-4309-80ba-2839445fc84e" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_system_auditsecuritysystemextension", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_audit_system_auditsecuritysystemextension_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "49b680d4-e72a-46d5-bd1e-ec4dbdffa4e6" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "eead22e9-5113-4e90-9aab-6db21c181cd6" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_accountlogonlogoff_auditspeciallogon", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_audit_accountlogonlogoff_auditspeciallogon_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "61145c6c-7611-4272-997d-6e29a8501bde" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "cfbb5d70-fc92-4af3-b32c-b16671b038db" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_accountmanagement_audituseraccountmanagement", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_audit_accountmanagement_audituseraccountmanagement_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "05429a09-84a2-404a-96a8-867f8712f01b" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "db9e922f-8955-4f6e-a2bc-28a9d56edfd8" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_detailedtracking_auditpnpactivity", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_audit_detailedtracking_auditpnpactivity_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "08bb32e6-d609-444c-a827-51ad88665bf1" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "4190d95f-8532-4ac5-b96c-a87e521a59c7" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_detailedtracking_auditprocesscreation", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_audit_detailedtracking_auditprocesscreation_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "7286e4e4-915d-47ac-807c-36dd2c98afe6" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "a0dfcef6-a1cf-46aa-be7b-25c1bb51e48c" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_objectaccess_auditdetailedfileshare", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_audit_objectaccess_auditdetailedfileshare_2", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "b1f5ef04-5f39-43cf-936b-5a3ec665350a" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "4590aa3d-f558-4d73-9f1f-488de147e635" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_objectaccess_auditotherobjectaccessevents", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_audit_objectaccess_auditotherobjectaccessevents_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "d5dad8e7-bc0b-4ef7-8916-21be64079b33" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "765cbb76-faa1-42a8-871d-ab88ce265be2" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_objectaccess_auditremovablestorage", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_audit_objectaccess_auditremovablestorage_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "654944a0-153a-4bd8-88c7-ef7e96186926" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "6dff3117-fd67-42b4-89a8-1be64974d7f3" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_policychange_auditmpssvcrulelevelpolicychange", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_audit_policychange_auditmpssvcrulelevelpolicychange_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "ab717ec0-50a1-44f6-bdfb-3bebc9c877ec" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "b6effe28-8269-4b6e-8ccc-7e14ddbea377" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_policychange_auditotherpolicychangeevents", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_audit_policychange_auditotherpolicychangeevents_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "71c1002a-a1af-4791-badc-abf179dd3f1d" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "2ccb34ec-e7e8-49c6-be8e-976dbac9e04e" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_privilegeuse_auditsensitiveprivilegeuse", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_audit_privilegeuse_auditsensitiveprivilegeuse_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "42344f7b-fce5-4dce-bb09-ab51327401d5" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "90ff86cd-ab47-40a5-b852-90d186b62bff" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_system_auditothersystemevents", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_audit_system_auditothersystemevents_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "6fe8f20d-3574-4be5-90dd-e12c9de96a30" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "d00c3a27-e8d9-4c9a-8926-793517613340" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_system_auditsecuritystatechange", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_audit_system_auditsecuritystatechange_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "a536b402-6905-4a81-8ad0-339b9ecba70f" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "a679718b-c28d-4980-abb1-852743b47b04" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_audit_system_auditsystemintegrity", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_audit_system_auditsystemintegrity_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "2ffcd787-b772-4b32-8482-c9ca0ccc56ad" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "8e6efa6d-a8f3-40f1-a2fd-ca8452fffd00" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_browser_allowpasswordmanager", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_browser_allowpasswordmanager_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "beeeed01-4dc5-45ed-b8c3-578e1bab95fc" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "4440dfb7-4164-4dcd-a5c5-46c1d0558a12" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_browser_allowsmartscreen", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_browser_allowsmartscreen_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "7c32b6e9-9d18-450b-8438-4a95a27339fe" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "6bdb1980-b5dd-4f0d-9663-2a31b37223ba" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_browser_preventcerterroroverrides", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_browser_preventcerterroroverrides_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "ded94c65-4861-42fc-8240-92b82838ebe4" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "124c2e50-5a4d-414b-bff9-d686be5bd697" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_browser_preventsmartscreenpromptoverride", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_browser_preventsmartscreenpromptoverride_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "9e050626-50ce-41ee-90b5-71424aefd700" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "107a6613-80ae-49f1-8108-ea0046713f06" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_browser_preventsmartscreenpromptoverrideforfiles", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_browser_preventsmartscreenpromptoverrideforfiles_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "6a28e48b-ffe5-4a3d-abfd-34a705303864" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "6e03d2df-8d80-4e98-b4e8-9dc9831d6895" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_dataprotection_allowdirectmemoryaccess", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_dataprotection_allowdirectmemoryaccess_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "0ade3ab6-33d3-4640-bc0f-44be72250c25" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "3231c529-ed72-486b-bffb-c9bea1b4a2b5" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_allowarchivescanning", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_defender_allowarchivescanning_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "5f0ee961-5c31-472e-bea5-d6b793c8b4f4" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "4629bd6c-e93e-4583-ae0b-330c9c28b694" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_allowbehaviormonitoring", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_defender_allowbehaviormonitoring_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "a2b155a3-ffd8-4705-8066-687fa762c912" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "8c626130-a3f8-4d09-b44f-48093a9172b8" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_allowcloudprotection", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_defender_allowcloudprotection_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "9cb78a55-aca2-4486-b263-eb3b150de7c5" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "e2eb5f82-3e7d-47ff-a2e0-206a817b61ea" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_allowfullscanremovabledrivescanning", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_defender_allowfullscanremovabledrivescanning_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "9b4ff59c-2457-4d43-a003-ae901e87202a" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "9cad9a6a-ae7b-43f0-b046-a31a859538f0" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_allowonaccessprotection", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_defender_allowonaccessprotection_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "4447be1b-3c0c-4535-a3a1-dd507627f511" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "3b031b98-5d6e-428d-b5bb-44febe5e802a" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_allowrealtimemonitoring", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_defender_allowrealtimemonitoring_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "825dca7a-18d3-4917-85a3-a4c3f371ca65" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "2b8d850d-585d-4c82-80b4-4e88b099dc8e" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_allowioavprotection", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_defender_allowioavprotection_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "079cff8c-483e-4351-ac0c-345733df6d56" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "49d492db-dd5b-4a10-86d0-3929c05ad743" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_allowscriptscanning", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_defender_allowscriptscanning_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "66600aca-be92-4971-93b9-0be888c8156a" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "71dfc97f-39ab-4196-9406-95223b85fb58" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_cloudblocklevel", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_defender_cloudblocklevel_2", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "a150d2c7-4380-4021-b311-54a4334f67d5" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "b19fcb8d-3052-4d21-8684-b7b5ab54e02a" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_cloudextendedtimeout", + "simpleSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationIntegerSettingValue", + "value": 50, + "settingValueTemplateReference": { + "settingValueTemplateId": "c8decc1e-14ae-4f77-afd6-e89a786231a1" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "3271caaa-ee8d-4e80-bd0b-82561023b318" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_defender_configuration_disablelocaladminmerge", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_defender_configuration_disablelocaladminmerge_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "b2a233d6-49c0-458b-87d4-4345aa826a5f" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "0ccc87b6-38cd-4676-8e06-19ba51bbf875" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_defender_configuration_enablefilehashcomputation", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_defender_configuration_enablefilehashcomputation_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "04e39dd9-b74c-4d1e-8a89-823c0b1457ce" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "c288354b-ec37-469b-9d13-195d97405e81" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_enablenetworkprotection", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_defender_enablenetworkprotection_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "6bc24627-5077-4614-bdaa-51bc3659e153" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "887ee7fa-4a91-4468-b3e1-1b717c2c2618" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_defender_configuration_hideexclusionsfromlocaladmins", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_defender_configuration_hideexclusionsfromlocaladmins_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "1a14248f-9a2b-40cc-9d54-861abe4f3640" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "66037ddc-d716-4f50-9842-c669f6ae4d8f" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_puaprotection", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_defender_puaprotection_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "fbd53bb4-a04a-4578-9c70-91f866951506" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "d591684c-569e-42d5-87cb-d015bac955ce" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_realtimescandirection", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_defender_realtimescandirection_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "cbdeef1a-a651-4d88-a637-2335e9852f88" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "33bd6674-ae18-43e4-9f10-a7785df1df94" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_defender_submitsamplesconsent", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_defender_submitsamplesconsent_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "166b1900-ec5d-48c6-9d6c-0db6ecddb6ad" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "e2cc0998-fb53-4d74-81eb-4962dbdc22bb" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_deviceguard_configuresystemguardlaunch", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_deviceguard_configuresystemguardlaunch_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "9860e181-0bb4-4496-8821-ad88edd9a437" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "c5d16b5e-b2b2-4a7a-86be-416eb5ff8d02" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_deviceguard_lsacfgflags", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_deviceguard_lsacfgflags_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "2518a53d-bc46-476d-9863-bb4893aae7af" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "0558dfe7-4b99-44e1-98d8-31d67307a090" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_deviceguard_enablevirtualizationbasedsecurity", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_deviceguard_enablevirtualizationbasedsecurity_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "ac12acc6-5a5f-4285-b32d-2e09a37f315e" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "81251104-89f3-4011-b3f9-6c1197395485" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_deviceguard_requireplatformsecurityfeatures", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_deviceguard_requireplatformsecurityfeatures_3", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "61892cf7-5a44-4590-a8f8-6ff6afb53ab5" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "0dd32130-3939-446a-85ef-80c65a9f204a" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_devicelock_devicepasswordenabled", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_devicelock_devicepasswordenabled_0", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_devicelock_devicepasswordhistory", + "simpleSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationIntegerSettingValue", + "value": 24, + "settingValueTemplateReference": { + "settingValueTemplateId": "0d2ac35c-2ed8-41ce-a25d-4279355d6883" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "59f32bc4-83b6-49e6-966d-3a2b651929be" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_devicelock_mindevicepasswordlength", + "simpleSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationIntegerSettingValue", + "value": 14, + "settingValueTemplateReference": { + "settingValueTemplateId": "b193e752-94a2-4d61-97da-2a237d8c1e8c" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "8f28400a-4098-418a-a0c9-df0da259a06c" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "d6c39a3e-da62-4879-b4be-d3dfca1949f2" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "50b7d26c-6cd6-4305-af23-adf0432c57d2" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_dmaguard_deviceenumerationpolicy", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_dmaguard_deviceenumerationpolicy_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "bfff897c-58c7-4c45-8d80-6a0be472e49c" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "3c3dec21-e31b-46af-99eb-2cb61ad12527" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "user_vendor_msft_policy_config_experience_allowwindowsspotlight", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "user_vendor_msft_policy_config_experience_allowwindowsspotlight_1", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_experience_allowwindowsconsumerfeatures", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_experience_allowwindowsconsumerfeatures_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "3845cc66-d148-4ca2-a25e-bbb0ee2b001f" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "d4b80e7a-cde6-4939-b736-794a7201bacd" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "user_vendor_msft_policy_config_experience_allowthirdpartysuggestionsinwindowsspotlight", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "user_vendor_msft_policy_config_experience_allowthirdpartysuggestionsinwindowsspotlight_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "5134d45b-74fb-42c3-834d-da11d009f9f3" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "ed36c396-e8a8-4056-a71c-49ba4b4d48e9" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "3190c9d4-3fba-45f5-b6b3-fbdebf9ef5a2" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "bb79e6fc-a957-476b-b8a1-0a7b1ac81c6a" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "vendor_msft_firewall_mdmstore_domainprofile_enablefirewall", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "vendor_msft_firewall_mdmstore_domainprofile_enablefirewall_true", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "vendor_msft_firewall_mdmstore_domainprofile_enablelogdroppedpackets", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "vendor_msft_firewall_mdmstore_domainprofile_enablelogdroppedpackets_true", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "832698e0-b5e2-4130-9c4f-d49b147e88ae" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "d69a5292-98d0-4b39-baad-5933fd881e3e" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "vendor_msft_firewall_mdmstore_domainprofile_defaultoutboundaction", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "vendor_msft_firewall_mdmstore_domainprofile_defaultoutboundaction_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "003f66fa-4bdc-4c60-b6a5-8c5ad86007b2" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "eeac01d3-39bd-4bc2-8b5c-4f17263eff0d" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "vendor_msft_firewall_mdmstore_domainprofile_disableinboundnotifications", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "vendor_msft_firewall_mdmstore_domainprofile_disableinboundnotifications_true", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "51366ca3-9811-498f-81be-32fdd32fa967" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "ea17ca65-425e-444e-88fb-9c5794addc54" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance", + "settingDefinitionId": "vendor_msft_firewall_mdmstore_domainprofile_logmaxfilesize", + "simpleSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationIntegerSettingValue", + "value": 16384, + "settingValueTemplateReference": { + "settingValueTemplateId": "3bfbe468-0549-4a70-b742-6b40be6a1087" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "d8a1dc04-88ee-4d57-ac7c-4aaaf3cbfa96" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "vendor_msft_firewall_mdmstore_domainprofile_defaultinboundaction", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "vendor_msft_firewall_mdmstore_domainprofile_defaultinboundaction_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "efe1d365-aca0-405b-92f7-4042f74402b0" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "3616568d-831b-4cd8-8143-627468003eb5" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "vendor_msft_firewall_mdmstore_domainprofile_enablelogsuccessconnections", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "vendor_msft_firewall_mdmstore_domainprofile_enablelogsuccessconnections_true", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "3edb867e-05dd-4500-aed3-305d4b8e2105" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "4ab98420-a349-4828-8675-135f9fd29a41" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "9e4bc472-20d4-4fc9-a9a8-05cdeb1c4f59" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "a3c0eb6a-83e5-41d2-8565-0833de9e3b4e" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "vendor_msft_firewall_mdmstore_privateprofile_enablefirewall", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "vendor_msft_firewall_mdmstore_privateprofile_enablefirewall_true", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance", + "settingDefinitionId": "vendor_msft_firewall_mdmstore_privateprofile_logmaxfilesize", + "simpleSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationIntegerSettingValue", + "value": 16384, + "settingValueTemplateReference": { + "settingValueTemplateId": "2b0622e3-f044-4f55-ab24-57a3d5e206d2" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "d4cb87be-47e9-49f7-8267-315bde8b7688" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "vendor_msft_firewall_mdmstore_privateprofile_defaultinboundaction", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "vendor_msft_firewall_mdmstore_privateprofile_defaultinboundaction_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "d0a7f940-5afb-4ce5-8f16-9cb7ad4ad2aa" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "3e8890a4-f038-4cd9-838f-3f66ba184bb6" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "vendor_msft_firewall_mdmstore_privateprofile_enablelogsuccessconnections", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "vendor_msft_firewall_mdmstore_privateprofile_enablelogsuccessconnections_true", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "92c28280-cbe7-4048-adcb-63494299f4cb" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "7b49474a-4fa8-4a20-8da3-6ca72734aac2" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "vendor_msft_firewall_mdmstore_privateprofile_enablelogdroppedpackets", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "vendor_msft_firewall_mdmstore_privateprofile_enablelogdroppedpackets_true", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "2d8d3f5d-2e49-411e-ba5e-03905889ed57" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "6959e391-f432-421f-8bcb-67e6a6fddcc1" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "vendor_msft_firewall_mdmstore_privateprofile_disableinboundnotifications", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "vendor_msft_firewall_mdmstore_privateprofile_disableinboundnotifications_true", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "da9e56a5-a181-425f-a21a-05a4ad7273f6" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "820911be-8b6d-46d0-83e1-b6b54e999624" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "vendor_msft_firewall_mdmstore_privateprofile_defaultoutboundaction", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "vendor_msft_firewall_mdmstore_privateprofile_defaultoutboundaction_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "0a00ddb1-ea27-4525-8ac9-333ae1902547" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "0fa42464-4d1a-4c22-9e35-986408022a91" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "b8007970-bd96-4b71-ba86-d0d551880148" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "efea36fa-004d-4b46-b7d2-c9ed20d6122e" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "vendor_msft_firewall_mdmstore_publicprofile_enablefirewall", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "vendor_msft_firewall_mdmstore_publicprofile_enablefirewall_true", + "children": [ + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "vendor_msft_firewall_mdmstore_publicprofile_enablelogdroppedpackets", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "vendor_msft_firewall_mdmstore_publicprofile_enablelogdroppedpackets_true", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "c3e248b6-0f28-4611-a873-0ec39281ad10" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "bfdc2273-e5cc-42c7-a97c-fc377a9abf2e" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance", + "settingDefinitionId": "vendor_msft_firewall_mdmstore_publicprofile_logmaxfilesize", + "simpleSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationIntegerSettingValue", + "value": 16384, + "settingValueTemplateReference": { + "settingValueTemplateId": "08f471b8-ed62-41e3-bb52-3db158f25834" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "89151949-4d57-4469-80a3-633350a0809c" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "vendor_msft_firewall_mdmstore_publicprofile_defaultoutboundaction", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "vendor_msft_firewall_mdmstore_publicprofile_defaultoutboundaction_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "fade140b-cdae-4b5a-af11-18b5258752dd" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "a05f0e16-655c-4608-9e00-89b077e57bf4" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "vendor_msft_firewall_mdmstore_publicprofile_disableinboundnotifications", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "vendor_msft_firewall_mdmstore_publicprofile_disableinboundnotifications_true", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "f37bf55e-5163-4e3d-8069-30d1c3390531" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "b502beeb-78fc-4b92-8447-1aa09b6ef542" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "vendor_msft_firewall_mdmstore_publicprofile_allowlocalpolicymerge", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "vendor_msft_firewall_mdmstore_publicprofile_allowlocalpolicymerge_false", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "75064e3e-3aec-478b-acf8-2c233a1172c8" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "304cc793-6468-4875-9bf2-9384c0d0f651" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "vendor_msft_firewall_mdmstore_publicprofile_defaultinboundaction", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "vendor_msft_firewall_mdmstore_publicprofile_defaultinboundaction_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "5e149012-80ff-481d-9629-51d841cbadcd" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "a32e93ba-be2e-4631-9192-18bbebd5990b" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "vendor_msft_firewall_mdmstore_publicprofile_enablelogsuccessconnections", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "vendor_msft_firewall_mdmstore_publicprofile_enablelogsuccessconnections_true", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "1cbff808-2ed6-4355-9fa5-52764162d3d7" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "7b5032e3-a8a8-4bac-88be-535c10581456" + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "vendor_msft_firewall_mdmstore_publicprofile_allowlocalipsecpolicymerge", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "vendor_msft_firewall_mdmstore_publicprofile_allowlocalipsecpolicymerge_false", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "01a0a9e2-1153-49f5-8ebe-a63329c496b0" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "93839913-a264-4945-aef5-1372c833f87b" + } + } + ], + "settingValueTemplateReference": { + "settingValueTemplateId": "90ee6a7a-e58f-4e95-9947-edcff35a1755" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "5a8b6b58-658e-47a7-a20d-addf7bf18533" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_lanmanworkstation_enableinsecureguestlogons", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_lanmanworkstation_enableinsecureguestlogons_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "23d34d64-8a7c-403d-b931-3392cdb3d540" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "885f5d03-c4ba-4067-80ed-03151df6a7c8" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_laps_policies_backupdirectory", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_laps_policies_backupdirectory_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "9717b1b9-ea8c-4eed-bea3-40565503862e" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "78874924-d4df-420e-baf7-bd6fd26bcf18" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_accounts_limitlocalaccountuseofblankpasswordstoconsolelogononly", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_accounts_limitlocalaccountuseofblankpasswordstoconsolelogononly_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "b7c18b12-d7a3-44b5-ba91-8b591d1eebe2" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "a6ea9c66-4363-4efb-b82b-7267f57b5ece" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_interactivelogon_machineinactivitylimit_v2", + "simpleSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationIntegerSettingValue", + "value": 900, + "settingValueTemplateReference": { + "settingValueTemplateId": "0c2d6162-10b5-4f3b-8c54-f41674abf936" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "1cea246e-e450-4993-b4ee-8b4daf8e70cc" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_interactivelogon_smartcardremovalbehavior", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_interactivelogon_smartcardremovalbehavior_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "dca8c8e4-e03c-4de8-b72c-e02bee8d2a63" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "a611d952-bce4-4ac2-8e94-3fd4ba5d3a16" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_microsoftnetworkclient_digitallysigncommunicationsalways", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_microsoftnetworkclient_digitallysigncommunicationsalways_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "5fbfab0b-91a9-4c03-b829-a4eb11243492" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "0c01a1f7-af42-4ccb-8a3a-c07567bb0089" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_microsoftnetworkclient_sendunencryptedpasswordtothirdpartysmbservers", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_microsoftnetworkclient_sendunencryptedpasswordtothirdpartysmbservers_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "ebc86a23-48dc-4c4a-bf81-83e904b06588" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "6c0bd6db-6402-40b3-bc12-bb948dfc43cf" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_microsoftnetworkserver_digitallysigncommunicationsalways", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_microsoftnetworkserver_digitallysigncommunicationsalways_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "cdad7c95-fa66-476b-a49d-d22865b945ee" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "b282e470-31b1-44ab-9f8e-2f516780a202" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networkaccess_donotallowanonymousenumerationofsamaccounts", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networkaccess_donotallowanonymousenumerationofsamaccounts_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "554e26e8-c1da-4459-b8f0-f3cc6939a274" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "6fabaf9b-36c8-4c40-887e-93ed6aefa8ce" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networkaccess_donotallowanonymousenumerationofsamaccountsandshares", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networkaccess_donotallowanonymousenumerationofsamaccountsandshares_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "0d790310-f6ea-4832-b1d7-2cc5fe5f0af6" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "71e5c427-7201-4b18-a016-c1bacf4054d5" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networkaccess_restrictanonymousaccesstonamedpipesandshares", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networkaccess_restrictanonymousaccesstonamedpipesandshares_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "2f4b3c7c-4bb1-46c1-8af2-220f80ca865f" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "bab6332a-aac0-452d-ab3a-c3781552dc4f" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networkaccess_restrictclientsallowedtomakeremotecallstosam", + "simpleSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue", + "value": "O:BAG:BAD:(A;;RC;;;BA)", + "settingValueTemplateReference": { + "settingValueTemplateId": "540c9109-bc6f-443c-812e-2b09baf8582c" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "cbe14209-7c72-48f4-89d1-cfeeab1d78a1" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networksecurity_donotstorelanmanagerhashvalueonnextpasswordchange", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networksecurity_donotstorelanmanagerhashvalueonnextpasswordchange_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "0ab2915d-59a8-4db9-8e93-82ae7f88ddb1" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "50f5685c-f499-4392-af5b-e42ea2669e88" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networksecurity_lanmanagerauthenticationlevel", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networksecurity_lanmanagerauthenticationlevel_5", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "a8418b3b-fe60-406c-b627-9c9d6e55beb9" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "45a4688d-e36b-4f58-be4b-ea629d7cfd2c" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networksecurity_minimumsessionsecurityforntlmsspbasedclients", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networksecurity_minimumsessionsecurityforntlmsspbasedclients_537395200", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "8706c5c6-4601-47f4-8212-0207ce487c97" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "785d0946-e003-4fa4-b517-245d961612d7" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networksecurity_minimumsessionsecurityforntlmsspbasedservers", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_networksecurity_minimumsessionsecurityforntlmsspbasedservers_537395200", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "8680dcb8-584c-4628-b759-f85f69742dc7" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "50a2e509-4d7c-44fe-8c90-9b9ab4e27624" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_behavioroftheelevationpromptforadministrators", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_behavioroftheelevationpromptforadministrators_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "2b6dc9a4-a5d8-49e3-9cae-9b88f62635e6" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "04100061-4b8c-4f16-9167-63eaa830e2c1" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_behavioroftheelevationpromptforstandardusers", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_behavioroftheelevationpromptforstandardusers_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "0a85226c-4988-4298-85a3-5a264f0cadf0" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "7fed1de6-6d25-4b3a-87d1-939b59860ed9" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_detectapplicationinstallationsandpromptforelevation", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_detectapplicationinstallationsandpromptforelevation_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "9f9b2854-b1fd-4ae1-849c-2c2cdf16a743" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "bab74079-a148-4ab7-b71b-0f5998fbb3e7" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_onlyelevateuiaccessapplicationsthatareinstalledinsecurelocations", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_onlyelevateuiaccessapplicationsthatareinstalledinsecurelocations_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "8b17fcfd-25e4-4a2f-8f56-296cb530a424" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "e05931c5-d507-405a-a5cc-ed0e837fd550" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_runalladministratorsinadminapprovalmode", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_runalladministratorsinadminapprovalmode_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "5fbf1169-47fa-427c-a622-cbfafb66fbf8" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "77dec054-14b9-405e-b0a3-6fae171ee7dc" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_useadminapprovalmode", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_useadminapprovalmode_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "2ab704e2-70fe-4b34-b0af-e4853438423d" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "8169d43b-a789-411d-98c1-717070974310" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_virtualizefileandregistrywritefailurestoperuserlocations", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_localpoliciessecurityoptions_useraccountcontrol_virtualizefileandregistrywritefailurestoperuserlocations_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "fadefee1-4de8-455d-85a7-e1afb3e2c32f" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "18af25c1-ad1e-4ab8-9f73-4a44da053d29" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_localsecurityauthority_configurelsaprotectedprocess", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_localsecurityauthority_configurelsaprotectedprocess_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "2371b876-3e5b-4c8d-9da7-cde9e385a49c" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "a8c5e695-e184-4581-b6ac-b564b256d64e" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_applicationmanagement_allowgamedvr", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_applicationmanagement_allowgamedvr_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "cf8fb349-bd29-453f-9bd5-6a3bdeb9ecd4" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "88e5df2b-44fc-4f96-9a9d-bdc4c3460ff5" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_applicationmanagement_msiallowusercontroloverinstall", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_applicationmanagement_msiallowusercontroloverinstall_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "6d0e6d1d-1fc8-4370-90cc-db8b03c39df2" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "dbd9dd3d-f9d9-4fcc-9bb6-094a9c927d68" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_applicationmanagement_msialwaysinstallwithelevatedprivileges", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_applicationmanagement_msialwaysinstallwithelevatedprivileges_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "75657c72-5e5e-4f8f-aaaa-e550c365611b" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "fd84af95-3d88-42ef-a369-0d9f68a59e6b" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge~smartscreen_smartscreenenabled", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge~smartscreen_smartscreenenabled_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "bc272f43-906c-418f-b113-565a8a1854aa" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "4a12af0a-4c14-46dc-beff-955d00b017ea" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge~smartscreen_preventsmartscreenpromptoverride", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_microsoft_edge~policy~microsoft_edge~smartscreen_preventsmartscreenpromptoverride_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "65fb02dd-61c0-43b0-89da-f11d75ca01bf" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "ff40bd07-443d-4988-b95a-342b5ee76f37" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_privacy_letappsactivatewithvoiceabovelock", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_privacy_letappsactivatewithvoiceabovelock_2", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "2a51bc39-376c-4da4-bb84-726f42d2725e" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "1962acab-d990-470c-848c-5d57e6f4e157" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_search_allowindexingencryptedstoresoritems", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_search_allowindexingencryptedstoresoritems_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "0054aa10-809c-426c-9438-45ad1fe47e02" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "5292e47c-7be2-414e-a040-6b5ca351013e" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_webthreatdefense_notifymalicious", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_webthreatdefense_notifymalicious_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "deec27ef-749a-4eff-90c6-cdad03600e57" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "9b0ceffd-7f83-4db3-a414-3bac67929fcb" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_webthreatdefense_notifypasswordreuse", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_webthreatdefense_notifypasswordreuse_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "6ebd3b05-c125-487a-80b5-3a6fd82dc20a" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "cb9c5284-fa09-4b2d-96e8-cec2ad1bf22e" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_webthreatdefense_notifyunsafeapp", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_webthreatdefense_notifyunsafeapp_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "a29bfec5-e7ed-4b5a-af30-9a36c70e1991" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "01a8ec94-8900-4f11-aed9-db1f59616830" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_webthreatdefense_serviceenabled", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_webthreatdefense_serviceenabled_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "78171361-7b27-4155-80b9-24cf89c089fd" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "f9284608-1728-4384-9ef1-4fe0119c97cb" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_smartscreen_enablesmartscreeninshell", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_smartscreen_enablesmartscreeninshell_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "966b00ac-b2ca-4233-9b93-c1cadad10ab7" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "cb363c90-f514-46aa-acaf-760a277b8f7b" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_smartscreen_preventoverrideforfilesinshell", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_smartscreen_preventoverrideforfilesinshell_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "e758a87b-fc8a-4fe7-8a24-5125a1173441" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "746bc3a0-d3ed-4a59-a81e-d0cab7b615f7" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_systemservices_configurexboxaccessorymanagementservicestartupmode", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_systemservices_configurexboxaccessorymanagementservicestartupmode_4", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "fea89187-d70e-4857-9a35-c484279208e2" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "96032425-933b-49d3-9bf2-47764e178489" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_systemservices_configurexboxliveauthmanagerservicestartupmode", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_systemservices_configurexboxliveauthmanagerservicestartupmode_4", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "bd8d9630-6358-451a-ab5b-92b31bd849a7" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "1856834d-09f0-4e9d-b83e-d4d87b6fd255" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_systemservices_configurexboxlivegamesaveservicestartupmode", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_systemservices_configurexboxlivegamesaveservicestartupmode_4", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "265acfa6-5af1-4963-9d3a-4d2b2765a4c7" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "77d53451-b875-4bd0-9bb0-9ac9ca99c50d" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_systemservices_configurexboxlivenetworkingservicestartupmode", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_systemservices_configurexboxlivenetworkingservicestartupmode_4", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "1b757ff4-7c75-4350-8d20-bdbef26e6368" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "4cb5ffab-1d59-4c1e-843f-2861d7fceb55" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_taskscheduler_enablexboxgamesavetask", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_taskscheduler_enablexboxgamesavetask_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "2f6204ae-5840-4ad6-a39a-89058ba05575" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "81aecabe-eab5-40dc-8d54-6612552be8bc" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_accessfromnetwork", + "simpleSettingCollectionValue": [ + { + "value": "Administrators", + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue" + }, + { + "value": "Remote Desktop Users", + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue" + } + ], + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "5b1a36e3-5503-412b-824b-3a1ae63ea2fd" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_allowlocallogon", + "simpleSettingCollectionValue": [ + { + "value": "Administrators", + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue" + }, + { + "value": "Users", + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue" + } + ], + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "aa5107cc-a779-49db-a1ab-f117bad9bf64" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_backupfilesanddirectories", + "simpleSettingCollectionValue": [ + { + "value": "Administrators", + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue" + } + ], + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "e4e51e09-260d-4f2f-8ab5-f589907f457a" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_createglobalobjects", + "simpleSettingCollectionValue": [ + { + "value": "Administrators", + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue" + }, + { + "value": "LOCAL SERVICE", + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue" + }, + { + "value": "NETWORK SERVICE", + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue" + }, + { + "value": "SERVICE", + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue" + } + ], + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "ca63b0ee-d22a-4213-b313-a20114759415" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_createpagefile", + "simpleSettingCollectionValue": [ + { + "value": "Administrators", + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue" + } + ], + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "529aedcf-cd5c-4677-b481-c2107a875236" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_debugprograms", + "simpleSettingCollectionValue": [ + { + "value": "Administrators", + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue" + } + ], + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "a27fcf27-c36d-41c0-8cc5-2fcaed5a03d7" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_denyaccessfromnetwork", + "simpleSettingCollectionValue": [ + { + "value": "NT AUTHORITY\\Local Account", + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue" + } + ], + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "567afec8-381f-4ff1-aced-e9d59badf17d" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_denyremotedesktopserviceslogon", + "simpleSettingCollectionValue": [ + { + "value": "NT AUTHORITY\\Local Account", + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue" + } + ], + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "bc3c735f-1c25-4877-b86d-6d1adff03eee" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_impersonateclient", + "simpleSettingCollectionValue": [ + { + "value": "Administrators", + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue" + }, + { + "value": "SERVICE", + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue" + }, + { + "value": "Local Service", + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue" + }, + { + "value": "Network Service", + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue" + } + ], + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "26fe917a-e117-4219-a4eb-7a6e764cedef" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_loadunloaddevicedrivers", + "simpleSettingCollectionValue": [ + { + "value": "Administrators", + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue" + } + ], + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "a9a9e367-5f32-43b1-95d5-e439a7ddb1c3" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_manageauditingandsecuritylog", + "simpleSettingCollectionValue": [ + { + "value": "Administrators", + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue" + } + ], + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "51e3ac65-dc02-4be1-95de-5a37028cedb8" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_managevolume", + "simpleSettingCollectionValue": [ + { + "value": "Administrators", + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue" + } + ], + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "c391db53-dbd6-4e0d-81e4-f1845887098c" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_modifyfirmwareenvironment", + "simpleSettingCollectionValue": [ + { + "value": "Administrators", + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue" + } + ], + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "64599138-78fc-47d8-a24b-665a0a7551d0" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_profilesingleprocess", + "simpleSettingCollectionValue": [ + { + "value": "Administrators", + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue" + } + ], + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "f67ae512-2092-4b22-ae13-ecaa235593e4" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_remoteshutdown", + "simpleSettingCollectionValue": [ + { + "value": "Administrators", + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue" + } + ], + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "f4d48a3f-3bc5-40d1-88a7-043710c40c44" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_restorefilesanddirectories", + "simpleSettingCollectionValue": [ + { + "value": "Administrators", + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue" + } + ], + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "cb80eb31-1b2f-419f-8667-dc6d0505e7f6" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_userrights_takeownership", + "simpleSettingCollectionValue": [ + { + "value": "Administrators", + "@odata.type": "#microsoft.graph.deviceManagementConfigurationStringSettingValue" + } + ], + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "9106ffc1-2777-4e6e-a80c-0f23730ca7ad" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_virtualizationbasedtechnology_hypervisorenforcedcodeintegrity", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_virtualizationbasedtechnology_hypervisorenforcedcodeintegrity_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "dc0066bb-e721-49f2-bfcd-19971eddf643" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "bf7b6212-1c6e-4669-a517-6653052f509e" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_wifi_allowautoconnecttowifisensehotspots", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_wifi_allowautoconnecttowifisensehotspots_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "cd487ee5-09e0-426b-acfc-04f6a542f85d" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "05ca51b4-beac-4c1c-90fa-103dba821f19" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_wifi_allowinternetsharing", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_wifi_allowinternetsharing_0", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "a35978d6-f58a-44b0-b71e-928e43301283" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "b97154f4-817b-496b-b88d-9bfd80dbe9ca" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_passportforwork_biometrics_facialfeaturesuseenhancedantispoofing", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_passportforwork_biometrics_facialfeaturesuseenhancedantispoofing_true", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "50385f1f-529e-4597-9bc3-efc8b09e1b69" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "6906cb56-4cfc-40bc-9df9-3801bb09a939" + } + } + }, + { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationSetting", + "settingInstance": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance", + "settingDefinitionId": "device_vendor_msft_policy_config_windowsinkworkspace_allowwindowsinkworkspace", + "choiceSettingValue": { + "@odata.type": "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue", + "value": "device_vendor_msft_policy_config_windowsinkworkspace_allowwindowsinkworkspace_1", + "children": [], + "settingValueTemplateReference": { + "settingValueTemplateId": "0615f2aa-0136-46fc-a89d-687a4934ac28" + } + }, + "settingInstanceTemplateReference": { + "settingInstanceTemplateId": "74afd036-aacf-4005-8ef1-7e6bbd63d777" + } + } + } + ], + "templateReference": { + "templateId": "66df8dce-0166-4b82-92f7-1f74e3ca17a3_1" + }, + "@odata.context": "https://graph.microsoft.com/beta/$metadata#deviceManagement/configurationPolicies/$entity" +} diff --git a/Products/Intune/Config/PolicyCatalog/catalog.psd1 b/Products/Intune/Config/PolicyCatalog/catalog.psd1 new file mode 100644 index 0000000..35e3b52 --- /dev/null +++ b/Products/Intune/Config/PolicyCatalog/catalog.psd1 @@ -0,0 +1,237 @@ +@{ + CatalogVersion = '1.0' + SourceName = 'SMBTool-main.zip' + SourceReviewDate = '2026-08-19' + RequiredEntryCount = 19 + + Entries = @( + @{ + Key = 'android-fully-managed-baseline-compliance' + RelativePath = 'Baseline/android-fully-managed-compliance.json' + Tier = 'Baseline' + Platform = 'AndroidFullyManaged' + Kind = 'Compliance' + ExpectedDiscriminator = '#microsoft.graph.androidDeviceOwnerCompliancePolicy' + SourceApiProfile = 'beta' + ApplyStatus = 'Blocked' + SourceSha256 = '229BED9039F56D290C9DC976DDA4E03B2A4B850D1F337CBAA35DFF74BDBB12D7' + Sha256 = '301AB2992ABC2A835165D152B92E2005957679CA1DC59093161969EE07C9D0A4' + } + @{ + Key = 'android-fully-managed-baseline-configuration' + RelativePath = 'Baseline/android-fully-managed-configuration.json' + Tier = 'Baseline' + Platform = 'AndroidFullyManaged' + Kind = 'DeviceConfiguration' + ExpectedDiscriminator = '#microsoft.graph.androidDeviceOwnerGeneralDeviceConfiguration' + SourceApiProfile = 'beta' + ApplyStatus = 'Blocked' + SourceSha256 = '310272BF1B0D57F719D3871B39A36341CA7065FC72E0519C68D16D25636B7CBD' + Sha256 = '83F2B82757230C1FA2157CB4C69D48B77DE9B338A45E39E4B0EC69C512272F37' + } + @{ + Key = 'android-work-profile-baseline-compliance' + RelativePath = 'Baseline/android-work-profile-compliance.json' + Tier = 'Baseline' + Platform = 'AndroidWorkProfile' + Kind = 'Compliance' + ExpectedDiscriminator = '#microsoft.graph.androidWorkProfileCompliancePolicy' + SourceApiProfile = 'beta' + ApplyStatus = 'Blocked' + SourceSha256 = 'C6355BCA6ABB4E4068E126C48C6BF2F9F7A08BD7B6895329C997ADC1305B51C1' + Sha256 = '09585497750442A524F6C3901A6548F668666A60E1786BA2BEAE57BBC94740E1' + } + @{ + Key = 'android-work-profile-baseline-configuration' + RelativePath = 'Baseline/android-work-profile-configuration.json' + Tier = 'Baseline' + Platform = 'AndroidWorkProfile' + Kind = 'DeviceConfiguration' + ExpectedDiscriminator = '#microsoft.graph.androidWorkProfileGeneralDeviceConfiguration' + SourceApiProfile = 'beta' + ApplyStatus = 'Blocked' + SourceSha256 = '4284EB019B895C5C864593E5AACDF81937B8877A119FA1A3457169187BF3714F' + Sha256 = '40CC61AD3108F88D3524C9A2D99EAC05F0AD8B27A61336C97C0FB106FC261B9A' + } + @{ + Key = 'ios-personal-baseline-configuration' + RelativePath = 'Baseline/ios-personal-configuration.json' + Tier = 'Baseline' + Platform = 'iOSPersonal' + Kind = 'SettingsCatalog' + ExpectedDiscriminator = 'https://graph.microsoft.com/beta/$metadata#deviceManagement/configurationPolicies/$entity' + SourceApiProfile = 'beta' + ApplyStatus = 'Blocked' + SourceSha256 = '54DE854B14BA3A0E45418EC4326DBFB466098F81FEB622CD63D9B2FDFB08A9F0' + Sha256 = 'AB60BCBBC6168162D0FF21DFDBEC576C6374514F1901020B8A7590194679CB5E' + } + @{ + Key = 'ios-supervised-baseline-configuration' + RelativePath = 'Baseline/ios-supervised-configuration.json' + Tier = 'Baseline' + Platform = 'iOSSupervised' + Kind = 'DeviceConfiguration' + ExpectedDiscriminator = '#microsoft.graph.iosGeneralDeviceConfiguration' + SourceApiProfile = 'beta' + ApplyStatus = 'Blocked' + SourceSha256 = 'FCB5802CA3D366DD18602D3C5C6D9C61F57C6A2035FBC79B25E8A18A541BAA67' + Sha256 = '656A5B1E8A330DF1ACFF0B13CDD222C3954414514E17D226C477674F20CCFFC5' + } + @{ + Key = 'ios-baseline-compliance' + RelativePath = 'Baseline/ios-compliance.json' + Tier = 'Baseline' + Platform = 'iOS' + Kind = 'Compliance' + ExpectedDiscriminator = '#microsoft.graph.iosCompliancePolicy' + SourceApiProfile = 'beta' + ApplyStatus = 'Blocked' + SourceSha256 = '7A335EF9CCE869EFACFE4B8B2D193C1E6F834BF76CBA551649CE2956D0A8C774' + Sha256 = '77876E9BCB7133205838E7F487F174CA89743FD5F755A63002EFB7F1D9CFA17E' + } + @{ + Key = 'android-fully-managed-advanced-compliance' + RelativePath = 'Advanced/android-fully-managed-compliance.json' + Tier = 'Advanced' + Platform = 'AndroidFullyManaged' + Kind = 'Compliance' + ExpectedDiscriminator = '#microsoft.graph.androidDeviceOwnerCompliancePolicy' + SourceApiProfile = 'beta' + ApplyStatus = 'Blocked' + SourceSha256 = '326508E426761FD9900E6BF1CDB260A9467338671A051439BDF1C7839A9B7035' + Sha256 = 'ABD02C323E121ACCDEDBB348AA878DC416D59FFBDE321CF64A3F36F931A847C5' + } + @{ + Key = 'android-fully-managed-advanced-configuration' + RelativePath = 'Advanced/android-fully-managed-configuration.json' + Tier = 'Advanced' + Platform = 'AndroidFullyManaged' + Kind = 'DeviceConfiguration' + ExpectedDiscriminator = '#microsoft.graph.androidDeviceOwnerGeneralDeviceConfiguration' + SourceApiProfile = 'beta' + ApplyStatus = 'Blocked' + SourceSha256 = '4B4B300EDCA85F5CB1D78BF7C6227490D60F9B22627CB2ED5C6614E6687A3F99' + Sha256 = '54A6FD95099C2FE3418B678C4970F5E360DB24A75103C15A4862720C5F648423' + } + @{ + Key = 'android-work-profile-advanced-compliance' + RelativePath = 'Advanced/android-work-profile-compliance.json' + Tier = 'Advanced' + Platform = 'AndroidWorkProfile' + Kind = 'Compliance' + ExpectedDiscriminator = '#microsoft.graph.androidWorkProfileCompliancePolicy' + SourceApiProfile = 'beta' + ApplyStatus = 'Blocked' + SourceSha256 = '7D9B427EC76A4761C8D50A27FB435A9A87C081AE02659978255F7EC6676AE6B8' + Sha256 = 'DC3FDE3AB8E1109C5553650E7C3983FF7E0902D3659F89C0FB8F5243431A368A' + } + @{ + Key = 'android-work-profile-advanced-configuration' + RelativePath = 'Advanced/android-work-profile-configuration.json' + Tier = 'Advanced' + Platform = 'AndroidWorkProfile' + Kind = 'DeviceConfiguration' + ExpectedDiscriminator = '#microsoft.graph.androidWorkProfileGeneralDeviceConfiguration' + SourceApiProfile = 'beta' + ApplyStatus = 'Blocked' + SourceSha256 = '3003950485EFF3CE762F6FB36777E0F4B187DF93C7287DF3029864BEFE6FEE80' + Sha256 = 'B9AFD9391D7C2720F95434640757FB38107BD1A2D76FBA7EED5ECBFCEA38763E' + } + @{ + Key = 'ios-personal-advanced-configuration' + RelativePath = 'Advanced/ios-personal-configuration.json' + Tier = 'Advanced' + Platform = 'iOSPersonal' + Kind = 'DeviceConfiguration' + ExpectedDiscriminator = '#microsoft.graph.iosGeneralDeviceConfiguration' + SourceApiProfile = 'beta' + ApplyStatus = 'Blocked' + SourceSha256 = '9939149B54BD9A20701AAC1D39AABC2F21AA1EA46B5DA8B693C210690C9B5C94' + Sha256 = 'BC865DF8F619A7FCFC012E732C83391EE827612382B468652376C87D3ACC12B1' + } + @{ + Key = 'ios-supervised-advanced-configuration' + RelativePath = 'Advanced/ios-supervised-configuration.json' + Tier = 'Advanced' + Platform = 'iOSSupervised' + Kind = 'DeviceConfiguration' + ExpectedDiscriminator = '#microsoft.graph.iosGeneralDeviceConfiguration' + SourceApiProfile = 'beta' + ApplyStatus = 'Blocked' + SourceSha256 = 'AD18D7FAF4989871F2AE14197B9EB818F3228E53F5E146DA4F61DB4E13F8C735' + Sha256 = '4817F6F9D356DC7E5F0AA8DCB59B5A5CF9B0EDFBDDE1391B5FA02688937C2A72' + } + @{ + Key = 'ios-advanced-compliance' + RelativePath = 'Advanced/ios-compliance.json' + Tier = 'Advanced' + Platform = 'iOS' + Kind = 'Compliance' + ExpectedDiscriminator = '#microsoft.graph.iosCompliancePolicy' + SourceApiProfile = 'beta' + ApplyStatus = 'Blocked' + SourceSha256 = 'A2A9A6BE32E1E0668B496296388EA65214BAE614F52C5AC0A63AC8C8C1CA2833' + Sha256 = '6BA86161FBB01786C0A421418E3B4233EA2F44EEBE3B75E32570A0161754FE68' + } + @{ + Key = 'windows-all-hardening' + RelativePath = 'Windows/windows-all-hardening.json' + Tier = 'Windows' + Platform = 'Windows' + Kind = 'SettingsCatalog' + ExpectedDiscriminator = 'https://graph.microsoft.com/beta/$metadata#deviceManagement/configurationPolicies/$entity' + SourceApiProfile = 'beta' + ApplyStatus = 'Blocked' + SourceSha256 = '0F44C27822FA4994E4E32F0D7BBB1D90FA1D4E5AE70D0EECFEF6D27881633D7D' + Sha256 = 'C04672A261059EF273DF50C9095371CB171F73E9DEA53FFEB2B60875C53B9F41' + } + @{ + Key = 'windows-asr-audit' + RelativePath = 'Windows/windows-asr-audit.json' + Tier = 'Windows' + Platform = 'Windows' + Kind = 'SettingsCatalog' + ExpectedDiscriminator = 'https://graph.microsoft.com/beta/$metadata#deviceManagement/configurationPolicies/$entity' + SourceApiProfile = 'beta' + ApplyStatus = 'Blocked' + SourceSha256 = '25B2DB1E0486C83AF08D4D344C9457D05731578028DE8A50727DA666BC1BF539' + Sha256 = '2FEE25FB95AABAF58E90048FFCA2F0A8392964417EF75E7094486C461EB605FA' + } + @{ + Key = 'windows-compliance' + RelativePath = 'Windows/windows-compliance.json' + Tier = 'Windows' + Platform = 'Windows' + Kind = 'Compliance' + ExpectedDiscriminator = '#microsoft.graph.windows10CompliancePolicy' + SourceApiProfile = 'beta' + ApplyStatus = 'Blocked' + SourceSha256 = '4BFA48D1124248795582E49813D0EDAA07F4B9E896E0A84CBC622140E8AB89E8' + Sha256 = '13F217F8BB00ABDC0C5DCFB644A67C4A15CC9CA06FF15174CDC368B3CA0F5C2D' + } + @{ + Key = 'windows-edge-hardening' + RelativePath = 'Windows/windows-edge-hardening.json' + Tier = 'Windows' + Platform = 'Windows' + Kind = 'SettingsCatalog' + ExpectedDiscriminator = 'https://graph.microsoft.com/beta/$metadata#deviceManagement/configurationPolicies/$entity' + SourceApiProfile = 'beta' + ApplyStatus = 'Blocked' + SourceSha256 = '52344CDB2CB748EFFB5E839696BFECBA3863B9807D43882C7EC6948A5DFF997E' + Sha256 = 'C052B9FE4470E392A3C185DB22098F62E3481FC2EBE1352C5D0E6FF64F6DAC88' + } + @{ + Key = 'windows-security-baseline' + RelativePath = 'Windows/windows-security-baseline.json' + Tier = 'Windows' + Platform = 'Windows' + Kind = 'SettingsCatalog' + ExpectedDiscriminator = 'https://graph.microsoft.com/beta/$metadata#deviceManagement/configurationPolicies/$entity' + SourceApiProfile = 'beta' + ApplyStatus = 'Blocked' + SourceSha256 = 'BCB7313BDB5970E1022F433BD15475604CB58FE6DA33FE83B6B1A77C4363C17F' + Sha256 = '2753C278712EB57FCAC32F83E20C1CB13CBE0DD869FE82D4DB32AA011A305018' + } + ) +} diff --git a/Products/Intune/Deploy-IntuneBestPractice.ps1 b/Products/Intune/Deploy-IntuneBestPractice.ps1 new file mode 100644 index 0000000..0f327cc --- /dev/null +++ b/Products/Intune/Deploy-IntuneBestPractice.ps1 @@ -0,0 +1,597 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Deploys the Microsoft Intune best-practice baseline. + +.DESCRIPTION + Applies the device enrollment and device management baseline from the + Device Management Deployment Guide for Small Business (Business Premium / + Intune Plan 1) using Purview- and Defender-compatible orchestration + semantics. + + The current release includes five automated assessments, six guarded tenant + write paths, guided portal tasks, and structured HTML/JSON evidence. + Standard writes remain pilot-first and every high-risk write requires the + documented approval gates. The imported policy catalog remains blocked. + +.PARAMETER PilotGroupId + Entra group object ID that policies are assigned to. The toolkit defaults to + a pilot group rather than the source guide's "add all users" instruction, + because enrollment restrictions, compliance enforcement, and Conditional + Access can deny access to every user in the tenant on a first run. + +.PARAMETER DelegatedOrganization + Customer tenant domain for a GDAP delegated run. Graph authenticates + directly to this tenant rather than the administrator's home tenant. + Tenant identity is verified against this domain (or, absent it, the + TenantAdminUpn domain) after connection. + +.PARAMETER AutoInstallModules + Install missing Microsoft Graph modules (Microsoft.Graph.Authentication and + Microsoft.Graph.DeviceManagement) to CurrentUser without prompting. + +.PARAMETER AssignTenantWide + Opt in to tenant-wide assignment instead of a pilot group. This reproduces + the source guide literally and is deliberately not the default. + +.PARAMETER IncludeHighRisk + Required before any high-risk baseline item is considered. Requires + CustomerApprovalId so the customer authorization is recorded in evidence. + +.PARAMETER EnableComplianceEnforcement + Opt in to marking devices with no compliance policy as not compliant. This + is inert until a device-based Conditional Access policy exists, at which + point it becomes an access denial. Requires IncludeHighRisk. + +.PARAMETER EnableEnrollmentRestrictions + Opt in to creating device platform enrollment restrictions. A misconfigured + restriction blocks the enrollment the rest of the baseline depends on. + Requires IncludeHighRisk. + +.PARAMETER EnableConditionalAccessEnforcement + Opt in to creating the device-based Conditional Access policy. The policy + uses ConditionalAccess.DefaultState, which is report-only by default. + Promotion to enabled is a separate configuration and change decision. This + is the highest blast-radius action in the baseline and requires + IncludeHighRisk, BreakGlassExclusionsConfirmed, and RollbackAcknowledged. + +.PARAMETER BreakGlassExclusionsConfirmed + Operator confirmation that emergency-access accounts are excluded from the + Conditional Access policy. + +.PARAMETER BreakGlassUserIds + Emergency-access user object IDs to write into the Conditional Access + policy exclusion list. At least one user or group ID is required when the + policy creation gate is selected. + +.PARAMETER BreakGlassGroupIds + Emergency-access group object IDs to write into the Conditional Access + policy exclusion list. At least one user or group ID is required when the + policy creation gate is selected. + +.PARAMETER EnablePolicyCatalogWrite + Reserved opt-in for future writes based on the imported 19-payload policy + catalog. Always denies the run today. The source used Microsoft Graph beta, + and did not provide this repository's tenant permission, pilot, readback, + preservation, assignment, or rollback evidence. + +.LINK + https://learn.microsoft.com/intune/intune-service/fundamentals/deployment-guide-enrollment +#> + +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +param( + [Parameter(Mandatory)] [ValidatePattern('^[^@\s]+@[^@\s]+\.[^@\s]+$')] [string] $TenantAdminUpn, + [ValidatePattern('^[^@\s]+\.[^@\s]+$')] [string] $DelegatedOrganization, + [string] $ConfigPath, + [switch] $AutoInstallModules, + [switch] $NonInteractive, + [switch] $NoLicenseAutoDetect, + [switch] $AdoptExisting, + [switch] $SkipPreflight, + [switch] $SkipEnrollmentPrerequisites, + [switch] $SkipEnrollmentRestrictions, + [switch] $SkipComplianceBaseline, + [switch] $SkipDeviceCompliancePolicies, + [switch] $SkipAppProtectionPolicies, + [switch] $SkipAppDeployment, + [switch] $SkipEnterpriseStateRoaming, + [switch] $SkipDeviceConditionalAccess, + [switch] $IncludeHighRisk, + [string] $CustomerApprovalId, + [string] $ApprovalArtifactPath, + [switch] $RollbackAcknowledged, + [switch] $EnableComplianceEnforcement, + [switch] $EnableEnrollmentRestrictions, + [switch] $EnableConditionalAccessEnforcement, + [switch] $BreakGlassExclusionsConfirmed, + [ValidatePattern('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$')] + [string[]] $BreakGlassUserIds = @(), + [ValidatePattern('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$')] + [string[]] $BreakGlassGroupIds = @(), + [switch] $AssignTenantWide, + [ValidatePattern('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$')] [string] $PilotGroupId, + [switch] $EnablePolicyCatalogWrite +) + +$ErrorActionPreference = 'Stop' +$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$moduleRoot = Join-Path $scriptRoot 'Modules' +$runId = [guid]::NewGuid() +$startTime = [datetime]::UtcNow +$jsonPath = $null +$htmlPath = $null +$resolvedTenantId = $null + +. (Join-Path $moduleRoot 'IntuneRunLog.ps1') +. (Join-Path $moduleRoot 'Invoke-WithTransientRetry.ps1') +. (Join-Path $moduleRoot 'Write-IntuneHtmlReport.ps1') + +function Test-IntuneConfigContract { + [CmdletBinding()] + param([Parameter(Mandatory)] [hashtable] $Config) + + # Sections are verified first. The per-item loop below dereferences + # LicenseCapabilities, and without this guard a config missing that section + # dies with "You cannot call a method on a null-valued expression", which is + # precisely the cryptic failure this function exists to prevent. + foreach ($section in @('BestPracticeItems', 'LicenseCapabilities', 'Assignment', + 'Preflight', 'ApplePushCertificate', 'ConditionalAccess', + 'PolicyCatalog', 'Report', 'Api')) { + if (-not $Config.ContainsKey($section)) { + throw "Intune configuration must define a '$section' section." + } + } + if (-not $Config.BestPracticeItems) { + throw 'Intune configuration must define at least one BestPracticeItems entry.' + } + if ([string]::IsNullOrWhiteSpace([string] $Config.PolicyCatalog.ManifestPath)) { + throw 'PolicyCatalog.ManifestPath must identify the imported catalog manifest.' + } + if ($Config.PolicyCatalog.RequiredEntryCount -isnot [int] -or + $Config.PolicyCatalog.RequiredEntryCount -lt 1) { + throw 'PolicyCatalog.RequiredEntryCount must be a positive integer.' + } + + $validRisk = @('Standard', 'High') + $validDisposition = @('Applicable', 'AlreadyCompliant', 'WillChange', 'GuidedOnly', 'Skipped', 'Blocked') + + # Booleans must be real booleans. A string such as 'false' is truthy in + # PowerShell, so a quoted value in the data file would silently invert a + # safety control. + foreach ($flag in @('RequirePilotGroupForHighRisk', 'AllowTenantWideAssignmentForHighRisk')) { + if (-not $Config.Assignment.ContainsKey($flag)) { + throw "Assignment section must define '$flag'." + } + if ($Config.Assignment[$flag] -isnot [bool]) { + throw "Assignment.$flag must be a boolean, not '$($Config.Assignment[$flag].GetType().Name)'. A quoted string is always truthy and would disable this control." + } + } + + # Dispositions are passed straight into a ValidateSet parameter. Validating + # them here fails the run before any connection instead of part-way through + # preflight. + foreach ($key in @($Config.Preflight.Keys)) { + if ($Config.Preflight[$key] -notin $validDisposition) { + throw "Preflight.$key is '$($Config.Preflight[$key])', which is not a valid disposition. Valid values: $($validDisposition -join ', ')." + } + } + + if (-not $Config.ApplePushCertificate.ContainsKey('RenewalWarningDays') -or + $Config.ApplePushCertificate.RenewalWarningDays -isnot [int] -or + $Config.ApplePushCertificate.RenewalWarningDays -lt 1 -or + $Config.ApplePushCertificate.RenewalWarningDays -gt 90) { + throw 'ApplePushCertificate.RenewalWarningDays must be an integer from 1 through 90.' + } + + if (-not $Config.ConditionalAccess.ContainsKey('DefaultState') -or + [string]::IsNullOrWhiteSpace([string] $Config.ConditionalAccess.DefaultState)) { + throw 'ConditionalAccess section must define DefaultState.' + } + # Validated by value, not just presence. This is the state the highest + # blast-radius policy is created in, so a typo must fail the run rather than + # be carried into a Conditional Access decision. + $validConditionalAccessState = 'enabledForReportingButNotEnforced' + if ($Config.ConditionalAccess.DefaultState -ne $validConditionalAccessState) { + throw "ConditionalAccess.DefaultState must remain '$validConditionalAccessState' in the current release. Enforcement is a separate portal change after exclusions, sign-in evidence, and rollback are verified." + } + foreach ($key in @('OutputDirectory', 'JsonLogFileName', 'HtmlReportFileName')) { + if ([string]::IsNullOrWhiteSpace([string] $Config.Report[$key])) { + throw "Report section must define '$key'." + } + } + + $keys = @() + $guideTasks = @() + foreach ($item in @($Config.BestPracticeItems)) { + foreach ($property in @('Key', 'Name', 'Module', 'Risk')) { + if ([string]::IsNullOrWhiteSpace([string] $item[$property])) { + throw "BestPracticeItems entries must define '$property'." + } + } + if ([string]::IsNullOrWhiteSpace([string] $item.LicenseCapability) -or + -not $Config.LicenseCapabilities.ContainsKey([string] $item.LicenseCapability)) { + throw "BestPracticeItems entry '$($item.Key)' must reference a configured LicenseCapability." + } + if ($item.Key -notmatch '^[a-z0-9]+(?:-[a-z0-9]+)+$') { + throw "BestPracticeItems key '$($item.Key)' must be lowercase kebab-case." + } + if ($keys -contains $item.Key) { + throw "Duplicate BestPracticeItems key '$($item.Key)'." + } + if ($item.Priority -notin @(1, 2, 3)) { + throw "BestPracticeItems entry '$($item.Key)' must declare Priority 1, 2, or 3." + } + if ($item.Risk -notin $validRisk) { + throw "BestPracticeItems entry '$($item.Key)' declares Risk '$($item.Risk)'; valid values are $($validRisk -join ', ')." + } + if ($item.RequiresHighRiskGate -isnot [bool]) { + throw "BestPracticeItems entry '$($item.Key)' must declare RequiresHighRiskGate as a boolean." + } + # Risk and the gate flag drive real enforcement below, so an + # inconsistent pair would silently under-gate a dangerous item. + if ($item.Risk -eq 'High' -and -not $item.RequiresHighRiskGate) { + throw "BestPracticeItems entry '$($item.Key)' is Risk High but does not set RequiresHighRiskGate." + } + if ($guideTasks -contains $item.GuideTask) { + throw "Duplicate GuideTask '$($item.GuideTask)' on BestPracticeItems entry '$($item.Key)'." + } + $keys += $item.Key + $guideTasks += $item.GuideTask + } +} + +function Deny-IntuneRun { + <# + Records a refused run as evidence, then throws. A gate failure is a + deployment outcome the operator needs in the report, not just a console + exception, so the refusal is logged with a Blocked disposition before + the run terminates. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Gate, + [Parameter(Mandatory)] [string] $Reason + ) + + if (Test-IntuneRunLogInitialized) { + Add-IntuneRunLogEntry -Module 'Deploy-IntuneBestPractice' ` + -Action $Gate -Status 'Skipped' -Disposition 'Blocked' ` + -Detail $Reason + } + throw $Reason +} + +$tasks = @() +try { + # Config and evidence are established before any gate is evaluated, so that + # a rejected run still produces a report. Previously the run log was + # initialised after gate validation, which meant a blocked high-risk + # invocation left no JSON and no HTML at all - the operator got a bare + # exception and no record of what was refused or why. + if (-not $ConfigPath) { + $ConfigPath = Join-Path $scriptRoot 'Config\IntuneConfig.psd1' + } + if (-not (Test-Path -LiteralPath $ConfigPath)) { + throw "Config file not found: $ConfigPath" + } + + $config = Import-PowerShellDataFile -Path $ConfigPath + Test-IntuneConfigContract -Config $config + + $reportDirectory = Join-Path $scriptRoot $config.Report.OutputDirectory + $jsonPath = Join-Path $reportDirectory $config.Report.JsonLogFileName + $htmlPath = Join-Path $reportDirectory $config.Report.HtmlReportFileName + Initialize-IntuneRunLog -JsonPath $jsonPath -RunId $runId ` + -StartTime $startTime -ScriptVersion $config.ProductVersion ` + -TenantAdminUpn $TenantAdminUpn + + $tasks = @( + @{ Name = 'Setup-IntunePreflight'; Script = 'Setup-IntunePreflight.ps1'; Skip = $SkipPreflight; Args = @{ Config = $config; TenantAdminUpn = $TenantAdminUpn; NoLicenseAutoDetect = $NoLicenseAutoDetect } } + @{ Name = 'Setup-EnrollmentPrerequisites'; Script = 'Setup-EnrollmentPrerequisites.ps1'; Skip = $SkipEnrollmentPrerequisites; Args = @{ Config = $config; AdoptExisting = $AdoptExisting } } + @{ Name = 'Setup-ComplianceBaseline'; Script = 'Setup-ComplianceBaseline.ps1'; Skip = $SkipComplianceBaseline; Args = @{ Config = $config; AdoptExisting = $AdoptExisting; IncludeHighRisk = $IncludeHighRisk; EnableComplianceEnforcement = $EnableComplianceEnforcement } } + @{ Name = 'Setup-DeviceCompliancePolicies'; Script = 'Setup-DeviceCompliancePolicies.ps1'; Skip = $SkipDeviceCompliancePolicies; Args = @{ Config = $config; AdoptExisting = $AdoptExisting; IncludeHighRisk = $IncludeHighRisk } } + @{ Name = 'Setup-EnrollmentRestrictions'; Script = 'Setup-EnrollmentRestrictions.ps1'; Skip = $SkipEnrollmentRestrictions; Args = @{ Config = $config; AdoptExisting = $AdoptExisting; IncludeHighRisk = $IncludeHighRisk; EnableEnrollmentRestrictions = $EnableEnrollmentRestrictions } } + @{ Name = 'Setup-AppProtectionPolicies'; Script = 'Setup-AppProtectionPolicies.ps1'; Skip = $SkipAppProtectionPolicies; Args = @{ Config = $config; AdoptExisting = $AdoptExisting; PilotGroupId = $PilotGroupId } } + @{ Name = 'Setup-AppDeployment'; Script = 'Setup-AppDeployment.ps1'; Skip = $SkipAppDeployment; Args = @{ Config = $config; AdoptExisting = $AdoptExisting } } + @{ Name = 'Setup-EnterpriseStateRoaming'; Script = 'Setup-EnterpriseStateRoaming.ps1'; Skip = $SkipEnterpriseStateRoaming; Args = @{ Config = $config } } + @{ Name = 'Setup-DeviceConditionalAccess'; Script = 'Setup-DeviceConditionalAccess.ps1'; Skip = $SkipDeviceConditionalAccess; Args = @{ Config = $config; IncludeHighRisk = $IncludeHighRisk; EnableConditionalAccessEnforcement = $EnableConditionalAccessEnforcement; BreakGlassExclusionsConfirmed = $BreakGlassExclusionsConfirmed; RollbackAcknowledged = $RollbackAcknowledged } } + ) + + Add-IntuneRunLogEntry -Module 'Deploy-IntuneBestPractice' ` + -Action 'Run' -Status 'Started' -Detail 'Intune scaffold run started.' + + # The imported 19-payload policy catalog remains apply-blocked pending API + # and tenant-permission verification, pilot-only assignment, readback, + # preservation, rollback, and recovery evidence. + if ($EnablePolicyCatalogWrite) { + Deny-IntuneRun -Gate 'EnablePolicyCatalogWrite' ` + -Reason 'EnablePolicyCatalogWrite is not yet supported. Imported policy payloads remain blocked pending API and tenant-permission verification, pilot-only assignment, readback, preservation, rollback, and recovery evidence.' + } + + if ($IncludeHighRisk -and [string]::IsNullOrWhiteSpace($CustomerApprovalId)) { + Deny-IntuneRun -Gate 'IncludeHighRisk' -Reason 'IncludeHighRisk requires CustomerApprovalId.' + } + if ($ApprovalArtifactPath) { + if (-not (Test-Path -LiteralPath $ApprovalArtifactPath -PathType Leaf)) { + Deny-IntuneRun -Gate 'ApprovalArtifact' ` + -Reason 'The configured approval artifact was not found.' + } + if ((Get-Item -LiteralPath $ApprovalArtifactPath).Length -eq 0) { + Deny-IntuneRun -Gate 'ApprovalArtifact' ` + -Reason 'The configured approval artifact is empty.' + } + } + + # Record the customer authorisation itself. Without this the approval is + # accepted but never appears in evidence, so the report cannot show who + # authorised a high-risk change. + if ($IncludeHighRisk) { + $approvalReferenceHash = [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData( + [Text.Encoding]::UTF8.GetBytes($CustomerApprovalId) + ) + ) + $approvalDetail = "High-risk operations authorised. CustomerApprovalReferenceSha256=$approvalReferenceHash." + if ($ApprovalArtifactPath) { + $artifactHash = (Get-FileHash -LiteralPath $ApprovalArtifactPath -Algorithm SHA256).Hash + $approvalDetail += " ApprovalArtifactSha256=$artifactHash." + } + Add-IntuneRunLogEntry -Module 'Deploy-IntuneBestPractice' ` + -Action 'HighRiskApproval' -Status 'Info' -Disposition 'Applicable' ` + -Detail $approvalDetail + } + + $categoryGates = @( + @{ Enabled = $EnableComplianceEnforcement; Name = 'EnableComplianceEnforcement' } + @{ Enabled = $EnableEnrollmentRestrictions; Name = 'EnableEnrollmentRestrictions' } + @{ Enabled = $EnableConditionalAccessEnforcement; Name = 'EnableConditionalAccessEnforcement' } + ) + foreach ($gate in $categoryGates) { + if ($gate.Enabled -and -not $IncludeHighRisk) { + Deny-IntuneRun -Gate $gate.Name -Reason "$($gate.Name) requires IncludeHighRisk." + } + } + + # Conditional Access is the only item that can lock an administrator out of + # the tenant, so it carries the strictest gate in the product. + if ($EnableConditionalAccessEnforcement) { + if (-not $BreakGlassExclusionsConfirmed) { + Deny-IntuneRun -Gate 'EnableConditionalAccessEnforcement' ` + -Reason 'EnableConditionalAccessEnforcement requires BreakGlassExclusionsConfirmed.' + } + if (-not $RollbackAcknowledged) { + Deny-IntuneRun -Gate 'EnableConditionalAccessEnforcement' ` + -Reason 'EnableConditionalAccessEnforcement requires RollbackAcknowledged.' + } + if (@($BreakGlassUserIds).Count -eq 0 -and + @($BreakGlassGroupIds).Count -eq 0) { + Deny-IntuneRun -Gate 'EnableConditionalAccessEnforcement' ` + -Reason 'EnableConditionalAccessEnforcement requires at least one BreakGlassUserIds or BreakGlassGroupIds value so the exclusion is written into the policy.' + } + } + + # Tenant-wide assignment reproduces the source guide literally and removes + # the pilot blast-radius control, so it is an explicit acknowledged opt-in. + if ($AssignTenantWide -and -not $RollbackAcknowledged) { + Deny-IntuneRun -Gate 'AssignTenantWide' -Reason 'AssignTenantWide requires RollbackAcknowledged.' + } + + if ($IncludeHighRisk -and + $config.Assignment.RequirePilotGroupForHighRisk -and + -not $AssignTenantWide -and + [string]::IsNullOrWhiteSpace($PilotGroupId)) { + Deny-IntuneRun -Gate 'PilotGroup' ` + -Reason 'High-risk items require PilotGroupId, or AssignTenantWide with RollbackAcknowledged.' + } + # The configuration key is named for exactly what it controls: whether a + # high-risk run may go tenant-wide. It is not an absolute veto, and naming + # it as one would mislead a config author into relying on it. + if ($AssignTenantWide -and -not $config.Assignment.AllowTenantWideAssignmentForHighRisk) { + Deny-IntuneRun -Gate 'AssignTenantWide' ` + -Reason 'AssignTenantWide is disabled by Assignment.AllowTenantWideAssignmentForHighRisk in configuration.' + } + + if ($WhatIfPreference) { + Add-IntuneRunLogEntry -Module 'Deploy-IntuneBestPractice' ` + -Action 'WhatIf' -Status 'Info' ` + -Detail 'WhatIf preview enabled; child modules may not change tenant state.' + } + + $assignmentScope = if ($AssignTenantWide) { 'TenantWide' } else { 'PilotGroup' } + Add-IntuneRunLogEntry -Module 'Deploy-IntuneBestPractice' ` + -Action 'AssignmentScope' -Status 'Info' ` + -Detail "Assignment scope is $assignmentScope. The source guide recommends all users; this toolkit defaults to a pilot group." + + # Risk and RequiresHighRiskGate drive real enforcement rather than being + # descriptive metadata. An item that needs the high-risk gate and did not + # get it is recorded as blocked here and named in the context, so a module + # cannot act on it even when the module itself was not skipped. Modules map + # to more than one baseline item, so this has to be per item, not per + # module. + # + # Global approval is necessary but not sufficient. Each high-risk category + # also has its own enable switch, and the blocklist has to agree with the + # gate a module will apply. Evaluating both here keeps one answer to + # "may this item be acted on", instead of leaving a module free to act on an + # item whose category approval was never given. + $categorySwitchByItemKey = @{ + 'default-compliance-settings' = @{ Name = 'EnableComplianceEnforcement'; Enabled = [bool] $EnableComplianceEnforcement } + 'enrollment-restrictions' = @{ Name = 'EnableEnrollmentRestrictions'; Enabled = [bool] $EnableEnrollmentRestrictions } + 'device-conditional-access' = @{ Name = 'EnableConditionalAccessEnforcement'; Enabled = [bool] $EnableConditionalAccessEnforcement } + } + + $blockedItemKeys = @() + $writeBlockedItemKeys = @() + foreach ($item in @($config.BestPracticeItems | Sort-Object Priority, GuideTask)) { + Add-IntuneRunLogEntry -Module $item.Module -Action 'ConfiguredItem' ` + -BestPracticeKey $item.Key -Status 'Info' ` + -Detail "Configured recommendation (priority $($item.Priority), guide task $($item.GuideTask), risk $($item.Risk)): $($item.Name)." + + $blockReason = $null + if ($item.RequiresHighRiskGate -and -not $IncludeHighRisk) { + $blockReason = "item is Risk $($item.Risk) and requires IncludeHighRisk, which was not supplied" + } + elseif ($categorySwitchByItemKey.ContainsKey([string] $item.Key) -and + -not $categorySwitchByItemKey[[string] $item.Key].Enabled) { + $blockReason = "item requires $($categorySwitchByItemKey[[string] $item.Key].Name), which was not supplied" + } + + if ($blockReason -and [string] $item.Key -in @( + 'default-compliance-settings', + 'enrollment-restrictions', + 'device-compliance-policies' + )) { + $writeBlockedItemKeys += $item.Key + Add-IntuneRunLogEntry -Module $item.Module -Action 'WriteRiskGate' ` + -BestPracticeKey $item.Key -Status 'Info' ` + -Detail "Write authorization withheld: $blockReason." + } + elseif ($blockReason) { + $blockedItemKeys += $item.Key + Add-IntuneRunLogEntry -Module $item.Module -Action 'RiskGate' ` + -BestPracticeKey $item.Key -Status 'Skipped' -Disposition 'Blocked' ` + -Detail "Blocked: $blockReason." + } + } + + $connectScript = Join-Path $moduleRoot 'Connect-IntuneServices.ps1' + $connectArgs = @{ + TenantAdminUpn = $TenantAdminUpn + DelegatedOrganization = $DelegatedOrganization + Scopes = $config.Api.GraphScopes + GraphBaseUri = $config.Api.GraphBaseUri + NonInteractive = $NonInteractive + ConnectGraph = $true + } + if ($AutoInstallModules) { $connectArgs.AutoInstallModules = $true } + $connectionInfo = & $connectScript @connectArgs + $resolvedTenantId = [string]$connectionInfo.TenantIdentity.TenantId + if ([string]::IsNullOrWhiteSpace($resolvedTenantId)) { + throw 'The verified Microsoft Graph connection did not return a tenant ID.' + } + Set-IntuneRunTenantId -TenantId $resolvedTenantId + + $commonContext = @{ + TenantAdminUpn = $TenantAdminUpn + TenantId = $resolvedTenantId + ConnectionInfo = $connectionInfo + DelegatedOrganization = $DelegatedOrganization + CustomerApprovalId = $CustomerApprovalId + ApprovalArtifactPath = $ApprovalArtifactPath + IncludeHighRisk = [bool] $IncludeHighRisk + RollbackAcknowledged = [bool] $RollbackAcknowledged + PilotGroupId = $PilotGroupId + BreakGlassUserIds = @($BreakGlassUserIds) + BreakGlassGroupIds = @($BreakGlassGroupIds) + AssignmentScope = $assignmentScope + BlockedItemKeys = $blockedItemKeys + WriteBlockedItemKeys = $writeBlockedItemKeys + ApplicabilityPath = (Join-Path $reportDirectory 'intune-applicability.json') + WhatIf = [bool] $WhatIfPreference + NonInteractive = [bool] $NonInteractive + } + + foreach ($task in $tasks) { + if ($task.Skip) { + Add-IntuneRunLogEntry -Module $task.Name -Action 'Module' ` + -Status 'Skipped' -Detail 'Skip switch was supplied.' + continue + } + + $taskPath = Join-Path $moduleRoot $task.Script + if (-not (Test-Path -LiteralPath $taskPath -PathType Leaf)) { + Add-IntuneRunLogEntry -Module $task.Name -Action 'Module' ` + -Status 'Failed' -Detail "Module script not found: $taskPath" + throw "Module script not found: $taskPath" + } + Add-IntuneRunLogEntry -Module $task.Name -Action 'Module' ` + -Status 'Started' -Detail 'Module process started.' + $taskArgs = $task.Args + $taskArgs.Context = $commonContext + if ($WhatIfPreference) { + $taskArgs['WhatIf'] = $true + } + try { + & $taskPath @taskArgs + Add-IntuneRunLogEntry -Module $task.Name -Action 'Module' ` + -Status 'Succeeded' -Detail 'Module process completed.' + + # Preflight determines which baseline items the tenant can actually + # support. Fold its result into the blocked set so later modules + # cannot act on an unlicensed item. Without this the preflight only + # produces commentary and every module runs regardless. + if ($task.Name -eq 'Setup-IntunePreflight' -and + (Test-Path -LiteralPath $commonContext.ApplicabilityPath -PathType Leaf)) { + $applicability = Get-Content -LiteralPath $commonContext.ApplicabilityPath -Raw | + ConvertFrom-Json + foreach ($key in @($applicability.unavailableItemKeys)) { + if ($blockedItemKeys -notcontains $key) { $blockedItemKeys += $key } + } + $commonContext.BlockedItemKeys = $blockedItemKeys + # Later tasks receive this updated blocklist through the + # shared context at dispatch. + Add-IntuneRunLogEntry -Module 'Deploy-IntuneBestPractice' ` + -Action 'Applicability' -Status 'Info' ` + -Detail "Blocked baseline items after preflight: $(@($blockedItemKeys).Count)." + } + } + catch { + $status = Get-IntuneHttpStatusCode -ErrorRecord $_ + Add-IntuneRunLogEntry -Module $task.Name -Action 'Module' ` + -Status 'Failed' -HttpStatusCode $status ` + -Detail $_.Exception.Message + throw + } + } + + Add-IntuneRunLogEntry -Module 'Deploy-IntuneBestPractice' ` + -Action 'Run' -Status 'Succeeded' -Detail 'Intune scaffold run completed.' +} +catch { + $status = Get-IntuneHttpStatusCode -ErrorRecord $_ + if (Test-IntuneRunLogInitialized) { + Add-IntuneRunLogEntry -Module 'Deploy-IntuneBestPractice' ` + -Action 'Run' -Status 'Failed' -HttpStatusCode $status ` + -Detail $_.Exception.Message + $entries = Get-IntuneRunLog + $recordedModules = @($entries | Where-Object Action -eq 'Module' | Select-Object -ExpandProperty Module -Unique) + foreach ($task in $tasks) { + if ($task.Name -in $recordedModules) { continue } + $disposition = if ($task.Skip) { 'Skipped' } else { 'Blocked' } + $detail = if ($task.Skip) { 'Skip switch was supplied.' } else { + 'Not started because an earlier prerequisite or module failed. No assessment or deployment was performed by this module.' + } + Add-IntuneRunLogEntry -Module $task.Name -Action 'Module' -Status 'Skipped' ` + -Disposition $disposition -Detail $detail + } + } + throw +} +finally { + if (Test-IntuneRunLogInitialized) { + $endTime = [datetime]::UtcNow + try { + $writtenJsonPath = Save-IntuneRunLogJson -EndTime $endTime -PassThru + if ($writtenJsonPath) { + Write-Host ("`nJSON run log written: {0}" -f $writtenJsonPath) -ForegroundColor Cyan + } + try { + Write-IntuneHtmlReport -Path $htmlPath -Entries (Get-IntuneRunLog) ` + -RunId $runId -StartTime $startTime -EndTime $endTime ` + -TenantId $resolvedTenantId -TenantAdminUpn $TenantAdminUpn ` + -ScriptVersion $config.ProductVersion ` + -WhatIfRun:([bool] $WhatIfPreference) + $writtenHtmlPath = (Resolve-Path -LiteralPath $htmlPath -ErrorAction Stop).ProviderPath + Write-Host ("HTML report written: {0}" -f $writtenHtmlPath) -ForegroundColor Cyan + Write-Host ("Open report: Invoke-Item -LiteralPath '{0}'" -f $writtenHtmlPath.Replace("'", "''")) -ForegroundColor Cyan + } + catch { + Write-Warning ("HTML report could not be written: {0}" -f $_.Exception.Message) -WarningAction Continue + } + } + finally { + Clear-IntuneRunLog + } + } +} diff --git a/Products/Intune/Examples/Read-Only-Assessment.md b/Products/Intune/Examples/Read-Only-Assessment.md new file mode 100644 index 0000000..8821fca --- /dev/null +++ b/Products/Intune/Examples/Read-Only-Assessment.md @@ -0,0 +1,153 @@ +# Intune assessment and deployment examples + +These examples use the current `0.3.0` write-capable release. Start with +`-WhatIf`, use an approved pilot group for assignment, and add high-risk +switches only after the customer approval and recovery record is complete. + +Replace sample identities and GUIDs with values from the approved deployment +record. Do not put real tenant identifiers in source control. + +New to the tool? Start with the [README preview](../README.md#first-time-here-start-with-a-preview). +Before using a pilot ID below, follow +[pilot group setup](../docs/Operator-Guide.md#prepare-a-pilot-group). + +## Example 1: preview the full baseline + +```powershell +cd Products/Intune +./Deploy-IntuneBestPractice.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com ` + -PilotGroupId '' ` + -WhatIf +``` + +The preview performs the supported assessments and records every selected +write as planned intent. It does not change tenant state. + +Expected local outputs: + +- `Reports/intune-run-report.html` +- `Reports/intune-run-log.json` +- `Reports/intune-applicability.json` + +Move completed tenant reports to the approved private evidence location. Do +not commit them. + +The final output prints the saved HTML and JSON run-report paths and a +copyable command to open the HTML. Nothing opens automatically. These reports +also capture blocked work and failures after logging starts; a report-write +warning means the file was not confirmed saved by this run. + +## Example 2: apply the standard pilot baseline + +This command creates the Microsoft 365 Apps deployment and the Android and +iOS/iPadOS Level 1 app-protection policies, then assigns them to the pilot +group. High-risk writes remain withheld. + +```powershell +./Deploy-IntuneBestPractice.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com ` + -PilotGroupId '' +``` + +Review the report for `Created` and assignment evidence, then verify app +targeting and assignment in Intune. A missing pilot group does not make the +write tenant-wide. Standard objects can be created without assignment, so +supply the group for a useful pilot. + +## Example 3: add one high-risk stage + +Use the copy-ready [staged commands in the operator guide](../docs/Operator-Guide.md#add-high-risk-changes-one-at-a-time). +They separate device compliance, enrollment restrictions, the tenant-wide +default compliance setting, and report-only Conditional Access. Do not combine +them for a first deployment. Each stage starts with `-WhatIf` and requires its +own approval, before-state, verification, and recovery decision. + +The default compliance setting is not limited by `-PilotGroupId`. Conditional +Access remains report-only; enforcement is a later portal decision. + +## Example 4: GDAP-delegated preview + +Use `-DelegatedOrganization` when the administrator signs in through a partner +tenant but must administer a customer tenant under Granular Delegated Admin +Privileges. + +```powershell +./Deploy-IntuneBestPractice.ps1 ` + -TenantAdminUpn admin@partner.onmicrosoft.com ` + -DelegatedOrganization customer.onmicrosoft.com ` + -AutoInstallModules ` + -PilotGroupId '' ` + -WhatIf +``` + +Authentication is delegated UPN/GDAP only, matching Purview. A cached Graph +context is reused only when its account, tenant, and every required scope +match. A stale or wrong-tenant context is disconnected and replaced. + +`-AutoInstallModules` installs DeviceManagement at the same SDK version as +Authentication rather than independently choosing the latest release. If +the current shell already contains incompatible Graph modules or assemblies, +open a fresh PowerShell 7 session and follow the +[module recovery steps](../docs/Evidence-Troubleshooting.md#graph-module-version-conflicts). + +## Example 5: intentionally omit one task + +Skip switches are for a documented scope decision, not for hiding a failure. + +```powershell +./Deploy-IntuneBestPractice.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com ` + -PilotGroupId '' ` + -SkipAppProtectionPolicies ` + -WhatIf +``` + +Record why the task was omitted and who approved the reduced scope. + +## Example 6: review module and write results + +```powershell +$report = Get-Content ./Reports/intune-run-log.json -Raw | + ConvertFrom-Json + +$report.moduleSummary | + Sort-Object module | + Format-Table module, result, entryCount + +$report.entries | + Where-Object status -in @('Created', 'Updated', 'Adopted') | + Select-Object module, bestPracticeKey, action, target, readback, detail | + Format-Table -Wrap +``` + +For a `-WhatIf` run, the second query must be empty. For an apply run, every tenant write must match the approved scope. Confirm +module readback where it is emitted and use the documented portal or device +verification for app-protection targeting and assignment. + +## Example 7: list guided work + +```powershell +$report.entries | + Where-Object disposition -eq 'GuidedOnly' | + Select-Object module, bestPracticeKey, action, detail | + Format-Table -Wrap +``` + +Assign each guided item an owner and review date. Apple certificate work, +Managed Google Play, Windows automatic MDM enrollment, Windows Backup for +Organizations, and unsupported platform or catalog paths remain outside the +supported apply scope. + +## Reserved command that must fail closed + +The imported policy catalog is not an approved deployment surface: + +```powershell +./Deploy-IntuneBestPractice.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com ` + -EnablePolicyCatalogWrite +``` + +The command must refuse the run and write blocked evidence. Do not use it as a +capability probe in a customer change window. diff --git a/Products/Intune/Modules/Connect-IntuneServices.ps1 b/Products/Intune/Modules/Connect-IntuneServices.ps1 new file mode 100644 index 0000000..0b44888 --- /dev/null +++ b/Products/Intune/Modules/Connect-IntuneServices.ps1 @@ -0,0 +1,409 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Establishes the delegated Microsoft Graph connection required by Intune. + +.DESCRIPTION + Uses the same delegated UPN/GDAP session-guard behavior as Purview. A cached + context is reused only when its account, scopes, and target tenant are + trustworthy. Stale contexts are disconnected before a new interactive + connection is opened, and tenant identity is verified before setup begins. + +.PARAMETER TenantAdminUpn + Administrator UPN used for delegated sign-in. + +.PARAMETER DelegatedOrganization + Customer tenant domain for a GDAP run. Graph authenticates directly to this + tenant rather than the administrator's home tenant. + +.PARAMETER Scopes + Product-specific delegated Graph scopes supplied from IntuneConfig.psd1. + +.PARAMETER AutoInstallModules + Installs missing Microsoft Graph modules to CurrentUser without prompting. + DeviceManagement is pinned to the Authentication version used by this run. + +.PARAMETER NonInteractive + Suppresses toolkit prompts. Delegated Graph authentication can still require + an existing usable token or interactive browser/WAM sign-in. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $TenantAdminUpn, + [string] $DelegatedOrganization, + [string[]] $Scopes = @('User.Read'), + [Parameter(Mandatory)] [string] $GraphBaseUri, + [switch] $AutoInstallModules, + [switch] $NonInteractive, + [switch] $ConnectGraph +) + +$ErrorActionPreference = 'Stop' +$WhatIfPreference = $false +$ConfirmPreference = 'None' + +. (Join-Path $PSScriptRoot 'IntuneRunLog.ps1') +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') +. (Join-Path $PSScriptRoot 'IntuneGraphClient.ps1') + +function Add-IntuneConnectGuardLog { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Service, + [Parameter(Mandatory)] + [ValidateSet('Info', 'Started', 'Retried', 'Failed')] + [string] $Status, + [Parameter(Mandatory)] [string] $ReasonKey, + [hashtable] $Detail + ) + + if (-not (Get-Command Add-IntuneRunLogEntry -ErrorAction SilentlyContinue)) { + return + } + + $parts = @("reason=$ReasonKey") + if ($Detail) { + foreach ($key in ($Detail.Keys | Sort-Object)) { + $value = $Detail[$key] + if ($null -eq $value) { $value = '' } + $parts += ('{0}={1}' -f $key, $value) + } + } + + Add-IntuneRunLogEntry -Module 'Connect-IntuneServices' ` + -Action "SessionGuard:$Service" -Status $Status ` + -Detail ($parts -join '; ') +} + +function Test-IntuneIsAdmin { + try { + $current = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() + return $current.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } + catch { + return $false + } +} + +function Ensure-IntuneRequiredModule { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Name, + [string] $RequiredCmdlet, + [version] $RequiredVersion, + [switch] $AutoInstall, + [switch] $NonInteractive + ) + + $loaded = @(Get-Module -Name $Name -ErrorAction SilentlyContinue | Where-Object { $null -ne $_ }) + if ($loaded.Count -gt 1 -or ($RequiredVersion -and @($loaded | Where-Object Version -ne $RequiredVersion).Count -gt 0)) { + throw "Module '$Name' is already loaded with an incompatible version. Open a fresh PowerShell 7 session and rerun the toolkit before importing other Graph modules. Required version: $RequiredVersion; loaded: $($loaded.Version -join ', ')." + } + if (-not $RequiredVersion -and $loaded.Count -eq 1) { + $RequiredVersion = $loaded[0].Version + } + $available = Get-Module -ListAvailable -Name $Name -ErrorAction SilentlyContinue | + Where-Object { $null -ne $_ -and (-not $RequiredVersion -or $_.Version -eq $RequiredVersion) } | + Sort-Object Version -Descending | Select-Object -First 1 + if ($available) { $RequiredVersion = $available.Version } + $versionArgument = if ($RequiredVersion) { "-RequiredVersion $RequiredVersion " } else { '' } + $manual = "Install-Module $Name ${versionArgument}-Scope CurrentUser -Force -AllowClobber" + + if (-not $available) { + $versionLabel = if ($RequiredVersion) { " v$RequiredVersion" } else { '' } + Write-Warning "Required module '$Name'$versionLabel is not installed." + $shouldInstall = $AutoInstall.IsPresent + if (-not $shouldInstall -and $NonInteractive.IsPresent) { + throw "Module '$Name'$versionLabel is required and -NonInteractive is set (cannot prompt). Pre-install the matching module or rerun with -AutoInstallModules:`n $manual" + } + if (-not $shouldInstall) { + $response = Read-Host "Install '$Name'$versionLabel from PSGallery now to the current user scope? [Y/n]" + $shouldInstall = [string]::IsNullOrWhiteSpace($response) -or $response -match '^(y|yes)$' + } + if (-not $shouldInstall) { + throw "Module '$Name'$versionLabel is required but was not installed. Install manually and rerun:`n $manual" + } + if (-not (Test-IntuneIsAdmin)) { + Write-Host ' Installing to CurrentUser; elevation is not required.' -ForegroundColor DarkGray + } + $installArgs = @{ Name = $Name; Scope = 'CurrentUser'; Force = $true; AllowClobber = $true; ErrorAction = 'Stop' } + if ($RequiredVersion) { $installArgs.RequiredVersion = $RequiredVersion } + try { Install-Module @installArgs } + catch { throw "Failed to install module '$Name': $($_.Exception.Message)`nRetry the approved installation manually:`n $manual" } + + $available = Get-Module -ListAvailable -Name $Name -ErrorAction SilentlyContinue | + Where-Object { $null -ne $_ -and (-not $RequiredVersion -or $_.Version -eq $RequiredVersion) } | + Sort-Object Version -Descending | Select-Object -First 1 + if (-not $available) { throw "Module '$Name' is still unavailable after installation. Open a fresh PowerShell 7 session and verify:`n $manual" } + $RequiredVersion = $available.Version + } + + try { + # A newer side-by-side SDK module may require a different Authentication assembly. + Import-Module -Name $Name -RequiredVersion $RequiredVersion -DisableNameChecking -ErrorAction Stop | Out-Null + } + catch { + throw "Could not import '$Name' v$RequiredVersion. Open a fresh PowerShell 7 session and rerun the toolkit before importing other Graph modules. Loaded assemblies cannot be replaced in this session. If needed, repair the matching installation with:`n Install-Module $Name -RequiredVersion $RequiredVersion -Scope CurrentUser -Force -AllowClobber`nOriginal error: $($_.Exception.Message)" + } + if ($RequiredCmdlet -and -not (Get-Command $RequiredCmdlet -ListImported -ErrorAction SilentlyContinue)) { + throw "Module '$Name' v$RequiredVersion was imported but cmdlet '$RequiredCmdlet' is unavailable. Open a fresh PowerShell 7 session and repair the module installation." + } + return $RequiredVersion +} + +function Test-IntuneWamBrokerReadiness { + [CmdletBinding()] + param() + + $issues = @() + $info = [ordered]@{ + wamEligible = $false + psEdition = $PSVersionTable.PSEdition + psVersion = $PSVersionTable.PSVersion.ToString() + isWindows = [bool]$IsWindows + windowsBuild = $null + productType = $null + userInteractive = [Environment]::UserInteractive + runningAsSystem = $false + graphAuthModuleVersion = 'not-installed' + } + + if (-not $IsWindows) { + $issues += 'WAM is available only on Windows; Graph will use its browser flow.' + } + else { + try { + $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop + $build = [int]$os.BuildNumber + $productType = [int]$os.ProductType + $info.windowsBuild = $build + $info.productType = $productType + if ($productType -eq 1 -and $build -lt 10240) { + $issues += "Windows build $build is older than Windows 10 1507." + } + elseif ($productType -ge 2 -and $build -lt 17763) { + $issues += "Windows Server build $build is older than Server 2019." + } + } + catch { + Write-Verbose "Could not detect Windows build via CIM: $($_.Exception.Message)" + } + + try { + $current = [Security.Principal.WindowsIdentity]::GetCurrent() + if ($current -and $current.IsSystem) { + $info.runningAsSystem = $true + $issues += 'WAM requires a user desktop session and cannot run as SYSTEM.' + } + } + catch { + Write-Verbose "Could not inspect the Windows identity: $($_.Exception.Message)" + } + } + + if (-not [Environment]::UserInteractive) { + $issues += 'WAM requires an interactive desktop session.' + } + + $graphModule = Get-Module Microsoft.Graph.Authentication -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $graphModule) { + $graphModule = Get-Module Microsoft.Graph.Authentication -ListAvailable -ErrorAction SilentlyContinue | + Sort-Object Version -Descending | Select-Object -First 1 + } + if ($graphModule) { + $info.graphAuthModuleVersion = $graphModule.Version.ToString() + if ($graphModule.Version.Major -lt 2) { + $issues += "Microsoft.Graph.Authentication v$($graphModule.Version) predates built-in WAM support." + } + } + + $info.wamEligible = ($issues.Count -eq 0) + return [pscustomobject]@{ + Eligible = $info.wamEligible + Issues = $issues + Info = $info + } +} + +function Get-IntuneTenantIdentity { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $ExpectedDomain, + [Parameter(Mandatory)] [string] $TenantAdminUpn, + [Parameter(Mandatory)] [string] $GraphBaseUri + ) + + try { + $organizationUri = Resolve-IntuneGraphUri -BaseUri $GraphBaseUri ` + -RelativePath 'organization?$select=id,verifiedDomains' + $response = Invoke-IntuneGraphRequest -Method 'GET' -Uri $organizationUri ` + -EvidenceTarget 'tenant organization' + $organization = @($response.value)[0] + if (-not $organization -or [string]::IsNullOrWhiteSpace([string]$organization.id)) { + throw 'Microsoft Graph returned no tenant organization identity.' + } + $domains = @($organization.verifiedDomains | ForEach-Object { $_.name.ToLowerInvariant() }) + # Register before the comparison so a mismatch failure cannot quote an + # unregistered tenant domain in its evidence. + Register-IntuneSensitiveValue -Value $domains + $expected = $ExpectedDomain.ToLowerInvariant() + if ($domains -notcontains $expected) { + throw 'Connected tenant does not contain the expected verified domain.' + } + + Add-IntuneRunLogEntry -Module 'Connect-IntuneServices' ` + -Action 'TenantIdentity' -Status 'Succeeded' ` + -Detail 'Tenant identity verified against the expected domain.' + return [pscustomobject]@{ + TenantId = $organization.id + ExpectedDomain = $ExpectedDomain + VerifiedDomains = $domains + TenantAdminUpn = $TenantAdminUpn + } + } + catch { + $status = Get-IntuneHttpStatusCode -ErrorRecord $_ + Add-IntuneRunLogEntry -Module 'Connect-IntuneServices' ` + -Action 'TenantIdentity' -Status 'Failed' ` + -HttpStatusCode $status -Detail $_.Exception.Message + throw + } +} + +$tenantIdentity = $null +if ($ConnectGraph) { + Register-IntuneSensitiveValue -Value $DelegatedOrganization + if ($TenantAdminUpn -notmatch '@(?[^@\s]+)$') { + throw 'Unable to derive an expected tenant domain from TenantAdminUpn.' + } + $upnDomain = $Matches.domain + Register-IntuneSensitiveValue -Value $upnDomain + $targetTenantDomain = if ($DelegatedOrganization) { $DelegatedOrganization } else { $upnDomain } + + $wamReadiness = Test-IntuneWamBrokerReadiness + if ($wamReadiness.Eligible) { + Write-Host "WAM broker available: Graph sign-in should usually use a one-click 'Continue' prompt." -ForegroundColor DarkGray + } + else { + Write-Warning 'Windows Authentication Manager (WAM) is not fully usable; expect a browser sign-in.' + foreach ($issue in $wamReadiness.Issues) { Write-Warning " * $issue" } + } + Add-IntuneConnectGuardLog -Service 'Startup' -Status 'Info' ` + -ReasonKey $(if ($wamReadiness.Eligible) { 'wam-ready' } else { 'wam-not-ready' }) ` + -Detail $wamReadiness.Info + + try { + $authVersion = Ensure-IntuneRequiredModule -Name 'Microsoft.Graph.Authentication' ` + -RequiredCmdlet 'Connect-MgGraph' -AutoInstall:$AutoInstallModules ` + -NonInteractive:$NonInteractive + $null = Ensure-IntuneRequiredModule -Name 'Microsoft.Graph.DeviceManagement' ` + -RequiredVersion $authVersion -RequiredCmdlet 'Get-MgDeviceManagement' ` + -AutoInstall:$AutoInstallModules -NonInteractive:$NonInteractive + Add-IntuneConnectGuardLog -Service 'Prerequisites' -Status 'Info' ` + -ReasonKey 'graph-modules-aligned' -Detail @{ graphModuleVersion = $authVersion.ToString() } + } + catch { + Add-IntuneConnectGuardLog -Service 'Prerequisites' -Status 'Failed' ` + -ReasonKey 'module-readiness-failed' -Detail @{ error = $_.Exception.Message } + throw + } + + $requiredScopes = @($Scopes | Where-Object { $_ } | Select-Object -Unique) + if ($requiredScopes.Count -eq 0) { $requiredScopes = @('User.Read') } + $guardDetail = @{ + expectedUpn = $TenantAdminUpn + expectedTenantDomain = $targetTenantDomain + expectedScopes = ($requiredScopes -join ',') + } + $graphConnected = $false + $guardReason = 'no-context' + + try { + $context = Get-MgContext -ErrorAction Stop + if ($context) { + $cachedScopes = @($context.Scopes) + $missingScopes = @($requiredScopes | Where-Object { $cachedScopes -notcontains $_ }) + if ($context.Account -and + $context.Account -ieq $TenantAdminUpn -and + $missingScopes.Count -eq 0) { + # Verified for every run, not only GDAP: an account/scope match + # alone does not prove the cached context is connected to the + # intended tenant (the same delegated account can hold a Graph + # context for a different tenant it was previously connected + # to). Fail closed and reconnect whenever /organization cannot + # confirm the target tenant domain. + $tenantMatches = $false + try { + $organizationUri = Resolve-IntuneGraphUri -BaseUri $GraphBaseUri ` + -RelativePath 'organization?$select=verifiedDomains' + $response = Invoke-IntuneGraphRequest -Method 'GET' -Uri $organizationUri ` + -EvidenceTarget 'cached tenant organization' + $domains = @($response.value.verifiedDomains | ForEach-Object { $_.name.ToLowerInvariant() }) + Register-IntuneSensitiveValue -Value $domains + $tenantMatches = $domains -contains $targetTenantDomain.ToLowerInvariant() + if (-not $tenantMatches) { $guardReason = 'tenant-domain-mismatch' } + } + catch { + $tenantMatches = $false + $guardReason = 'tenant-verification-failed' + } + if ($tenantMatches) { + $graphConnected = $true + $guardReason = 'reused' + } + } + elseif ($missingScopes.Count -gt 0 -and $context.Account -ieq $TenantAdminUpn) { + $guardReason = 'missing-scopes' + $guardDetail.missingScopes = ($missingScopes -join ',') + } + else { + $guardReason = 'account-mismatch' + } + + $guardDetail.cachedAccount = [string]$context.Account + $guardDetail.cachedTenantId = [string]$context.TenantId + if (-not $graphConnected) { + Add-IntuneConnectGuardLog -Service 'Graph' -Status 'Retried' ` + -ReasonKey $guardReason -Detail $guardDetail + try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch { } + } + } + } + catch { + $guardReason = 'get-mgcontext-failed' + $guardDetail.error = $_.Exception.Message + } + + if ($graphConnected) { + Add-IntuneConnectGuardLog -Service 'Graph' -Status 'Info' ` + -ReasonKey 'reused' -Detail $guardDetail + } + else { + Add-IntuneConnectGuardLog -Service 'Graph' -Status 'Started' ` + -ReasonKey "reconnect-$guardReason" -Detail $guardDetail + try { + Connect-MgGraph -TenantId $targetTenantDomain -Scopes $requiredScopes ` + -NoWelcome -ErrorAction Stop + Add-IntuneConnectGuardLog -Service 'Graph' -Status 'Info' ` + -ReasonKey 'connected' -Detail $guardDetail + } + catch { + Add-IntuneConnectGuardLog -Service 'Graph' -Status 'Failed' ` + -ReasonKey 'connect-failed' -Detail @{ error = $_.Exception.Message } + throw + } + } + + $tenantIdentity = Get-IntuneTenantIdentity -ExpectedDomain $targetTenantDomain ` + -TenantAdminUpn $TenantAdminUpn -GraphBaseUri $GraphBaseUri +} + +[pscustomobject]@{ + TenantAdminUpn = $TenantAdminUpn + TenantId = if ($tenantIdentity) { $tenantIdentity.TenantId } else { $null } + DelegatedOrganization = $DelegatedOrganization + GraphConnected = [bool]$ConnectGraph + GraphBaseUri = $GraphBaseUri + TenantIdentity = $tenantIdentity +} diff --git a/Products/Intune/Modules/Get-IntunePolicyCatalog.ps1 b/Products/Intune/Modules/Get-IntunePolicyCatalog.ps1 new file mode 100644 index 0000000..8681870 --- /dev/null +++ b/Products/Intune/Modules/Get-IntunePolicyCatalog.ps1 @@ -0,0 +1,263 @@ +#requires -Version 7.0 + +function Get-IntunePolicyContentHash { + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)] [string] $Content) + + # Git may materialize text files with LF or CRLF depending on platform and + # checkout settings. Hash the canonical LF representation so integrity is + # stable across Windows, macOS, Linux, and public-repository promotion. + $normalized = $Content.Replace("`r`n", "`n").Replace("`r", "`n") + return [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData( + [Text.Encoding]::UTF8.GetBytes($normalized) + ) + ) +} + +function Assert-IntunePolicyPayloadShape { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object] $Payload, + [Parameter(Mandatory)] [System.Collections.IDictionary] $Entry + ) + + $factoryResetAccounts = $Payload.PSObject.Properties[ + 'factoryResetDeviceAdministratorEmails' + ] + if ($factoryResetAccounts -and @($factoryResetAccounts.Value).Count -gt 0) { + throw "Intune policy catalog payload '$($Entry.Key)' contains environment-specific Factory Reset Protection accounts." + } + + $requiredProperties = switch ([string] $Entry.Kind) { + 'SettingsCatalog' { @('name', 'platforms', 'technologies', 'settings') } + 'Compliance' { @('displayName') } + 'DeviceConfiguration' { @('displayName') } + } + foreach ($name in $requiredProperties) { + $property = $Payload.PSObject.Properties[$name] + if (-not $property -or $null -eq $property.Value) { + throw "Intune policy catalog payload '$($Entry.Key)' must define '$name' for kind '$($Entry.Kind)'." + } + if ($property.Value -is [string] -and + [string]::IsNullOrWhiteSpace([string] $property.Value)) { + throw "Intune policy catalog payload '$($Entry.Key)' must define '$name' for kind '$($Entry.Kind)'." + } + } + if ($Entry.Kind -eq 'SettingsCatalog' -and @($Payload.settings).Count -eq 0) { + throw "Intune policy catalog payload '$($Entry.Key)' must contain settings." + } +} + +function Get-IntunePolicyCatalog { + [CmdletBinding()] + [OutputType([object[]])] + param( + [Parameter(Mandatory)] [hashtable] $Config, + [switch] $IncludePayload + ) + + if (-not $Config.ContainsKey('PolicyCatalog')) { + throw 'Intune configuration must define a PolicyCatalog section.' + } + foreach ($name in @('ManifestPath', 'RequiredEntryCount')) { + if (-not $Config.PolicyCatalog.ContainsKey($name)) { + throw "PolicyCatalog must define '$name'." + } + } + if ($Config.PolicyCatalog.RequiredEntryCount -isnot [int] -or + $Config.PolicyCatalog.RequiredEntryCount -lt 1) { + throw 'PolicyCatalog.RequiredEntryCount must be a positive integer.' + } + + $productRoot = Split-Path -Parent $PSScriptRoot + $productRootFull = [IO.Path]::GetFullPath($productRoot) + $manifestPath = [IO.Path]::GetFullPath( + (Join-Path $productRoot ([string] $Config.PolicyCatalog.ManifestPath)) + ) + $productPrefix = $productRootFull.TrimEnd( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar + ) + [IO.Path]::DirectorySeparatorChar + if (-not $manifestPath.StartsWith( + $productPrefix, + [StringComparison]::OrdinalIgnoreCase + )) { + throw 'PolicyCatalog.ManifestPath must remain inside Products/Intune.' + } + if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { + throw "Intune policy catalog manifest not found: $manifestPath" + } + + $manifest = Import-PowerShellDataFile -LiteralPath $manifestPath + foreach ($name in @( + 'CatalogVersion', + 'SourceName', + 'SourceReviewDate', + 'RequiredEntryCount', + 'Entries' + )) { + if (-not $manifest.ContainsKey($name)) { + throw "Intune policy catalog manifest must define '$name'." + } + } + if ($manifest.RequiredEntryCount -ne $Config.PolicyCatalog.RequiredEntryCount) { + throw 'Policy catalog entry-count contract differs between configuration and manifest.' + } + + $entries = @($manifest.Entries) + if ($entries.Count -ne $manifest.RequiredEntryCount) { + throw "Intune policy catalog expected $($manifest.RequiredEntryCount) entries but contains $($entries.Count)." + } + + $catalogRoot = Split-Path -Parent $manifestPath + $catalogRootFull = [IO.Path]::GetFullPath($catalogRoot) + $catalogPrefix = $catalogRootFull.TrimEnd( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar + ) + [IO.Path]::DirectorySeparatorChar + $keys = [System.Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase + ) + $paths = [System.Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase + ) + $result = [System.Collections.Generic.List[object]]::new() + + foreach ($entry in $entries) { + foreach ($name in @( + 'Key', + 'RelativePath', + 'Tier', + 'Platform', + 'Kind', + 'ExpectedDiscriminator', + 'SourceApiProfile', + 'ApplyStatus', + 'SourceSha256', + 'Sha256' + )) { + if ([string]::IsNullOrWhiteSpace([string] $entry[$name])) { + throw "Intune policy catalog entry must define '$name'." + } + } + if ($entry.Key -notmatch '^[a-z0-9]+(?:-[a-z0-9]+)+$') { + throw "Intune policy catalog key '$($entry.Key)' must be lowercase kebab-case." + } + if (-not $keys.Add([string] $entry.Key)) { + throw "Duplicate Intune policy catalog key '$($entry.Key)'." + } + if ($entry.Tier -notin @('Baseline', 'Advanced', 'Windows')) { + throw "Intune policy catalog entry '$($entry.Key)' has unsupported tier '$($entry.Tier)'." + } + if ($entry.Kind -notin @('Compliance', 'DeviceConfiguration', 'SettingsCatalog')) { + throw "Intune policy catalog entry '$($entry.Key)' has unsupported kind '$($entry.Kind)'." + } + if ($entry.ApplyStatus -ne 'Blocked') { + throw "Intune policy catalog entry '$($entry.Key)' must remain ApplyStatus Blocked." + } + foreach ($digestName in @('SourceSha256', 'Sha256')) { + if ([string] $entry[$digestName] -notmatch '^[0-9A-F]{64}$') { + throw "Intune policy catalog entry '$($entry.Key)' has an invalid $digestName." + } + } + + $payloadPath = [IO.Path]::GetFullPath( + (Join-Path $catalogRoot ([string] $entry.RelativePath)) + ) + if (-not $payloadPath.StartsWith( + $catalogPrefix, + [StringComparison]::OrdinalIgnoreCase + )) { + throw "Intune policy catalog path for '$($entry.Key)' leaves the catalog root." + } + if (-not $paths.Add($payloadPath)) { + throw "Duplicate Intune policy catalog path '$($entry.RelativePath)'." + } + if (-not (Test-Path -LiteralPath $payloadPath -PathType Leaf)) { + throw "Intune policy catalog payload not found for '$($entry.Key)'." + } + + $payloadContent = [IO.File]::ReadAllText($payloadPath) + $actualHash = Get-IntunePolicyContentHash -Content $payloadContent + if (-not [string]::Equals( + $actualHash, + [string] $entry.Sha256, + [StringComparison]::OrdinalIgnoreCase + )) { + throw "Intune policy catalog payload hash mismatch for '$($entry.Key)'." + } + + try { + $payload = $payloadContent | ConvertFrom-Json -Depth 100 + } + catch { + throw "Intune policy catalog payload '$($entry.Key)' is not valid JSON." + } + Assert-IntunePolicyPayloadShape -Payload $payload -Entry $entry + foreach ($forbidden in @( + 'id', + 'createdDateTime', + 'lastModifiedDateTime', + 'creationSource', + 'priorityMetaData', + 'settingCount', + 'version', + 'assignments', + 'groupId' + )) { + if ($payload.PSObject.Properties[$forbidden]) { + throw "Intune policy catalog payload '$($entry.Key)' contains forbidden root property '$forbidden'." + } + } + + $typeProperty = $payload.PSObject.Properties['@odata.type'] + $contextProperty = $payload.PSObject.Properties['@odata.context'] + $discriminator = if ( + $typeProperty -and + -not [string]::IsNullOrWhiteSpace([string] $typeProperty.Value) + ) { + [string] $typeProperty.Value + } + elseif ($contextProperty) { + [string] $contextProperty.Value + } + else { + $null + } + if (-not [string]::Equals( + $discriminator, + [string] $entry.ExpectedDiscriminator, + [StringComparison]::Ordinal + )) { + throw "Intune policy catalog payload '$($entry.Key)' has an unexpected Graph discriminator." + } + + $result.Add([pscustomobject] @{ + Key = [string] $entry.Key + RelativePath = [string] $entry.RelativePath + Tier = [string] $entry.Tier + Platform = [string] $entry.Platform + Kind = [string] $entry.Kind + SourceApiProfile = [string] $entry.SourceApiProfile + ApplyStatus = [string] $entry.ApplyStatus + Sha256 = [string] $entry.Sha256 + Payload = if ($IncludePayload) { $payload } else { $null } + }) + } + + $catalogFiles = @( + Get-ChildItem -LiteralPath $catalogRoot -Recurse -File -Filter '*.json' + ) + $unlistedFiles = @( + $catalogFiles | Where-Object { + -not $paths.Contains([IO.Path]::GetFullPath($_.FullName)) + } + ) + if ($unlistedFiles.Count -gt 0) { + throw "Intune policy catalog contains $($unlistedFiles.Count) unlisted JSON payload(s)." + } + + return @($result) +} diff --git a/Products/Intune/Modules/IntuneAssignmentScope.ps1 b/Products/Intune/Modules/IntuneAssignmentScope.ps1 new file mode 100644 index 0000000..aa99890 --- /dev/null +++ b/Products/Intune/Modules/IntuneAssignmentScope.ps1 @@ -0,0 +1,187 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Shared assignment-target scope classification for Intune modules. + +.DESCRIPTION + One traversal of a Microsoft Graph assignment collection, used by every + Intune module that has to describe how broadly a policy or configuration is + targeted. + + Modules previously carried their own copy of this logic and the copies + drifted. The app protection copy classified an exclusion target as Unknown + while the enrollment copy counted it, and the enrollment copy returned as + soon as it saw a broad target, so any exclusion later in the collection was + never counted. Microsoft Graph does not guarantee assignment order, so that + made the reported exclusion count depend on response ordering. + + Exclusion handling is the one genuine domain difference between callers, so + it is an explicit parameter here rather than an accident of two separate + implementations. Everything else is shared. + + This function classifies data the caller already fetched. It issues no Graph + requests and changes no tenant state. +#> + +function Get-IntuneAssignmentTargetProperty { + <# + Property reader that tolerates both hashtable and PSObject shapes, + because Invoke-MgGraphRequest returns either depending on the call. + Assign through a variable rather than an inline `if`, since a value + assigned from an `if` block that yields an empty collection collapses to + $null and would make an empty but valid Graph collection look like a + missing property. + #> + [CmdletBinding()] + param( + [AllowNull()] $InputObject, + [Parameter(Mandatory)] [string] $Name + ) + + $exists = $false + $value = $null + + if ($null -eq $InputObject) { + return [pscustomobject] @{ Exists = $exists; Value = $value } + } + + if ($InputObject -is [System.Collections.IDictionary]) { + $exists = $InputObject.Contains($Name) + if ($exists) { $value = $InputObject[$Name] } + return [pscustomobject] @{ Exists = $exists; Value = $value } + } + + $property = $InputObject.PSObject.Properties[$Name] + $exists = $null -ne $property + if ($exists) { $value = $property.Value } + return [pscustomobject] @{ Exists = $exists; Value = $value } +} + +function Get-IntuneAssignmentScopeResult { + <# + .SYNOPSIS + Classify an assignment collection as None, PilotOnly, Broad, or Unknown. + + .PARAMETER ExclusionBehavior + How to treat an exclusionGroupAssignmentTarget. 'Unknown' marks the + whole collection Unknown, which is what a caller that does not model + exclusions should do so it never overstates how narrow the targeting is. + 'Count' records the exclusions in ExclusionCount and lets the rest of the + collection decide the scope. Exclusions alone have Unknown scope. + + .PARAMETER MissingTargetMessage + Message thrown when an assignment carries no target. Callers supply + their own so the failure names the resource the operator was reading. + + .OUTPUTS + PSCustomObject with Scope and ExclusionCount. ExclusionCount is always 0 + when ExclusionBehavior is 'Unknown'. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [AllowEmptyCollection()] [object[]] $Assignments, + [string] $PilotGroupId, + [ValidateSet('Unknown', 'Count')] [string] $ExclusionBehavior = 'Unknown', + [string] $MissingTargetMessage = 'Microsoft Graph returned an assignment without a target.' + ) + + if (@($Assignments).Count -eq 0) { + return [pscustomobject] @{ Scope = 'None'; ExclusionCount = 0 } + } + + $hasBroad = $false + $hasUnknown = $false + $exclusionCount = 0 + $groupIds = [System.Collections.Generic.List[string]]::new() + + foreach ($assignment in @($Assignments)) { + # Every assignment is validated, including any that follow a broad + # target. The enrollment classifier used to return at the first broad + # target, so whether a malformed assignment was caught at all depended + # on the order Microsoft Graph returned assignments in. Walking the + # whole collection makes that deterministic and fails closed, which is + # what the app protection caller already did. + $target = Get-IntuneAssignmentTargetProperty -InputObject $assignment -Name 'target' + if (-not $target.Exists -or $null -eq $target.Value) { + throw $MissingTargetMessage + } + + $targetType = [string] (Get-IntuneAssignmentTargetProperty ` + -InputObject $target.Value -Name '@odata.type').Value + + # Exclusion is tested first on purpose. The group pattern below is a + # substring of exclusionGroupAssignmentTarget, so testing group first + # would swallow every exclusion target and read its group as an include. + if ($targetType -match '(?i)exclusionGroupAssignmentTarget$') { + if ($ExclusionBehavior -eq 'Count') { $exclusionCount++ } else { $hasUnknown = $true } + continue + } + + # Flag rather than return. Returning here would stop the walk and lose + # any exclusion that sorts after the broad target, which is the ordering + # defect this shared helper exists to prevent. + if ($targetType -match '(?i)(?:allLicensedUsersAssignmentTarget|allDevicesAssignmentTarget)$') { + $hasBroad = $true + continue + } + + # Covers a blank or absent @odata.type as well, which must never be + # read as a narrow assignment. + if ($targetType -notmatch '(?i)(?:groupAssignmentTarget|scopeTagGroupAssignmentTarget)$') { + $hasUnknown = $true + continue + } + + # groupAssignmentTarget carries groupId and scopeTagGroupAssignmentTarget + # carries entraObjectId, so exactly one is normally present. Both + # original classifiers keyed their fallback on whether the property + # EXISTED, not on whether it held a value, so a present-but-empty + # identifier meant Unknown rather than silently falling through to the + # other name. Preserve that, and when both properties exist with + # different values there is no safe way to pick a winner, so report + # Unknown rather than risk reporting targeting narrower than it is. + $group = Get-IntuneAssignmentTargetProperty -InputObject $target.Value -Name 'groupId' + $altGroup = Get-IntuneAssignmentTargetProperty ` + -InputObject $target.Value -Name 'entraObjectId' + + $groupId = '' + if ($group.Exists -and $altGroup.Exists) { + if (-not [string]::Equals( + [string] $group.Value, + [string] $altGroup.Value, + [StringComparison]::OrdinalIgnoreCase)) { + $hasUnknown = $true + continue + } + $groupId = [string] $group.Value + } + elseif ($group.Exists) { $groupId = [string] $group.Value } + elseif ($altGroup.Exists) { $groupId = [string] $altGroup.Value } + + if ([string]::IsNullOrWhiteSpace($groupId)) { + $hasUnknown = $true + continue + } + $groupIds.Add($groupId) + } + + # Broad outranks Unknown. A collection that reaches everyone is reported as + # Broad even when another target could not be read, because that is the + # wider and therefore safer thing to tell the operator. + $scope = + if ($hasBroad) { 'Broad' } + elseif ($hasUnknown) { 'Unknown' } + elseif ($groupIds.Count -eq 0) { 'Unknown' } + elseif ([string]::IsNullOrWhiteSpace($PilotGroupId)) { 'Broad' } + elseif (@($groupIds | Where-Object { + -not [string]::Equals( + $_, + $PilotGroupId, + [StringComparison]::OrdinalIgnoreCase + ) + }).Count -gt 0) { 'Broad' } + else { 'PilotOnly' } + + return [pscustomobject] @{ Scope = $scope; ExclusionCount = $exclusionCount } +} diff --git a/Products/Intune/Modules/IntuneGraphClient.ps1 b/Products/Intune/Modules/IntuneGraphClient.ps1 new file mode 100644 index 0000000..5024a7a --- /dev/null +++ b/Products/Intune/Modules/IntuneGraphClient.ps1 @@ -0,0 +1,113 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Shared, connection-independent Microsoft Graph request helpers for Intune. + +.DESCRIPTION + Resolve-IntuneGraphUri and Invoke-IntuneGraphRequest do not authenticate. + They assume a Microsoft Graph context already exists in the current + PowerShell session (established once by Connect-IntuneServices.ps1 through + the orchestrator, or by the operator when a module is run standalone). + Task modules dot-source this file instead of Connect-IntuneServices.ps1 so + that reading these helpers never has the side effect of connecting or + reconnecting Microsoft Graph. +#> + +. (Join-Path $PSScriptRoot 'IntuneRunLog.ps1') +# Dot-sourced here (not only relied on from a caller's own dot-source) so +# Get-IntuneHttpStatusCode always resolves even when a caller dot-sources only +# this file, matching the "connection-independent" contract in the synopsis +# above. Re-dot-sourcing is harmless: it only redefines functions that are +# already idempotent to redefine. +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') + +function Resolve-IntuneGraphUri { + <# + Builds a request URI from the configured base. The base is validated + against the approved Graph hosts so a configuration edit cannot redirect + tenant reads to an arbitrary endpoint. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] [string] $BaseUri, + [Parameter(Mandatory)] [string] $RelativePath + ) + + $approvedHosts = @( + 'graph.microsoft.com', + 'graph.microsoft.us', + 'dod-graph.microsoft.us', + 'microsoftgraph.chinacloudapi.cn' + ) + + $parsed = $null + if (-not [uri]::TryCreate($BaseUri, [UriKind]::Absolute, [ref] $parsed)) { + throw "Api.GraphBaseUri is not an absolute URI: $BaseUri" + } + if ($parsed.Scheme -ne 'https') { + throw "Api.GraphBaseUri must use HTTPS: $BaseUri" + } + if ($parsed.Host -notin $approvedHosts) { + throw "Api.GraphBaseUri host '$($parsed.Host)' is not an approved Microsoft Graph endpoint." + } + + return ('{0}/{1}' -f $BaseUri.TrimEnd('/'), $RelativePath.TrimStart('/')) +} + +function Invoke-IntuneGraphRequest { + <# + Read-only while the product is Planned. The write verbs are deliberately + absent from the ValidateSet rather than merely unused, so a future + module cannot reach a tenant write before its operation is verified. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [ValidateSet('GET')] + [string] $Method, + [Parameter(Mandatory)] [string] $Uri, + [string] $EvidenceTarget, + [int[]] $ExpectedStatusCodes = @(), + # Only defer inside Invoke-WithTransientRetry, which owns the terminal + # Failed entry after retries are exhausted. + [switch] $DeferFailureEvidence + ) + + try { + $request = @{ + Method = $Method + Uri = $Uri + ErrorAction = 'Stop' + } + return Invoke-MgGraphRequest @request + } + catch { + $status = Get-IntuneHttpStatusCode -ErrorRecord $_ + $target = if ([string]::IsNullOrWhiteSpace($EvidenceTarget)) { + $Uri + } + else { + $EvidenceTarget + } + $detail = if ([string]::IsNullOrWhiteSpace($EvidenceTarget)) { + $_.Exception.Message + } + else { + "Microsoft Graph request failed for $EvidenceTarget." + } + $safeException = [Exception]::new($detail) + if ($status) { + $safeException | Add-Member -NotePropertyName Response ` + -NotePropertyValue ([pscustomobject] @{ StatusCode = $status }) + } + if ($status -in $ExpectedStatusCodes) { + throw $safeException + } + if (-not $DeferFailureEvidence) { + Add-IntuneRunLogEntry -Module 'Connect-IntuneServices' ` + -Action 'Invoke-MgGraphRequest' -Status 'Failed' ` + -Target $target -HttpStatusCode $status -Detail $detail + } + throw $safeException + } +} diff --git a/Products/Intune/Modules/IntunePolicyReadback.ps1 b/Products/Intune/Modules/IntunePolicyReadback.ps1 new file mode 100644 index 0000000..8e3c985 --- /dev/null +++ b/Products/Intune/Modules/IntunePolicyReadback.ps1 @@ -0,0 +1,117 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Compare managed JSON fields and exact assignment intent without coercion. +#> + +function Get-IntuneReadbackProperty { + param([AllowNull()] $InputObject, [Parameter(Mandatory)] [string] $Name) + + $exists = $false + $value = $null + if ($InputObject -is [System.Collections.IDictionary]) { + $exists = $InputObject.Contains($Name) + if ($exists) { $value = $InputObject[$Name] } + } + elseif ($null -ne $InputObject) { + $property = $InputObject.PSObject.Properties[$Name] + $exists = $null -ne $property + if ($exists) { $value = $property.Value } + } + return [pscustomobject] @{ Exists = $exists; Value = $value } +} + +function Test-IntuneManagedValue { + param([AllowNull()] $Expected, [AllowNull()] $Actual, [string] $PropertyName = '') + + if ($null -eq $Expected) { return $null -eq $Actual } + if ($null -eq $Actual) { return $false } + if ($Expected -is [System.Collections.IDictionary] -or $Expected -is [pscustomobject]) { + if ($Actual -isnot [System.Collections.IDictionary] -and $Actual -isnot [pscustomobject]) { + return $false + } + $names = if ($Expected -is [System.Collections.IDictionary]) { + @($Expected.Keys) + } else { @($Expected.PSObject.Properties.Name) } + foreach ($name in $names) { + $wanted = Get-IntuneReadbackProperty $Expected $name + $found = Get-IntuneReadbackProperty $Actual $name + # This non-derived complex type is known from the property schema; + # Graph can omit its OData annotation. Root resource types must match. + if ($name -eq '@odata.type' -and $PropertyName -eq 'platformRestriction' -and + $wanted.Value -in @('microsoft.graph.deviceEnrollmentPlatformRestriction', + '#microsoft.graph.deviceEnrollmentPlatformRestriction') -and -not $found.Exists) { + continue + } + if (-not $found.Exists -or -not (Test-IntuneManagedValue $wanted.Value $found.Value $name)) { + return $false + } + } + # Properties not managed by the payload, including server metadata, are preserved. + return $true + } + if ($Expected -is [System.Collections.IList]) { + if ($Actual -isnot [System.Collections.IList] -or $Expected.Count -ne $Actual.Count) { return $false } + $unordered = $PropertyName -in @( + 'scheduledActionsForRule', 'scheduledActionConfigurations', 'roleScopeTagIds', + 'notificationMessageCCList', 'restrictedApps', 'blockedManufacturers', 'blockedSkus' + ) + $used = [System.Collections.Generic.HashSet[int]]::new() + for ($i = 0; $i -lt $Expected.Count; $i++) { + if (-not $unordered) { + if (-not (Test-IntuneManagedValue $Expected[$i] $Actual[$i])) { return $false } + continue + } + $matched = $false + for ($j = 0; $j -lt $Actual.Count; $j++) { + if (-not $used.Contains($j) -and (Test-IntuneManagedValue $Expected[$i] $Actual[$j])) { + $null = $used.Add($j) + $matched = $true + break + } + } + if (-not $matched) { return $false } + } + return $true + } + if ($Expected -is [bool]) { return $Actual -is [bool] -and $Expected -eq $Actual } + if ($Expected -is [string]) { + if ($Actual -isnot [string]) { return $false } + if ($PropertyName -eq '@odata.type') { + return [string]::Equals($Expected.TrimStart('#'), $Actual.TrimStart('#'), [StringComparison]::Ordinal) + } + return [string]::Equals($Expected, $Actual, [StringComparison]::Ordinal) + } + # JSON numbers can deserialize to different CLR numeric types, but never strings or Booleans. + $numericTypes = @([byte], [sbyte], [short], [ushort], [int], [uint], [long], [ulong], [float], [double], [decimal]) + if ($Expected.GetType() -in $numericTypes -and $Actual.GetType() -in $numericTypes) { + return $Expected -eq $Actual + } + return $false +} + +function Test-IntuneExactAssignment { + param([AllowNull()] $ExpectedTarget, [AllowEmptyCollection()] [object[]] $Assignments) + + if ($null -eq $ExpectedTarget) { return @($Assignments).Count -eq 0 } + if (@($Assignments).Count -ne 1) { return $false } + $target = (Get-IntuneReadbackProperty $Assignments[0] 'target').Value + if (-not (Test-IntuneManagedValue $ExpectedTarget $target)) { return $false } + $names = if ($target -is [System.Collections.IDictionary]) { + @($target.Keys) + } else { @($target.PSObject.Properties.Name) } + foreach ($name in $names) { + if ((Get-IntuneReadbackProperty $ExpectedTarget $name).Exists) { continue } + $value = (Get-IntuneReadbackProperty $target $name).Value + switch -Exact ($name) { + 'deviceAndAppManagementAssignmentFilterType' { + if ($null -ne $value -and ($value -isnot [string] -or $value -cne 'none')) { return $false } + } + 'deviceAndAppManagementAssignmentFilterId' { + if ($null -ne $value -and ($value -isnot [string] -or $value -cne '')) { return $false } + } + default { return $false } + } + } + return $true +} diff --git a/Products/Intune/Modules/IntuneRunLog.ps1 b/Products/Intune/Modules/IntuneRunLog.ps1 new file mode 100644 index 0000000..574138c --- /dev/null +++ b/Products/Intune/Modules/IntuneRunLog.ps1 @@ -0,0 +1,346 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Structured console and local JSON logging for Intune deployments. + +.DESCRIPTION + Mirrors the Purview and Defender run-log contract while adding Intune + best-practice keys and HTTP status evidence. JSON persistence failures are + surfaced through warnings but do not interrupt the deployment. + + The status and disposition vocabulary is a compatibility contract for + the product's structured evidence. Preserve existing values when + changing the logger or report renderer. + + Persistence is deliberately durability-first: every entry rewrites the whole + JSON sidecar, so a run killed at any point still leaves a complete and valid + evidence file. The cost is that serialisation work grows quadratically with + entry count. That is an accepted trade for the current baseline, which + produces on the order of one hundred entries per run. + + If a run is ever expected to exceed roughly one thousand entries, revisit + the model rather than the constant: append events to a JSON Lines file for + durability and build the sidecar once at completion. Measure first, because + the current behaviour is the reason a crashed run is still diagnosable. +#> + +function Initialize-IntuneRunLog { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $JsonPath, + [guid] $RunId = [guid]::NewGuid(), + [datetime] $StartTime = [datetime]::UtcNow, + [string] $ScriptVersion = 'unknown', + [string] $TenantId, + [string] $TenantAdminUpn + ) + + $directory = Split-Path -Parent $JsonPath + if ($directory -and -not (Test-Path -LiteralPath $directory)) { + # Evidence output is local, not a tenant change. It must be written even + # during -WhatIf, otherwise a dry run produces no report at all, which + # is precisely when the operator most needs one. + New-Item -ItemType Directory -Path $directory -Force -WhatIf:$false | Out-Null + } + + $global:IntuneRunLog = [System.Collections.Generic.List[hashtable]]::new() + $global:IntuneRunLogPath = $JsonPath + if ($TenantAdminUpn -match '@(?[^@\s]+)$') { + Register-IntuneSensitiveValue -Value $Matches.domain + } + $global:IntuneRunMetadata = @{ + RunId = $RunId + StartTime = $StartTime + ScriptVersion = $ScriptVersion + TenantId = Protect-IntuneTenantId -Value $TenantId + TenantAdminUpn = Protect-IntuneIdentity -Value $TenantAdminUpn + } + Save-IntuneRunLogJson +} + +function Set-IntuneRunTenantId { + [CmdletBinding()] + param([Parameter(Mandatory)] [string] $TenantId) + + if (-not $global:IntuneRunMetadata) { + throw 'Intune run log metadata is not initialized.' + } + Register-IntuneSensitiveValue -Value $TenantId + $global:IntuneRunMetadata.TenantId = Protect-IntuneTenantId -Value $TenantId + Save-IntuneRunLogJson +} + +function Save-IntuneRunLogJson { + [CmdletBinding()] + param( + [string] $Path = $global:IntuneRunLogPath, + [datetime] $EndTime = [datetime]::UtcNow, + [switch] $PassThru + ) + + try { + if (-not $Path) { return } + $entries = if ($global:IntuneRunLog) { @($global:IntuneRunLog) } else { @() } + $metadata = if ($global:IntuneRunMetadata) { $global:IntuneRunMetadata } else { @{} } + # Per-module verdicts, so the JSON sidecar carries the same summary the + # HTML report shows and a downstream consumer does not have to + # re-derive it from the raw entries. + $moduleSummary = @($entries | Group-Object Module | Sort-Object Name | ForEach-Object { + $items = @($_.Group) + [ordered]@{ + module = $_.Name + result = Get-IntuneModuleVerdict -Entries $items + entryCount = $items.Count + } + }) + $payload = [ordered]@{ + runId = if ($metadata.RunId) { ([guid] $metadata.RunId).ToString() } else { $null } + scriptVersion = $metadata.ScriptVersion + startTime = if ($metadata.StartTime) { ([datetime] $metadata.StartTime).ToString('o') } else { $null } + endTime = $EndTime.ToString('o') + durationSec = if ($metadata.StartTime) { [int] (($EndTime - [datetime] $metadata.StartTime).TotalSeconds) } else { $null } + tenantId = $metadata.TenantId + tenantAdmin = $metadata.TenantAdminUpn + entryCount = $entries.Count + moduleSummary = $moduleSummary + entries = @($entries | ForEach-Object { + [ordered]@{ + timestamp = $_.Timestamp + module = $_.Module + action = $_.Action + bestPracticeKey = $_.BestPracticeKey + disposition = $_.Disposition + target = $_.Target + status = $_.Status + detail = $_.Detail + httpStatusCode = $_.HttpStatusCode + attempt = $_.Attempt + elapsedMs = $_.ElapsedMs + readback = $_.Readback + } + }) + } + # Evidence output is local, not a tenant change; never suppress it under + # -WhatIf. + $payload | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $Path -Encoding utf8 -WhatIf:$false -ErrorAction Stop + if ($PassThru) { (Resolve-Path -LiteralPath $Path -ErrorAction Stop).ProviderPath } + } + catch { + Write-Warning "JSON run log could not be written: $($_.Exception.Message)" -WarningAction Continue + } +} + +function Add-IntuneRunLogEntry { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Module, + [Parameter(Mandatory)] [string] $Action, + [ValidateSet('Started','Succeeded','Created','Updated','Adopted','Skipped','Failed','Retried','Info')] + [string] $Status = 'Info', + [string] $BestPracticeKey, + [ValidateSet('Applicable','AlreadyCompliant','WillChange','GuidedOnly','Skipped','Blocked')] + [string] $Disposition, + [string] $Target, + [string] $Detail, + [int] $HttpStatusCode, + [int] $Attempt, + [int] $ElapsedMs, + [ValidateSet('Verified','Mismatch','NotAttempted')] + [string] $Readback + ) + + try { + if (-not $global:IntuneRunLog) { + $global:IntuneRunLog = [System.Collections.Generic.List[hashtable]]::new() + } + + $entry = [ordered]@{ + Timestamp = [datetime]::UtcNow.ToString('o') + Module = $Module + Action = $Action + Status = $Status + BestPracticeKey = $BestPracticeKey + Disposition = $Disposition + Target = Protect-IntuneLogText -Value $Target + Detail = Protect-IntuneLogText -Value $Detail + HttpStatusCode = if ($PSBoundParameters.ContainsKey('HttpStatusCode')) { $HttpStatusCode } else { $null } + Attempt = if ($PSBoundParameters.ContainsKey('Attempt')) { $Attempt } else { $null } + ElapsedMs = if ($PSBoundParameters.ContainsKey('ElapsedMs')) { $ElapsedMs } else { $null } + Readback = if ($PSBoundParameters.ContainsKey('Readback')) { $Readback } else { $null } + } + + $global:IntuneRunLog.Add($entry) + Write-Verbose ("[{0}] {1}/{2}: {3}" -f $Status, $Module, $Action, $entry.Detail) + Write-Host ("[{0}] {1}: {2}" -f $Status, $Action, $entry.Detail) + Save-IntuneRunLogJson + } + catch { + Write-Verbose "Add-IntuneRunLogEntry failed: $($_.Exception.Message)" + } +} + +function Protect-IntuneLogText { + [CmdletBinding()] + param([AllowNull()] [object] $Value) + + if ($null -eq $Value) { return $null } + $text = [string] $Value + $text = $text -replace '(?i)(authorization\s*:\s*bearer\s+)[^\s,;]+', '$1[REDACTED]' + # Key=value and key: value forms. + $text = $text -replace '(?i)(access[_-]?token|refresh[_-]?token|id[_-]?token|client[_-]?secret|password|api[_-]?key)\s*[=:]\s*[^&\s,;]+', '$1=[REDACTED]' + # JSON forms, where the separator sits between quotes and the plain + # key=value pattern above does not match. + $text = $text -replace '(?i)("(?:access|refresh|id)[_-]?token"|"client[_-]?secret"|"password"|"api[_-]?key")\s*:\s*"[^"]*"', '$1:"[REDACTED]"' + # Object and tenant identifiers are useful for correlation, but the complete + # GUID is tenant evidence. Preserve only the first segment. + $text = [regex]::Replace( + $text, + '(?i)\b([0-9a-f]{8})-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b', + '$1-****-****-****-************' + ) + # Mask both the local part and tenant domain in UPNs and email addresses. + $text = [regex]::Replace( + $text, + '(?i)\b([A-Z0-9])[A-Z0-9._%+-]*@[A-Z0-9.-]+\.[A-Z]{2,}\b', + '$1***@[REDACTED]' + ) + # Tenant-scoped hostnames name the customer directly. Entra and Graph + # failures quote them in plain text, and that exception reaches evidence + # verbatim, so the domain has to be removed here rather than at the caller. + $text = [regex]::Replace( + $text, + '(?i)\b[A-Z0-9][A-Z0-9-]*\.(?:onmicrosoft\.com|sharepoint\.com)\b', + '[REDACTED]' + ) + # Domains resolved for this run, which covers a verified vanity domain that + # no fixed pattern can predict. Microsoft service endpoints are deliberately + # not registered, because they are diagnostic rather than tenant evidence. + $registered = Get-Variable -Name 'IntuneSensitiveValues' -Scope Global -ErrorAction SilentlyContinue + if ($registered -and $registered.Value) { + foreach ($sensitive in @($registered.Value)) { + if ([string]::IsNullOrWhiteSpace($sensitive)) { continue } + $text = [regex]::Replace($text, [regex]::Escape($sensitive), '[REDACTED]', 'IgnoreCase') + } + } + return $text +} + +function Register-IntuneSensitiveValue { + <# + Records a tenant-identifying value so later evidence can remove it. + Values shorter than five characters are ignored, because redacting a + very short string would corrupt unrelated text. + #> + [CmdletBinding()] + param([AllowNull()] [string[]] $Value) + + if (-not (Get-Variable -Name 'IntuneSensitiveValues' -Scope Global -ErrorAction SilentlyContinue)) { + $global:IntuneSensitiveValues = [System.Collections.Generic.List[string]]::new() + } + + foreach ($item in @($Value)) { + if ([string]::IsNullOrWhiteSpace($item)) { continue } + $normalized = $item.Trim() + if ($normalized.Length -lt 5) { continue } + if ($global:IntuneSensitiveValues -notcontains $normalized) { + $global:IntuneSensitiveValues.Add($normalized) + } + } +} + +function Protect-IntuneIdentity { + [CmdletBinding()] + param([AllowNull()] [string] $Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { return $null } + if ($Value -match '^([^@\s])[^@\s]*@[^@\s]+$') { + return "$($Matches[1])***@[REDACTED]" + } + return '[REDACTED]' +} + +function Protect-IntuneTenantId { + [CmdletBinding()] + param([AllowNull()] [string] $Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { return $null } + if ($Value -match '^(?[0-9a-fA-F]{8})-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { + return "$($Matches.prefix)-****-****-****-************" + } + return '[REDACTED]' +} + +function Get-IntuneRunLog { + [CmdletBinding()] + [OutputType([hashtable[]])] + param() + + if (-not (Test-IntuneRunLogInitialized)) { return @() } + return ,@($global:IntuneRunLog.ToArray()) +} + +function Get-IntuneEntryField { + <# + Reads a field from a run-log entry regardless of whether it arrives as a + hashtable or an object, and returns null for an absent field rather than + throwing under Set-StrictMode. + #> + [CmdletBinding()] + param( + [AllowNull()] [object] $Entry, + [Parameter(Mandatory)] [string] $Name + ) + + if ($null -eq $Entry) { return $null } + if ($Entry -is [System.Collections.IDictionary]) { + if ($Entry.Contains($Name)) { return $Entry[$Name] } + return $null + } + + $property = $Entry.PSObject.Properties[$Name] + if ($property) { return $property.Value } + return $null +} + +function Get-IntuneModuleVerdict { + <# + Single source of truth for the per-module verdict shown in both the JSON + sidecar and the HTML report. Precedence is FAILED, then BLOCKED, then + SKIPPED, then OK. Deriving it twice let the two reports disagree after a + status or disposition change, which would give an operator contradictory + evidence for the same run. + #> + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Entries) + + $items = @($Entries) + if (@($items | Where-Object { (Get-IntuneEntryField -Entry $_ -Name 'Status') -eq 'Failed' }).Count -gt 0) { return 'FAILED' } + if (@($items | Where-Object { (Get-IntuneEntryField -Entry $_ -Name 'Disposition') -eq 'Blocked' }).Count -gt 0) { return 'BLOCKED' } + if (@($items | Where-Object { (Get-IntuneEntryField -Entry $_ -Name 'Status') -eq 'Skipped' }).Count -gt 0) { return 'SKIPPED' } + return 'OK' +} + +function Test-IntuneRunLogInitialized { <# + Strict-mode safe check for run-log availability. A configuration failure + can terminate before Initialize-IntuneRunLog runs, and reading an unset + global under Set-StrictMode throws an error that masks the real reason + the run stopped. + #> + [CmdletBinding()] + [OutputType([bool])] + param() + + $variable = Get-Variable -Name 'IntuneRunLog' -Scope Global -ErrorAction SilentlyContinue + return [bool] ($variable -and $variable.Value) +} + +function Clear-IntuneRunLog { + [CmdletBinding()] + param() + + Remove-Variable -Name 'IntuneRunLog' -Scope Global -ErrorAction SilentlyContinue -WhatIf:$false + Remove-Variable -Name 'IntuneRunLogPath' -Scope Global -ErrorAction SilentlyContinue -WhatIf:$false + Remove-Variable -Name 'IntuneRunMetadata' -Scope Global -ErrorAction SilentlyContinue -WhatIf:$false + Remove-Variable -Name 'IntuneSensitiveValues' -Scope Global -ErrorAction SilentlyContinue -WhatIf:$false +} diff --git a/Products/Intune/Modules/Invoke-WithTransientRetry.ps1 b/Products/Intune/Modules/Invoke-WithTransientRetry.ps1 new file mode 100644 index 0000000..28074da --- /dev/null +++ b/Products/Intune/Modules/Invoke-WithTransientRetry.ps1 @@ -0,0 +1,170 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Single retry boundary for transient Intune API and cmdlet operations. + +.DESCRIPTION + Uses bounded exponential backoff with small random jitter and records + Started, Retried, Succeeded, and Failed outcomes through the Intune + run-log interface when it is available. + + This is the only retry mechanism in the product. Do not hand-roll retry + loops in task modules. +#> + +function Invoke-WithTransientRetry { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [scriptblock] $Action, + [Parameter(Mandatory)] [string] $Description, + [int] $MaxAttempts = 3, + [int[]] $ExpectedStatusCodes = @(), + [int[]] $AdditionalTransientStatusCodes = @(), + [int] $BackoffCapSeconds = 30, + [int] $MaxJitterSeconds = 3 + ) + + $transientStatusCodes = @(408, 429, 500, 502, 503, 504) + $extraOverlap = @($AdditionalTransientStatusCodes | Where-Object { $_ -in $transientStatusCodes }) + if ($extraOverlap.Count -gt 0) { + throw "AdditionalTransientStatusCodes cannot include standard transient HTTP status codes: $($extraOverlap -join ', ')." + } + $expectedExtraOverlap = @($ExpectedStatusCodes | Where-Object { $_ -in $AdditionalTransientStatusCodes }) + if ($expectedExtraOverlap.Count -gt 0) { + throw "ExpectedStatusCodes and AdditionalTransientStatusCodes cannot overlap: $($expectedExtraOverlap -join ', ')." + } + $transientStatusCodes = @($transientStatusCodes + $AdditionalTransientStatusCodes | Sort-Object -Unique) + $overlap = @($ExpectedStatusCodes | Where-Object { $_ -in $transientStatusCodes }) + if ($overlap.Count -gt 0) { + throw "ExpectedStatusCodes cannot include transient HTTP status codes: $($overlap -join ', ')." + } + + $logEnabled = [bool](Get-Command 'Add-IntuneRunLogEntry' -ErrorAction SilentlyContinue) + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + if ($logEnabled) { + Add-IntuneRunLogEntry -Module 'Invoke-WithTransientRetry' ` + -Action $Description -Status 'Started' -Attempt 1 ` + -Detail 'Transient operation started.' + } + + for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + try { + $result = & $Action + $stopwatch.Stop() + if ($logEnabled) { + Add-IntuneRunLogEntry -Module 'Invoke-WithTransientRetry' ` + -Action $Description -Status 'Succeeded' -Attempt $attempt ` + -ElapsedMs ([int] $stopwatch.ElapsedMilliseconds) ` + -Detail 'Transient operation completed successfully.' + } + return $result + } + catch { + $errorRecord = $_ + $status = Get-IntuneHttpStatusCode -ErrorRecord $errorRecord + if ($status -in $ExpectedStatusCodes) { + $stopwatch.Stop() + if ($logEnabled) { + Add-IntuneRunLogEntry -Module 'Invoke-WithTransientRetry' ` + -Action $Description -Status 'Info' -Attempt $attempt ` + -HttpStatusCode $status ` + -ElapsedMs ([int] $stopwatch.ElapsedMilliseconds) ` + -Detail ("{0} returned an expected unsupported response." -f $Description) + } + throw + } + + $transient = Test-IntuneTransientFailure -ErrorRecord $errorRecord ` + -HttpStatusCode $status -TransientHttpStatusCodes $transientStatusCodes + if (-not $transient -or $attempt -eq $MaxAttempts) { + $stopwatch.Stop() + if ($logEnabled) { + Add-IntuneRunLogEntry -Module 'Invoke-WithTransientRetry' ` + -Action $Description -Status 'Failed' -Attempt $attempt ` + -HttpStatusCode $status ` + -ElapsedMs ([int] $stopwatch.ElapsedMilliseconds) ` + -Detail $errorRecord.Exception.Message + } + throw + } + + $backoff = [math]::Min($BackoffCapSeconds, [math]::Pow(2, $attempt)) + $jitter = Get-Random -Minimum 0 -Maximum ($MaxJitterSeconds + 1) + $sleepSeconds = $backoff + $jitter + if ($logEnabled) { + Add-IntuneRunLogEntry -Module 'Invoke-WithTransientRetry' ` + -Action $Description -Status 'Retried' -Attempt $attempt ` + -HttpStatusCode $status ` + -Detail ("{0}. Sleeping {1}s before retry." -f $errorRecord.Exception.Message, $sleepSeconds) + } + Start-Sleep -Seconds $sleepSeconds + } + } +} + +# Response-less transient failures (cmdlet-style throttling or a transport +# reset) expose no HTTP status, so classification falls back to the exception +# type/message. Only consulted when no HTTP status code is available. +$script:IntuneTransientExceptionTypeNames = @( + 'HttpRequestException', 'TaskCanceledException', 'TimeoutException', + 'WebException', 'SocketException', 'IOException' +) +$script:IntuneTransientMessagePattern = + 'throttl|too many requests|\b429\b|\b502\b|\b503\b|\b504\b|service unavailable|temporarily unavailable|timed? ?out|connection (?:reset|closed|refused|aborted)|(?:operation|task) was canceled' + +function Test-IntuneTransientFailure { + <# + Single failure-classification contract. A transient failure is any + configured transient HTTP status, or — when no HTTP status is available + (a response-less cmdlet/transport error) — a recognized throttling or + transient-transport exception type/message. A real permanent status + (400/401/403/404/409...) always fails fast. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)] [System.Management.Automation.ErrorRecord] $ErrorRecord, + [int] $HttpStatusCode = 0, + [int[]] $TransientHttpStatusCodes = @(408, 429, 500, 502, 503, 504) + ) + + if ($HttpStatusCode -in $TransientHttpStatusCodes) { return $true } + if ($HttpStatusCode -ne 0) { return $false } + + $exception = $ErrorRecord.Exception + while ($exception) { + if ($exception.GetType().Name -in $script:IntuneTransientExceptionTypeNames) { return $true } + $exception = $exception.InnerException + } + return ([string] $ErrorRecord.Exception.Message) -match $script:IntuneTransientMessagePattern +} + +function Get-IntuneHttpStatusCode { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [System.Management.Automation.ErrorRecord] $ErrorRecord + ) + + $responseProperty = $ErrorRecord.Exception.PSObject.Properties['Response'] + if (-not $responseProperty) { + return 0 + } + + $response = $responseProperty.Value + $statusCodeProperty = if ($response) { + $response.PSObject.Properties['StatusCode'] + } + if ($statusCodeProperty -and $null -ne $statusCodeProperty.Value) { + $value = $statusCodeProperty.Value + if ($value -is [enum]) { + return [int] $value + } + + $parsedStatusCode = 0 + if ([int]::TryParse([string] $value, [ref] $parsedStatusCode)) { + return $parsedStatusCode + } + } + return 0 +} diff --git a/Products/Intune/Modules/Setup-AppDeployment.ps1 b/Products/Intune/Modules/Setup-AppDeployment.ps1 new file mode 100644 index 0000000..2d2b35c --- /dev/null +++ b/Products/Intune/Modules/Setup-AppDeployment.ps1 @@ -0,0 +1,333 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Microsoft 365 Apps deployment to managed Windows devices (guide task 8). + +.DESCRIPTION + Creates a single officeSuiteApp (Monthly Enterprise Channel, Open Document + Format default) from the AppDeployment section of IntuneConfig.psd1 and + assigns it to the pilot group, or to all licensed users when the operator + opted into tenant-wide assignment. + + Idempotent: an existing officeSuiteApp with the toolkit name or management + tag is left untouched. The current writer is create-only and does not + perform in-place adoption or refresh. This is a Standard-risk write (app + delivery only; it denies no access), so it does not require the high-risk + gate. All state changes are gated by ShouldProcess, wrapped in the shared + transient-retry boundary, and read back after write. +#> + +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +param( + [Parameter(Mandatory)] [hashtable] $Config, + [hashtable] $Context, + [switch] $AdoptExisting +) + +$ErrorActionPreference = 'Stop' +# Auto-confirm: this toolkit runs unattended. -WhatIf still previews; the +# orchestrator supplies the high-risk gates. +$ConfirmPreference = 'None' + +. (Join-Path $PSScriptRoot 'IntuneRunLog.ps1') +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') + +$module = 'Setup-AppDeployment' +$bestPracticeKey = 'm365-apps-deployment' + +function Get-IntuneAppDeploymentProperty { + param([AllowNull()] $InputObject, [Parameter(Mandatory)] [string] $Name) + + $exists = $false + $value = $null + if ($null -eq $InputObject) { + return [pscustomobject] @{ Exists = $exists; Value = $value } + } + if ($InputObject -is [System.Collections.IDictionary]) { + $exists = $InputObject.Contains($Name) + if ($exists) { $value = $InputObject[$Name] } + return [pscustomobject] @{ Exists = $exists; Value = $value } + } + $property = $InputObject.PSObject.Properties[$Name] + $exists = $null -ne $property + if ($exists) { $value = $property.Value } + return [pscustomobject] @{ Exists = $exists; Value = $value } +} + +function Get-IntuneOfficeSuiteApps { + param( + [Parameter(Mandatory)] [string] $InitialUri, + [Parameter(Mandatory)] [string] $GraphBaseUri + ) + + $base = [uri] $GraphBaseUri + $expectedPathPrefix = $base.AbsolutePath.TrimEnd('/') + '/' + $visited = [System.Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase + ) + $apps = [System.Collections.Generic.List[object]]::new() + $nextUri = $InitialUri + + while (-not [string]::IsNullOrWhiteSpace($nextUri)) { + if (-not $visited.Add($nextUri)) { + throw 'Microsoft Graph returned a pagination cycle for Microsoft 365 Apps deployments.' + } + $parsedNext = $null + if (-not [uri]::TryCreate($nextUri, [UriKind]::Absolute, [ref] $parsedNext) -or + $parsedNext.Scheme -ne 'https' -or + -not [string]::Equals( + $parsedNext.Host, + $base.Host, + [StringComparison]::OrdinalIgnoreCase + ) -or + -not $parsedNext.AbsolutePath.StartsWith( + $expectedPathPrefix, + [StringComparison]::OrdinalIgnoreCase + )) { + throw 'Microsoft Graph returned a Microsoft 365 Apps next link outside the configured Graph API path.' + } + + $response = Invoke-WithTransientRetry -Description 'Read existing officeSuiteApp' -Action { + Invoke-MgGraphRequest -Method GET -Uri $nextUri + } + $value = Get-IntuneAppDeploymentProperty -InputObject $response -Name 'value' + if (-not $value.Exists -or $null -eq $value.Value) { + throw 'Microsoft Graph returned no Microsoft 365 Apps collection.' + } + foreach ($item in @($value.Value)) { + if ($null -eq $item) { + throw 'Microsoft Graph returned a null Microsoft 365 Apps deployment.' + } + $apps.Add($item) + } + + $nextLink = Get-IntuneAppDeploymentProperty ` + -InputObject $response -Name '@odata.nextLink' + $nextUri = if ($nextLink.Exists) { [string] $nextLink.Value } else { $null } + } + + return @($apps) +} + +# Preflight or a gate may have withheld this item; a withheld item must not be +# written even though the module itself was not skipped. +if ($Context -and @($Context.BlockedItemKeys) -contains $bestPracticeKey) { + Add-IntuneRunLogEntry -Module $module -Action 'Deploy' -BestPracticeKey $bestPracticeKey ` + -Status 'Skipped' -Disposition 'Blocked' ` + -Detail 'Microsoft 365 Apps deployment was withheld by preflight or a gate.' + return +} + +# Standalone modules no longer connect Graph themselves; require the caller +# (the orchestrator, or an operator running this module directly) to have +# already established an authenticated Graph context in this session. +if (-not $Context -or [string]::IsNullOrWhiteSpace([string] $Context.TenantAdminUpn)) { + throw 'Setup-AppDeployment.ps1 requires Context.TenantAdminUpn from a pre-authenticated Graph connection. Run it through Deploy-IntuneBestPractice.ps1 or connect to Microsoft Graph yourself and supply -Context.' +} + +$app = $Config.AppDeployment +$tag = $Config.ManagedByTag +$appsUri = "$($Config.Api.GraphBetaBaseUri)/deviceAppManagement/mobileApps" + +# Read before write. A toolkit-owned officeSuiteApp is treated as already +# compliant so re-runs do not create duplicates. +$managedExisting = @() +$unmanagedSameName = @() +try { + $existingApps = @(Get-IntuneOfficeSuiteApps ` + -InitialUri ("{0}?`$filter=isof('microsoft.graph.officeSuiteApp')" -f $appsUri) ` + -GraphBaseUri $Config.Api.GraphBetaBaseUri) + foreach ($existingApp in $existingApps) { + $displayNameProperty = Get-IntuneAppDeploymentProperty ` + -InputObject $existingApp -Name 'displayName' + if (-not $displayNameProperty.Exists -or + $displayNameProperty.Value -isnot [string] -or + [string]::IsNullOrWhiteSpace([string] $displayNameProperty.Value)) { + throw 'Microsoft Graph returned a malformed Microsoft 365 Apps display name.' + } + $descriptionProperty = Get-IntuneAppDeploymentProperty ` + -InputObject $existingApp -Name 'description' + if ($descriptionProperty.Exists -and + $null -ne $descriptionProperty.Value -and + $descriptionProperty.Value -isnot [string]) { + throw 'Microsoft Graph returned a malformed Microsoft 365 Apps description.' + } + + $isManaged = $descriptionProperty.Exists -and + $descriptionProperty.Value -is [string] -and + ([string] $descriptionProperty.Value).Contains( + $tag, + [StringComparison]::Ordinal + ) + $isSameName = [string]::Equals( + [string] $displayNameProperty.Value, + [string] $app.DisplayName, + [StringComparison]::OrdinalIgnoreCase + ) + if ($isManaged) { + $managedExisting += $existingApp + } + elseif ($isSameName) { + $unmanagedSameName += $existingApp + } + } +} +catch { + $reason = "Could not enumerate existing Microsoft 365 Apps deployments before create: $($_.Exception.Message)" + Add-IntuneRunLogEntry -Module $module -Action 'ReadExisting' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'NotAttempted' ` + -Detail $reason + throw $reason +} + +if ($unmanagedSameName.Count -gt 0) { + $reason = "A Microsoft 365 Apps deployment named '$($app.DisplayName)' already exists but is not managed by the toolkit. Rename it or choose a different configured name; the create-only writer will not adopt or overwrite it." + Add-IntuneRunLogEntry -Module $module -Action 'Deploy' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Target $app.DisplayName ` + -Readback 'NotAttempted' -Detail $reason + throw $reason +} + +if ($managedExisting.Count -gt 1) { + $reason = 'Multiple toolkit-managed Microsoft 365 Apps deployments exist. Resolve the duplicate ownership collision before rerunning.' + Add-IntuneRunLogEntry -Module $module -Action 'Deploy' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Target $app.DisplayName ` + -Readback 'NotAttempted' -Detail $reason + throw $reason +} + +if ($managedExisting.Count -gt 0) { + Add-IntuneRunLogEntry -Module $module -Action 'Deploy' -BestPracticeKey $bestPracticeKey ` + -Status 'Skipped' -Disposition 'AlreadyCompliant' -Target $app.DisplayName ` + -Readback 'Verified' ` + -Detail 'A toolkit-managed Microsoft 365 Apps deployment already exists; leaving it unchanged. The current writer is create-only and does not refresh or rename existing applications.' + return +} + +if ([string] $Context.AssignmentScope -eq 'TenantWide' -and + $Context.RollbackAcknowledged -ne $true) { + $reason = 'Tenant-wide Microsoft 365 Apps assignment requires Context.RollbackAcknowledged from the verified run context.' + Add-IntuneRunLogEntry -Module $module -Action 'Deploy' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Target $app.DisplayName ` + -Readback 'NotAttempted' -Detail $reason + throw $reason +} + +# Resolve the assignment target from the orchestrator's assignment decision. +$assignmentTarget = $null +$assignmentDetail = $null +if ($Context.AssignmentScope -eq 'TenantWide') { + $assignmentTarget = @{ '@odata.type' = '#microsoft.graph.allLicensedUsersAssignmentTarget' } + $assignmentDetail = 'all licensed users' +} +elseif (-not [string]::IsNullOrWhiteSpace([string] $Context.PilotGroupId)) { + $assignmentTarget = @{ '@odata.type' = '#microsoft.graph.groupAssignmentTarget'; groupId = $Context.PilotGroupId } + $assignmentDetail = "pilot group $($Context.PilotGroupId)" +} + +$displayName = $app.DisplayName +if (-not $PSCmdlet.ShouldProcess($displayName, 'Create Microsoft 365 Apps (Windows) and assign')) { + Add-IntuneRunLogEntry -Module $module -Action 'Deploy' -BestPracticeKey $bestPracticeKey ` + -Status 'Info' -Disposition 'WillChange' -Target $displayName -Readback 'NotAttempted' ` + -Detail "WhatIf: would create officeSuiteApp '$displayName' ($($app.UpdateChannel), $($app.DefaultFileFormat)) and assign to $([string]::IsNullOrWhiteSpace($assignmentDetail) ? 'no target (none provided)' : $assignmentDetail)." + return +} + +$excludedApps = @{ '@odata.type' = '#microsoft.graph.excludedApps' } +foreach ($excluded in @($app.ExcludedApps)) { $excludedApps[$excluded] = $true } + +$body = @{ + '@odata.type' = '#microsoft.graph.officeSuiteApp' + displayName = $displayName + description = $tag + publisher = $app.Publisher + autoAcceptEula = $true + officePlatformArchitecture = $app.Architecture + updateChannel = $app.UpdateChannel + officeSuiteAppDefaultFileFormat = $app.DefaultFileFormat + useSharedComputerActivation = [bool] $app.UseSharedComputerActivation + localesToInstall = @($app.Locales) + productIds = @($app.ProductIds) + excludedApps = $excludedApps +} | ConvertTo-Json -Depth 10 + +$created = $null +try { + $created = Invoke-WithTransientRetry -Description "Create officeSuiteApp '$displayName'" -Action { + Invoke-MgGraphRequest -Method POST -Uri $appsUri -Body $body -ContentType 'application/json' + } + Add-IntuneRunLogEntry -Module $module -Action 'Create' -BestPracticeKey $bestPracticeKey ` + -Status 'Created' -Disposition 'Applicable' -Target $displayName ` + -Detail "Created Microsoft 365 Apps deployment ($($app.UpdateChannel), $($app.DefaultFileFormat))." +} +catch { + Add-IntuneRunLogEntry -Module $module -Action 'Create' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Applicable' -Target $displayName ` + -HttpStatusCode (Get-IntuneHttpStatusCode -ErrorRecord $_) ` + -Detail "Failed to create Microsoft 365 Apps deployment: $($_.Exception.Message)" + throw +} + +$createdId = [string] $created.id +if ([string]::IsNullOrWhiteSpace($createdId)) { + throw "Microsoft Graph returned no ID for the created Microsoft 365 Apps deployment '$displayName'." +} +$encodedCreatedId = [uri]::EscapeDataString($createdId) + +if ($assignmentTarget) { + $assignBody = @{ + mobileAppAssignments = @(@{ + '@odata.type' = '#microsoft.graph.mobileAppAssignment' + intent = $app.AssignmentIntent + target = $assignmentTarget + }) + } | ConvertTo-Json -Depth 10 + try { + Invoke-WithTransientRetry -Description "Assign officeSuiteApp '$displayName'" -Action { + Invoke-MgGraphRequest -Method POST -Uri "$appsUri/$encodedCreatedId/assign" -Body $assignBody -ContentType 'application/json' | Out-Null + } + Add-IntuneRunLogEntry -Module $module -Action 'Assign' -BestPracticeKey $bestPracticeKey ` + -Status 'Succeeded' -Disposition 'Applicable' -Target $displayName ` + -Detail "Assigned to $assignmentDetail." + } + catch { + Add-IntuneRunLogEntry -Module $module -Action 'Assign' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Applicable' -Target $displayName ` + -HttpStatusCode (Get-IntuneHttpStatusCode -ErrorRecord $_) ` + -Detail "Created the app but failed to assign it: $($_.Exception.Message)" + throw + } +} +else { + Add-IntuneRunLogEntry -Module $module -Action 'Assign' -BestPracticeKey $bestPracticeKey ` + -Status 'Info' -Disposition 'GuidedOnly' -Target $displayName ` + -Detail 'No assignment target was available (no PilotGroupId and not tenant-wide); the app was created but not assigned. Supply -PilotGroupId or -AssignTenantWide to assign it.' +} + +# Read back so evidence records the tenant's committed state, not just the +# create response. +try { + $verify = Invoke-WithTransientRetry -Description "Read back officeSuiteApp '$displayName'" -Action { + Invoke-MgGraphRequest -Method GET -Uri "$appsUri/$encodedCreatedId" + } + $readback = if ($verify -and $verify.displayName -eq $displayName -and $verify.updateChannel -eq $app.UpdateChannel) { 'Verified' } else { 'Mismatch' } + if ($readback -ne 'Verified') { + $reason = "Microsoft 365 Apps readback did not match the configured display name and update channel." + Add-IntuneRunLogEntry -Module $module -Action 'Readback' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Target $displayName -Readback $readback ` + -Detail $reason + throw $reason + } + Add-IntuneRunLogEntry -Module $module -Action 'Readback' -BestPracticeKey $bestPracticeKey ` + -Status 'Info' -Disposition 'Applicable' -Target $displayName -Readback $readback ` + -Detail "Read-back of the deployed app returned displayName='$($verify.displayName)', updateChannel='$($verify.updateChannel)'." +} +catch { + if ([string] $_.Exception.Message -eq 'Microsoft 365 Apps readback did not match the configured display name and update channel.') { + throw + } + Add-IntuneRunLogEntry -Module $module -Action 'Readback' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Target $displayName -Readback 'NotAttempted' ` + -Detail "Read-back could not be completed: $($_.Exception.Message)" + throw +} diff --git a/Products/Intune/Modules/Setup-AppProtectionPolicies.ps1 b/Products/Intune/Modules/Setup-AppProtectionPolicies.ps1 new file mode 100644 index 0000000..47efe57 --- /dev/null +++ b/Products/Intune/Modules/Setup-AppProtectionPolicies.ps1 @@ -0,0 +1,540 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + App protection policies for mobile application management (guide task 6). + +.DESCRIPTION + Reads Android and iOS/iPadOS managed app protection inventory, then creates + the Level 1 basic data-protection policy for core Microsoft apps from the + AppProtection section of IntuneConfig.psd1 (create policy, target the core + apps, assign to the pilot group or all licensed users). + + Standard risk: MAM protects app data and denies no device access. Idempotent + on the managed-by tag; an existing toolkit-managed policy is left + unchanged because the current writer is create-only. All writes are gated + by ShouldProcess and wrapped in the shared transient-retry boundary. +#> + +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +param( + [Parameter(Mandatory)] [hashtable] $Config, + [hashtable] $Context, + [switch] $AdoptExisting, + [string] $PilotGroupId +) + +$ErrorActionPreference = 'Stop' +$ConfirmPreference = 'None' + +. (Join-Path $PSScriptRoot 'IntuneRunLog.ps1') +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') +. (Join-Path $PSScriptRoot 'IntuneAssignmentScope.ps1') +. (Join-Path $PSScriptRoot 'IntuneGraphClient.ps1') + +function Get-IntuneObjectProperty { + [CmdletBinding()] + param( + [AllowNull()] $InputObject, + [Parameter(Mandatory)] [string] $Name + ) + + # Assign through a variable rather than an inline `if`. A hashtable member + # assigned from an `if` block that yields an empty collection collapses to + # $null, which would make an empty but valid Graph collection look like a + # missing property and fail closed on a legitimately empty tenant. + $exists = $false + $value = $null + + if ($null -eq $InputObject) { + return [pscustomobject] @{ Exists = $exists; Value = $value } + } + + if ($InputObject -is [System.Collections.IDictionary]) { + $exists = $InputObject.Contains($Name) + if ($exists) { $value = $InputObject[$Name] } + return [pscustomobject] @{ Exists = $exists; Value = $value } + } + + $property = $InputObject.PSObject.Properties[$Name] + $exists = $null -ne $property + if ($exists) { $value = $property.Value } + return [pscustomobject] @{ Exists = $exists; Value = $value } +} + +function New-IntuneSafeGraphException { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $EvidenceTarget, + [Parameter(Mandatory)] $ErrorRecord + ) + + $status = Get-IntuneHttpStatusCode -ErrorRecord $ErrorRecord + $exception = [Exception]::new("Microsoft Graph request failed for $EvidenceTarget.") + if ($status) { + $exception | Add-Member -NotePropertyName Response ` + -NotePropertyValue ([pscustomobject] @{ StatusCode = $status }) + } + return $exception +} + +function Get-IntuneGraphCollection { + [CmdletBinding()] + [OutputType([object[]])] + param( + [Parameter(Mandatory)] [string] $InitialUri, + [Parameter(Mandatory)] [string] $GraphBaseUri, + [Parameter(Mandatory)] [string] $EvidenceTarget, + [int[]] $ExpectedStatusCodes = @() + ) + + $base = [uri] $GraphBaseUri + $expectedPathPrefix = $base.AbsolutePath.TrimEnd('/') + '/' + $visited = [System.Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase + ) + $results = [System.Collections.Generic.List[object]]::new() + $nextUri = $InitialUri + + while (-not [string]::IsNullOrWhiteSpace($nextUri)) { + if (-not $visited.Add($nextUri)) { + throw "Microsoft Graph returned a pagination cycle for $EvidenceTarget." + } + + $parsedNext = $null + if (-not [uri]::TryCreate($nextUri, [UriKind]::Absolute, [ref] $parsedNext) -or + $parsedNext.Scheme -ne 'https' -or + -not [string]::Equals($parsedNext.Host, $base.Host, [StringComparison]::OrdinalIgnoreCase)) { + throw "Microsoft Graph returned a next link outside the approved Microsoft Graph host for $EvidenceTarget." + } + if (-not $parsedNext.AbsolutePath.StartsWith( + $expectedPathPrefix, + [StringComparison]::OrdinalIgnoreCase + )) { + throw "Microsoft Graph returned a next link outside the configured Graph API path for $EvidenceTarget." + } + + $response = Invoke-WithTransientRetry ` + -Description "Get $EvidenceTarget" ` + -ExpectedStatusCodes $ExpectedStatusCodes ` + -Action { + try { + Invoke-IntuneGraphRequest -Method 'GET' -Uri $nextUri ` + -EvidenceTarget $EvidenceTarget ` + -ExpectedStatusCodes $ExpectedStatusCodes ` + -DeferFailureEvidence + } + catch { + throw (New-IntuneSafeGraphException ` + -EvidenceTarget $EvidenceTarget -ErrorRecord $_) + } + } + + $valueProperty = Get-IntuneObjectProperty -InputObject $response -Name 'value' + if (-not $valueProperty.Exists -or $null -eq $valueProperty.Value) { + throw "Microsoft Graph returned no value collection for $EvidenceTarget." + } + foreach ($item in @($valueProperty.Value)) { + if ($null -eq $item) { + throw "Microsoft Graph returned a null collection item for $EvidenceTarget." + } + $results.Add($item) + } + + $nextLinkProperty = Get-IntuneObjectProperty ` + -InputObject $response -Name '@odata.nextLink' + $nextUri = if ($nextLinkProperty.Exists) { + [string] $nextLinkProperty.Value + } + else { + $null + } + } + + return @($results) +} + +function Get-IntuneAssignmentScope { + <# + Thin wrapper over the shared classifier in IntuneAssignmentScope.ps1. + This module does not model exclusions, so an exclusion target makes the + whole collection Unknown rather than being counted. That keeps the + assessment from understating how broadly a policy is targeted. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [AllowEmptyCollection()] [object[]] $Assignments, + [string] $PilotGroupId + ) + + return (Get-IntuneAssignmentScopeResult ` + -Assignments $Assignments ` + -PilotGroupId $PilotGroupId ` + -ExclusionBehavior 'Unknown' ` + -MissingTargetMessage 'Microsoft Graph returned an assignment without a target.').Scope +} + +function Assert-IntuneManagedAppCollection { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [ValidateSet('Android', 'iOS')] [string] $Platform, + [AllowEmptyCollection()] [object[]] $Apps + ) + + $identifierField = if ($Platform -eq 'Android') { 'packageId' } else { 'bundleId' } + foreach ($app in @($Apps)) { + $identifierProperty = Get-IntuneObjectProperty ` + -InputObject $app -Name 'mobileAppIdentifier' + if (-not $identifierProperty.Exists -or $null -eq $identifierProperty.Value) { + throw "Microsoft Graph returned a malformed $Platform managed app identifier." + } + $valueProperty = Get-IntuneObjectProperty ` + -InputObject $identifierProperty.Value -Name $identifierField + if (-not $valueProperty.Exists -or + [string]::IsNullOrWhiteSpace([string] $valueProperty.Value)) { + throw "Microsoft Graph returned a malformed $Platform managed app identifier." + } + } +} + +function Get-IntuneAppProtectionAssessmentState { + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)] [ValidateSet('Android', 'iOS')] [string] $Platform, + [Parameter(Mandatory)] [string] $GraphBaseUri, + [string] $PilotGroupId, + [Parameter(Mandatory)] [string] $ManagedByTag, + [Parameter(Mandatory)] [string] $ExpectedDisplayName + ) + + $resourceName = if ($Platform -eq 'Android') { + 'androidManagedAppProtections' + } + else { + 'iosManagedAppProtections' + } + $collectionUri = Resolve-IntuneGraphUri -BaseUri $GraphBaseUri ` + -RelativePath "deviceAppManagement/$resourceName" + $policies = @(Get-IntuneGraphCollection -InitialUri $collectionUri ` + -GraphBaseUri $GraphBaseUri -EvidenceTarget "$Platform app protection policies") + + $assignments = [System.Collections.Generic.List[object]]::new() + $toolkitManagedPolicyCount = 0 + $managedSameNamePolicyCount = 0 + $unmanagedSameNamePolicyCount = 0 + $targetAppReadback = if ($policies.Count -eq 0) { 'NotApplicable' } else { 'Available' } + + foreach ($policy in $policies) { + $idProperty = Get-IntuneObjectProperty -InputObject $policy -Name 'id' + $descriptionProperty = Get-IntuneObjectProperty ` + -InputObject $policy -Name 'description' + $displayNameProperty = Get-IntuneObjectProperty ` + -InputObject $policy -Name 'displayName' + $policyId = [string] $idProperty.Value + if (-not $idProperty.Exists -or + [string]::IsNullOrWhiteSpace($policyId)) { + throw "Microsoft Graph returned a malformed $Platform app protection policy ID." + } + $encodedPolicyId = [uri]::EscapeDataString($policyId) + if (-not $descriptionProperty.Exists -or + ($null -ne $descriptionProperty.Value -and + $descriptionProperty.Value -isnot [string])) { + throw "Microsoft Graph returned a malformed $Platform app protection policy description." + } + $description = [string] $descriptionProperty.Value + if ($displayNameProperty.Exists -and + $null -ne $displayNameProperty.Value -and + $displayNameProperty.Value -isnot [string]) { + throw "Microsoft Graph returned a malformed $Platform app protection policy display name." + } + $displayName = [string] $displayNameProperty.Value + $isManaged = -not [string]::IsNullOrWhiteSpace($description) -and + $description.Contains($ManagedByTag, [StringComparison]::Ordinal) + if ($isManaged) { + $toolkitManagedPolicyCount++ + } + if ([string]::Equals( + $displayName, + $ExpectedDisplayName, + [StringComparison]::OrdinalIgnoreCase + )) { + if ($isManaged) { + $managedSameNamePolicyCount++ + } + else { + $unmanagedSameNamePolicyCount++ + } + } + + $assignmentUri = Resolve-IntuneGraphUri -BaseUri $GraphBaseUri ` + -RelativePath "deviceAppManagement/$resourceName/$encodedPolicyId/assignments" + foreach ($assignment in @(Get-IntuneGraphCollection ` + -InitialUri $assignmentUri -GraphBaseUri $GraphBaseUri ` + -EvidenceTarget "$Platform app protection assignments")) { + $assignments.Add($assignment) + } + + $appsUri = Resolve-IntuneGraphUri -BaseUri $GraphBaseUri ` + -RelativePath "deviceAppManagement/$resourceName/$encodedPolicyId/apps" + try { + $apps = @(Get-IntuneGraphCollection -InitialUri $appsUri ` + -GraphBaseUri $GraphBaseUri ` + -EvidenceTarget "$Platform app protection targeted apps" ` + -ExpectedStatusCodes @(404, 405, 501)) + Assert-IntuneManagedAppCollection -Platform $Platform -Apps $apps + } + catch { + $status = Get-IntuneHttpStatusCode -ErrorRecord $_ + if ($status -in @(404, 405, 501)) { + $targetAppReadback = 'Unavailable' + } + else { + throw + } + } + } + + return [pscustomobject] @{ + Platform = $Platform + PolicyCount = $policies.Count + ToolkitManagedPolicyCount = $toolkitManagedPolicyCount + ManagedSameNamePolicyCount = $managedSameNamePolicyCount + UnmanagedSameNamePolicyCount = $unmanagedSameNamePolicyCount + AssignmentScope = Get-IntuneAssignmentScope ` + -Assignments @($assignments) -PilotGroupId $PilotGroupId + TargetAppReadback = $targetAppReadback + } +} + +function Add-IntuneAppProtectionFailure { + param( + [Parameter(Mandatory)] [ValidateSet('Android', 'iOS')] [string] $Platform, + [Parameter(Mandatory)] $ErrorRecord + ) + + $status = Get-IntuneHttpStatusCode -ErrorRecord $ErrorRecord + # The reason is constructed by this module and names only the platform and + # the evidence target, never a policy, group, app, or tenant identifier, so + # it is safe to record and keeps a failed run diagnosable. + $detail = "Platform=$Platform; app protection assessment failed. Reason=$($ErrorRecord.Exception.Message)" + if ($status) { $detail += " HttpStatusCode=$status." } + Add-IntuneRunLogEntry -Module 'Setup-AppProtectionPolicies' ` + -Action 'Assessment' -BestPracticeKey 'app-protection-policies' ` + -Status 'Failed' -HttpStatusCode $status -Detail $detail +} + +$bestPracticeKey = 'app-protection-policies' +$platforms = @('Android', 'iOS') + +if ($Context -and @($Context.BlockedItemKeys) -contains $bestPracticeKey) { + foreach ($platform in $platforms) { + Add-IntuneRunLogEntry -Module 'Setup-AppProtectionPolicies' ` + -Action 'Assessment' -BestPracticeKey $bestPracticeKey ` + -Status 'Skipped' -Disposition 'Skipped' ` + -Detail "Platform=$platform; skipped because preflight or operator gating blocked app-protection-policies." + } + return +} + +try { + if (-not $Context) { + throw 'Setup-AppProtectionPolicies.ps1 requires -Context from a pre-authenticated Graph connection. Run it through Deploy-IntuneBestPractice.ps1 or connect to Microsoft Graph yourself and supply -Context.' + } + if ([string]::IsNullOrWhiteSpace([string] $Context.TenantAdminUpn)) { + throw 'Setup-AppProtectionPolicies.ps1 requires Context.TenantAdminUpn for Graph authentication.' + } +} +catch { + foreach ($platform in $platforms) { + Add-IntuneAppProtectionFailure -Platform $platform -ErrorRecord $_ + } + throw +} + +$failures = [System.Collections.Generic.List[string]]::new() +$states = @{} +foreach ($platform in $platforms) { + try { + $expectedDisplayName = if ($platform -eq 'iOS') { + [string] $Config.AppProtection.Ios.DisplayName + } + else { + [string] $Config.AppProtection.Android.DisplayName + } + $state = Get-IntuneAppProtectionAssessmentState ` + -Platform $platform ` + -GraphBaseUri $Config.Api.GraphBaseUri ` + -PilotGroupId $PilotGroupId ` + -ManagedByTag $Config.ManagedByTag ` + -ExpectedDisplayName $expectedDisplayName + $states[$platform] = $state + $detail = 'Platform={0}; PolicyCount={1}; ToolkitManagedPolicyCount={2}; AssignmentScope={3}; TargetAppReadback={4}.' -f ` + $state.Platform, + $state.PolicyCount, + $state.ToolkitManagedPolicyCount, + $state.AssignmentScope, + $state.TargetAppReadback + Add-IntuneRunLogEntry -Module 'Setup-AppProtectionPolicies' ` + -Action 'Assessment' -BestPracticeKey $bestPracticeKey ` + -Status 'Succeeded' -Disposition 'GuidedOnly' -Detail $detail + } + catch { + Add-IntuneAppProtectionFailure -Platform $platform -ErrorRecord $_ + $failures.Add($platform) + } +} + +if ($failures.Count -gt 0) { + throw "App protection assessment failed for: $($failures -join ', ')." +} + +if ([string] $Context.AssignmentScope -eq 'TenantWide' -and + $Context.RollbackAcknowledged -ne $true) { + $reason = 'Tenant-wide app protection assignment requires Context.RollbackAcknowledged from the verified run context.' + Add-IntuneRunLogEntry -Module 'Setup-AppProtectionPolicies' ` + -Action 'Deploy' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'NotAttempted' ` + -Detail $reason + throw $reason +} + +# --------------------------------------------------------------------------- +# Write phase: create the Level 1 policy per platform, target the core apps, +# and assign. Idempotent on the managed-by tag. +# --------------------------------------------------------------------------- +$beta = $Config.Api.GraphBetaBaseUri +$pilotGroup = if (-not [string]::IsNullOrWhiteSpace([string] $Context.PilotGroupId)) { $Context.PilotGroupId } else { $PilotGroupId } +$assignmentTarget = $null +$assignmentDetail = $null +if ($Context.AssignmentScope -eq 'TenantWide') { + $assignmentTarget = @{ '@odata.type' = '#microsoft.graph.allLicensedUsersAssignmentTarget' } + $assignmentDetail = 'all licensed users' +} +elseif (-not [string]::IsNullOrWhiteSpace([string] $pilotGroup)) { + $assignmentTarget = @{ '@odata.type' = '#microsoft.graph.groupAssignmentTarget'; groupId = $pilotGroup } + $assignmentDetail = "pilot group $pilotGroup" +} + +foreach ($platform in $platforms) { + $def = if ($platform -eq 'iOS') { $Config.AppProtection.Ios } else { $Config.AppProtection.Android } + $resourceName = if ($platform -eq 'iOS') { 'iosManagedAppProtections' } else { 'androidManagedAppProtections' } + $state = $states[$platform] + $displayName = $def.DisplayName + + if ($state -and $state.UnmanagedSameNamePolicyCount -gt 0) { + $reason = "A $platform app protection policy named '$displayName' already exists but is not managed by the toolkit. Rename it or choose a different configured name; the create-only writer will not adopt or overwrite it." + Add-IntuneRunLogEntry -Module 'Setup-AppProtectionPolicies' ` + -Action 'Deploy' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Target $displayName ` + -Readback 'NotAttempted' -Detail $reason + throw $reason + } + + if ($state -and $state.ToolkitManagedPolicyCount -gt 1) { + $reason = "Multiple toolkit-managed $platform app protection policies exist. Resolve the duplicate ownership collision before rerunning." + Add-IntuneRunLogEntry -Module 'Setup-AppProtectionPolicies' ` + -Action 'Deploy' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Target $displayName ` + -Readback 'NotAttempted' -Detail $reason + throw $reason + } + + if ($state -and $state.ToolkitManagedPolicyCount -eq 1) { + Add-IntuneRunLogEntry -Module 'Setup-AppProtectionPolicies' ` + -Action 'Deploy' -BestPracticeKey $bestPracticeKey ` + -Status 'Skipped' -Disposition 'GuidedOnly' -Target $displayName -Readback 'NotAttempted' ` + -Detail "Platform=$platform; a toolkit-owned app protection policy already exists; leaving it unchanged. This inventory does not verify its settings, targeted apps, or exact assignment. Review them in Intune; the create-only writer does not refresh or rename existing policies." + continue + } + + if (-not $PSCmdlet.ShouldProcess("$platform app protection ($displayName)", 'Create policy, target core apps, and assign')) { + Add-IntuneRunLogEntry -Module 'Setup-AppProtectionPolicies' ` + -Action 'Deploy' -BestPracticeKey $bestPracticeKey ` + -Status 'Info' -Disposition 'WillChange' -Target $displayName -Readback 'NotAttempted' ` + -Detail "WhatIf: would create the $platform baseline app protection policy, target $(@($def.Apps).Count) core apps, and assign to $([string]::IsNullOrWhiteSpace($assignmentDetail) ? 'no target (none provided)' : $assignmentDetail)." + continue + } + + $body = [ordered]@{ + '@odata.type' = $def.OdataType + displayName = $displayName + description = $Config.ManagedByTag + } + foreach ($key in $def.Settings.Keys) { $body[$key] = $def.Settings[$key] } + $bodyJson = $body | ConvertTo-Json -Depth 20 + + $created = $null + try { + $created = Invoke-WithTransientRetry -Description "Create $platform app protection policy" -Action { + Invoke-MgGraphRequest -Method POST -Uri "$beta/deviceAppManagement/$resourceName" -Body $bodyJson -ContentType 'application/json' + } + Add-IntuneRunLogEntry -Module 'Setup-AppProtectionPolicies' ` + -Action 'Create' -BestPracticeKey $bestPracticeKey ` + -Status 'Created' -Disposition 'Applicable' -Target $displayName ` + -Detail "Created $platform baseline app protection policy." + } + catch { + Add-IntuneRunLogEntry -Module 'Setup-AppProtectionPolicies' ` + -Action 'Create' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Applicable' -Target $displayName ` + -HttpStatusCode (Get-IntuneHttpStatusCode -ErrorRecord $_) ` + -Detail "Failed to create $platform app protection policy: $($_.Exception.Message)" + throw + } + + $createdId = [string] $created.id + if ([string]::IsNullOrWhiteSpace($createdId)) { + throw "Microsoft Graph returned no ID for the created $platform app protection policy." + } + $encodedCreatedId = [uri]::EscapeDataString($createdId) + + $apps = @($def.Apps | ForEach-Object { + @{ mobileAppIdentifier = @{ '@odata.type' = $def.AppIdentifierType; $def.AppIdentifierKey = $_ } } + }) + $targetJson = @{ apps = $apps } | ConvertTo-Json -Depth 10 + try { + Invoke-WithTransientRetry -Description "Target apps for $platform app protection policy" -Action { + Invoke-MgGraphRequest -Method POST -Uri "$beta/deviceAppManagement/$resourceName('$encodedCreatedId')/targetApps" -Body $targetJson -ContentType 'application/json' | Out-Null + } + Add-IntuneRunLogEntry -Module 'Setup-AppProtectionPolicies' ` + -Action 'TargetApps' -BestPracticeKey $bestPracticeKey ` + -Status 'Succeeded' -Disposition 'Applicable' -Target $displayName ` + -Detail "Targeted $(@($def.Apps).Count) core Microsoft apps." + } + catch { + Add-IntuneRunLogEntry -Module 'Setup-AppProtectionPolicies' ` + -Action 'TargetApps' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Applicable' -Target $displayName ` + -HttpStatusCode (Get-IntuneHttpStatusCode -ErrorRecord $_) ` + -Detail "Created the policy but failed to target apps: $($_.Exception.Message)" + throw + } + + if ($assignmentTarget) { + $assignJson = @{ assignments = @(@{ target = $assignmentTarget }) } | ConvertTo-Json -Depth 10 + try { + Invoke-WithTransientRetry -Description "Assign $platform app protection policy" -Action { + Invoke-MgGraphRequest -Method POST -Uri "$beta/deviceAppManagement/$resourceName('$encodedCreatedId')/assign" -Body $assignJson -ContentType 'application/json' | Out-Null + } + Add-IntuneRunLogEntry -Module 'Setup-AppProtectionPolicies' ` + -Action 'Assign' -BestPracticeKey $bestPracticeKey ` + -Status 'Succeeded' -Disposition 'Applicable' -Target $displayName ` + -Detail "Assigned to $assignmentDetail." + } + catch { + Add-IntuneRunLogEntry -Module 'Setup-AppProtectionPolicies' ` + -Action 'Assign' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Applicable' -Target $displayName ` + -HttpStatusCode (Get-IntuneHttpStatusCode -ErrorRecord $_) ` + -Detail "Created and targeted the policy but failed to assign it: $($_.Exception.Message)" + throw + } + } + else { + Add-IntuneRunLogEntry -Module 'Setup-AppProtectionPolicies' ` + -Action 'Assign' -BestPracticeKey $bestPracticeKey ` + -Status 'Info' -Disposition 'GuidedOnly' -Target $displayName ` + -Detail "No assignment target was available; the $platform policy was created and targeted but not assigned. Supply -PilotGroupId or -AssignTenantWide to assign it." + } +} diff --git a/Products/Intune/Modules/Setup-ComplianceBaseline.ps1 b/Products/Intune/Modules/Setup-ComplianceBaseline.ps1 new file mode 100644 index 0000000..1e111b7 --- /dev/null +++ b/Products/Intune/Modules/Setup-ComplianceBaseline.ps1 @@ -0,0 +1,206 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Default device compliance setting (guide task 4). + +.DESCRIPTION + Assesses and, when authorized, enables the tenant-wide default compliance + setting (secureByDefault) that treats a device with no targeted compliance + policy as not compliant. Creating per-platform compliance policies is a + separate module (Setup-DeviceCompliancePolicies). + + The setting is inert on its own and becomes an access denial once a + device-based Conditional Access policy exists. The write is therefore High + risk and only runs when the orchestrator has cleared the item (IncludeHighRisk + + EnableComplianceEnforcement); otherwise the module assesses and reports + without changing tenant state. All changes are gated by ShouldProcess, + wrapped in the shared transient-retry boundary, and read back. +#> + +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +param( + [Parameter(Mandatory)] [hashtable] $Config, + [hashtable] $Context, + [switch] $AdoptExisting, + # High-risk authorization. The orchestrator also records the item in + # Context.WriteBlockedItemKeys when these are absent; both are honored. + [switch] $IncludeHighRisk, + [switch] $EnableComplianceEnforcement +) + +$ErrorActionPreference = 'Stop' +$ConfirmPreference = 'None' + +. (Join-Path $PSScriptRoot 'IntuneRunLog.ps1') +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') + +function Get-IntuneDefaultComplianceState { + [CmdletBinding()] + [OutputType([pscustomobject])] + param() + + if (-not (Get-Command -Name 'Get-MgDeviceManagement' -ErrorAction SilentlyContinue)) { + throw 'Get-MgDeviceManagement is unavailable. Install or import Microsoft.Graph.DeviceManagement, then rerun the default compliance assessment.' + } + + $deviceManagement = Invoke-WithTransientRetry ` + -Description 'Get default compliance setting' ` + -Action { + try { + Get-MgDeviceManagement -Property 'Settings' -ErrorAction Stop + } + catch { + $status = Get-IntuneHttpStatusCode -ErrorRecord $_ + $exception = [Exception]::new( + 'Microsoft Graph request failed for the default compliance setting.' + ) + if ($status) { + $exception | Add-Member -NotePropertyName Response ` + -NotePropertyValue ([pscustomobject] @{ StatusCode = $status }) + } + throw $exception + } + } + + if ($null -eq $deviceManagement) { + throw 'Get-MgDeviceManagement returned no deviceManagement response; default compliance state is unknown.' + } + + $settingsProperty = $deviceManagement.PSObject.Properties['Settings'] + if (-not $settingsProperty -or $null -eq $settingsProperty.Value) { + return [pscustomobject]@{ + SecureByDefault = $null + ProjectionUnavailable = $true + ProjectionDetail = 'Microsoft Graph returned no Settings object for default compliance.' + } + } + + $secureByDefaultProperty = $settingsProperty.Value.PSObject.Properties['SecureByDefault'] + if (-not $secureByDefaultProperty) { + return [pscustomobject]@{ + SecureByDefault = $null + ProjectionUnavailable = $true + ProjectionDetail = 'Microsoft Graph returned no SecureByDefault value for default compliance.' + } + } + if ($null -eq $secureByDefaultProperty.Value) { + return [pscustomobject]@{ + SecureByDefault = $null + ProjectionUnavailable = $true + ProjectionDetail = 'Microsoft Graph returned a nullable SecureByDefault value, so the effective default compliance state could not be confirmed.' + } + } + if ($secureByDefaultProperty.Value -isnot [bool]) { + throw 'Get-MgDeviceManagement returned a SecureByDefault value that is not a real Boolean; default compliance state is unknown.' + } + + return [pscustomobject]@{ + SecureByDefault = [bool] $secureByDefaultProperty.Value + ProjectionUnavailable = $false + ProjectionDetail = $null + } +} + +$bestPracticeKey = 'default-compliance-settings' + +if ($Context -and @($Context.BlockedItemKeys) -contains $bestPracticeKey) { + Add-IntuneRunLogEntry -Module 'Setup-ComplianceBaseline' ` + -Action 'Assessment' -BestPracticeKey $bestPracticeKey ` + -Status 'Skipped' -Disposition 'Skipped' ` + -Detail 'Skipped because preflight or operator gating blocked default-compliance-settings.' + return +} + +try { + if (-not $Context) { + throw 'Setup-ComplianceBaseline.ps1 requires -Context from a pre-authenticated Graph connection. Run it through Deploy-IntuneBestPractice.ps1 (the orchestrator), or connect to Microsoft Graph yourself and supply -Context.' + } + + if ([string]::IsNullOrWhiteSpace([string] $Context.TenantAdminUpn)) { + throw 'Setup-ComplianceBaseline.ps1 requires Context.TenantAdminUpn for Graph authentication.' + } + + $state = Get-IntuneDefaultComplianceState + + if ($state.SecureByDefault) { + Add-IntuneRunLogEntry -Module 'Setup-ComplianceBaseline' ` + -Action 'Assessment' -BestPracticeKey $bestPracticeKey ` + -Status 'Skipped' -Disposition 'AlreadyCompliant' -Readback 'Verified' ` + -Detail 'Default compliance already treats devices without a targeted compliance policy as noncompliant. SecureByDefault=True.' + return + } + + if ($state.ProjectionUnavailable) { + Add-IntuneRunLogEntry -Module 'Setup-ComplianceBaseline' ` + -Action 'Assessment' -BestPracticeKey $bestPracticeKey ` + -Status 'Succeeded' -Disposition 'GuidedOnly' -Readback 'NotAttempted' ` + -Detail "$($state.ProjectionDetail) Verify the Compliance policy settings page in the Intune admin center. No tenant write was attempted because the current state is unknown." + return + } + + # SecureByDefault is False (drift). Only write when the orchestrator cleared + # the item and both high-risk switches are present; otherwise assess only. + $writeWithheld = (@($Context.WriteBlockedItemKeys) -contains $bestPracticeKey) -or + -not $IncludeHighRisk -or + -not $EnableComplianceEnforcement -or + -not [bool] $Context.IncludeHighRisk + if ($writeWithheld) { + Add-IntuneRunLogEntry -Module 'Setup-ComplianceBaseline' ` + -Action 'Assessment' -BestPracticeKey $bestPracticeKey ` + -Status 'Succeeded' -Disposition 'GuidedOnly' ` + -Detail 'Assessment only: the default compliance setting remains unchanged. Enabling secure-by-default is withheld until IncludeHighRisk and EnableComplianceEnforcement are supplied; it marks devices without a targeted compliance policy noncompliant and can deny access when compliant-device Conditional Access is enforced. SecureByDefault=False.' + return + } + + $checkin = [int] $Config.DefaultCompliance.CheckinThresholdDays + if (-not $PSCmdlet.ShouldProcess('Default compliance settings', "Set secureByDefault=true, check-in threshold=$checkin days")) { + Add-IntuneRunLogEntry -Module 'Setup-ComplianceBaseline' ` + -Action 'DefaultCompliance' -BestPracticeKey $bestPracticeKey ` + -Status 'Info' -Disposition 'WillChange' -Readback 'NotAttempted' ` + -Detail "WhatIf: would enable secure-by-default (checkin threshold $checkin days). This marks devices without a targeted compliance policy noncompliant." + return + } + + Invoke-WithTransientRetry -Description 'Enable secure-by-default compliance' -Action { + Update-MgDeviceManagement -Settings @{ + secureByDefault = $true + deviceComplianceCheckinThresholdDays = $checkin + isScheduledActionEnabled = $true + } -Confirm:$false -ErrorAction Stop | Out-Null + } + Add-IntuneRunLogEntry -Module 'Setup-ComplianceBaseline' ` + -Action 'DefaultCompliance' -BestPracticeKey $bestPracticeKey ` + -Status 'Updated' -Disposition 'Applicable' ` + -Detail "Enabled secure-by-default; devices without a targeted compliance policy are now treated as noncompliant (check-in threshold $checkin days)." + + $after = Get-IntuneDefaultComplianceState + if ($after.ProjectionUnavailable) { + $reason = "$($after.ProjectionDetail) The update request completed, but Graph did not provide a verifiable readback. Verify the Compliance policy settings page in the Intune admin center before rerunning or continuing deployment." + Add-IntuneRunLogEntry -Module 'Setup-ComplianceBaseline' ` + -Action 'Readback' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' ` + -Detail $reason + throw $reason + } + $readback = if ($after.SecureByDefault) { 'Verified' } else { 'Mismatch' } + if ($readback -ne 'Verified') { + $reason = 'Default compliance readback did not confirm SecureByDefault=True.' + Add-IntuneRunLogEntry -Module 'Setup-ComplianceBaseline' ` + -Action 'Readback' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback $readback ` + -Detail $reason + throw $reason + } + Add-IntuneRunLogEntry -Module 'Setup-ComplianceBaseline' ` + -Action 'Readback' -BestPracticeKey $bestPracticeKey ` + -Status 'Info' -Disposition 'Applicable' -Readback $readback ` + -Detail "Read-back returned SecureByDefault=$($after.SecureByDefault)." +} +catch { + $status = Get-IntuneHttpStatusCode -ErrorRecord $_ + Add-IntuneRunLogEntry -Module 'Setup-ComplianceBaseline' ` + -Action 'Assessment' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -HttpStatusCode $status ` + -Detail $_.Exception.Message + throw +} diff --git a/Products/Intune/Modules/Setup-DeviceCompliancePolicies.ps1 b/Products/Intune/Modules/Setup-DeviceCompliancePolicies.ps1 new file mode 100644 index 0000000..d8129ee --- /dev/null +++ b/Products/Intune/Modules/Setup-DeviceCompliancePolicies.ps1 @@ -0,0 +1,526 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Per-platform device compliance policy assessment and deployment (guide task 7). + +.DESCRIPTION + Inventories existing Microsoft Graph v1.0 compliance policies, then creates + the per-platform baseline compliance policies (Windows incl. Secure Boot and + code integrity, iOS, Android work profile) from the payloads in + DeviceCompliance.PayloadDirectory and assigns them to the pilot group or all + licensed users. + + High risk: a new compliance policy can mark existing devices noncompliant and + deny access where compliant-device Conditional Access is enforced. The write + only runs when the orchestrator has cleared the item (IncludeHighRisk); + otherwise the module assesses and reports without changing tenant state. + Idempotent on display name; every change is gated by ShouldProcess, wrapped + in the shared transient-retry boundary, and read back. +#> + +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +param( + [Parameter(Mandatory)] [hashtable] $Config, + [hashtable] $Context, + [switch] $AdoptExisting, + [switch] $IncludeHighRisk +) + +$ErrorActionPreference = 'Stop' +$ConfirmPreference = 'None' + +. (Join-Path $PSScriptRoot 'IntuneRunLog.ps1') +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') +. (Join-Path $PSScriptRoot 'Get-IntunePolicyCatalog.ps1') +. (Join-Path $PSScriptRoot 'IntuneGraphClient.ps1') +. (Join-Path $PSScriptRoot 'IntunePolicyReadback.ps1') + +function Get-ComplianceProperty { + param([AllowNull()] $InputObject, [Parameter(Mandatory)] [string] $Name) + + return Get-IntuneReadbackProperty -InputObject $InputObject -Name $Name +} + +function Test-ComplianceScheduledActionsMatch { + param( + [AllowNull()] $ExpectedActions, + [Parameter(Mandatory)] [string] $PolicyId, + [Parameter(Mandatory)] [hashtable] $Config + ) + + if ($null -eq $ExpectedActions) { return $true } + $actionUri = Resolve-IntuneGraphUri -BaseUri $Config.Api.GraphBaseUri ` + -RelativePath "deviceManagement/deviceCompliancePolicies/$PolicyId/scheduledActionsForRule" + $actions = @(Get-ComplianceCollection -InitialUri $actionUri ` + -GraphBaseUri $Config.Api.GraphBaseUri ` + -EvidenceTarget 'device compliance scheduled action readback') + $actualActions = @( + foreach ($action in $actions) { + $id = (Get-ComplianceProperty $action 'id').Value + $rule = Get-ComplianceProperty $action 'ruleName' + if ($id -isnot [string] -or [string]::IsNullOrWhiteSpace($id) -or + -not $rule.Exists -or $rule.Value -isnot [string]) { + throw 'Microsoft Graph returned a malformed compliance scheduled action.' + } + $encodedId = [uri]::EscapeDataString($id) + $children = @(Get-ComplianceCollection -InitialUri "$actionUri/$encodedId/scheduledActionConfigurations" ` + -GraphBaseUri $Config.Api.GraphBaseUri ` + -EvidenceTarget 'device compliance scheduled action configurations') + @{ + ruleName = $rule.Value + scheduledActionConfigurations = $children + } + } + ) + return Test-IntuneManagedValue -Expected @($ExpectedActions) -Actual $actualActions ` + -PropertyName 'scheduledActionsForRule' +} + +function Assert-ComplianceReadback { + param( + [Parameter(Mandatory)] $Payload, + [Parameter(Mandatory)] [string] $PolicyId, + [Parameter(Mandatory)] [hashtable] $ExpectedTarget, + [Parameter(Mandatory)] [hashtable] $Config, + [switch] $Existing + ) + + $readback = 'NotAttempted' + try { + $encodedId = [uri]::EscapeDataString($PolicyId) + $uri = Resolve-IntuneGraphUri -BaseUri $Config.Api.GraphBaseUri ` + -RelativePath "deviceManagement/deviceCompliancePolicies/$encodedId" + $actual = Invoke-WithTransientRetry -Description 'Read compliance policy managed fields' -Action { + Invoke-IntuneGraphRequest -Method GET -Uri $uri ` + -EvidenceTarget 'device compliance policy readback' -DeferFailureEvidence + } + $expected = $Payload | ConvertTo-Json -Depth 30 | ConvertFrom-Json -AsHashtable + $expectedActions = $expected['scheduledActionsForRule'] + $null = $expected.Remove('scheduledActionsForRule') + $fieldsMatch = Test-IntuneManagedValue $expected $actual + $actionsMatch = Test-ComplianceScheduledActionsMatch -ExpectedActions $expectedActions ` + -PolicyId $encodedId -Config $Config + $assignments = @(Get-ComplianceCollection -InitialUri "$uri/assignments" ` + -GraphBaseUri $Config.Api.GraphBaseUri -EvidenceTarget 'device compliance policy assignment readback') + $assignmentMatch = Test-IntuneExactAssignment $ExpectedTarget $assignments + $readback = 'Mismatch' + if (-not ($fieldsMatch -and $actionsMatch -and $assignmentMatch)) { + throw "ManagedFieldsMatch=$fieldsMatch; ScheduledActionsMatch=$actionsMatch; ExactAssignmentMatch=$assignmentMatch." + } + Add-IntuneRunLogEntry -Module 'Setup-DeviceCompliancePolicies' -Action 'Readback' ` + -BestPracticeKey 'device-compliance-policies' -Target $Payload.displayName ` + -Status $(if ($Existing) { 'Skipped' } else { 'Info' }) ` + -Disposition $(if ($Existing) { 'AlreadyCompliant' } else { 'Applicable' }) ` + -Readback 'Verified' -Detail 'Managed payload fields, scheduled action children, and exact assignment verified. No existing settings were changed.' + } + catch { + Add-IntuneRunLogEntry -Module 'Setup-DeviceCompliancePolicies' -Action 'Readback' ` + -BestPracticeKey 'device-compliance-policies' -Target $Payload.displayName ` + -Status 'Failed' -Disposition 'Blocked' -Readback $readback ` + -HttpStatusCode (Get-IntuneHttpStatusCode -ErrorRecord $_) ` + -Detail "Verification failed. The create-only writer will not repair existing policies. Review settings, scheduled actions, and assignments in Intune before rerunning. Reason=$($_.Exception.Message)" + throw + } +} + +function Get-ComplianceCollection { + [OutputType([object[]])] + param( + [Parameter(Mandatory)] [string] $InitialUri, + [Parameter(Mandatory)] [string] $GraphBaseUri, + [Parameter(Mandatory)] [string] $EvidenceTarget, + [int[]] $ExpectedStatusCodes = @() + ) + + $base = [uri] $GraphBaseUri + $pathPrefix = $base.AbsolutePath.TrimEnd('/') + '/' + $visited = [System.Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase + ) + $items = [System.Collections.Generic.List[object]]::new() + $nextUri = $InitialUri + + while (-not [string]::IsNullOrWhiteSpace($nextUri)) { + if (-not $visited.Add($nextUri)) { + throw "Microsoft Graph returned a pagination cycle for $EvidenceTarget." + } + $parsed = $null + if (-not [uri]::TryCreate($nextUri, [UriKind]::Absolute, [ref] $parsed) -or + $parsed.Scheme -ne 'https' -or -not $parsed.IsDefaultPort -or + $parsed.UserInfo -ne '' -or $parsed.Fragment -ne '' -or + -not [string]::Equals($parsed.Host, $base.Host, [StringComparison]::OrdinalIgnoreCase) -or + $parsed.AbsolutePath -ne ([uri] $InitialUri).AbsolutePath -or + -not $parsed.AbsolutePath.StartsWith($pathPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "Microsoft Graph returned an unsafe next link for $EvidenceTarget." + } + + $response = Invoke-WithTransientRetry -Description "Get $EvidenceTarget" ` + -ExpectedStatusCodes $ExpectedStatusCodes -Action { + try { + Invoke-IntuneGraphRequest -Method 'GET' -Uri $nextUri ` + -EvidenceTarget $EvidenceTarget ` + -ExpectedStatusCodes $ExpectedStatusCodes ` + -DeferFailureEvidence + } + catch { + $status = Get-IntuneHttpStatusCode -ErrorRecord $_ + $safe = [Exception]::new("Microsoft Graph request failed for $EvidenceTarget.") + if ($status) { + $safe | Add-Member -NotePropertyName Response ` + -NotePropertyValue ([pscustomobject] @{ StatusCode = $status }) + } + throw $safe + } + } + $value = Get-ComplianceProperty -InputObject $response -Name 'value' + if (-not $value.Exists -or $value.Value -isnot [System.Collections.IList]) { + throw "Microsoft Graph returned no value collection for $EvidenceTarget." + } + foreach ($item in @($value.Value)) { + if ($null -eq $item) { + throw "Microsoft Graph returned a null item for $EvidenceTarget." + } + $items.Add($item) + } + $next = Get-ComplianceProperty -InputObject $response -Name '@odata.nextLink' + if ($next.Exists -and $null -ne $next.Value -and $next.Value -isnot [string]) { + throw "Microsoft Graph returned a malformed next link for $EvidenceTarget." + } + $nextUri = if ($next.Exists) { [string] $next.Value } else { $null } + } + return @($items) +} + +$bestPracticeKey = 'device-compliance-policies' +if ($Context -and @($Context.BlockedItemKeys) -contains $bestPracticeKey) { + Add-IntuneRunLogEntry -Module 'Setup-DeviceCompliancePolicies' ` + -Action 'Assessment' -BestPracticeKey $bestPracticeKey ` + -Status 'Skipped' -Disposition 'Skipped' ` + -Detail 'Skipped because preflight or operator gating blocked device-compliance-policies.' + return +} + +try { + if (-not $Context -or + [string]::IsNullOrWhiteSpace([string] $Context.TenantAdminUpn)) { + throw 'Setup-DeviceCompliancePolicies.ps1 requires Context.TenantAdminUpn from a pre-authenticated Graph connection.' + } + + $collectionUri = Resolve-IntuneGraphUri -BaseUri $Config.Api.GraphBaseUri ` + -RelativePath 'deviceManagement/deviceCompliancePolicies' + $policies = @(Get-ComplianceCollection -InitialUri $collectionUri ` + -GraphBaseUri $Config.Api.GraphBaseUri ` + -EvidenceTarget 'device compliance policies') + $catalogStatus = 'Available' + try { + $catalog = @(Get-IntunePolicyCatalog -Config $Config) + } + catch { + $catalog = @() + $catalogStatus = 'Unavailable' + Add-IntuneRunLogEntry -Module 'Setup-DeviceCompliancePolicies' ` + -Action 'CatalogAssessment' -BestPracticeKey $bestPracticeKey ` + -Status 'Info' ` + -Detail 'WARNING: Candidate policy catalog validation failed. Live tenant compliance inventory will continue without catalog counts.' + } + $complianceCandidates = @($catalog | Where-Object Kind -eq 'Compliance') + $baselineCandidates = @( + $complianceCandidates | Where-Object Tier -eq 'Baseline' + ) + $advancedCandidates = @( + $complianceCandidates | Where-Object Tier -eq 'Advanced' + ) + $windowsCandidates = @( + $complianceCandidates | Where-Object Tier -eq 'Windows' + ) + + $platformCounts = [ordered] @{ + AndroidDeviceAdministrator = 0 + AndroidWorkProfile = 0 + iOS = 0 + macOS = 0 + Windows = 0 + GuidedBeta = 0 + Unknown = 0 + } + $assignmentCount = 0 + $scheduledActionCount = 0 + $scheduledActionReadback = 'Available' + + foreach ($policy in $policies) { + $id = Get-ComplianceProperty -InputObject $policy -Name 'id' + $type = Get-ComplianceProperty -InputObject $policy -Name '@odata.type' + $parsedId = [guid]::Empty + if (-not $id.Exists -or + -not [guid]::TryParse([string] $id.Value, [ref] $parsedId) -or + [string]::IsNullOrWhiteSpace([string] $type.Value)) { + throw 'Microsoft Graph returned a malformed device compliance policy identity.' + } + + switch -Regex ([string] $type.Value) { + 'androidCompliancePolicy$' { $platformCounts.AndroidDeviceAdministrator++; break } + 'androidWorkProfileCompliancePolicy$' { $platformCounts.AndroidWorkProfile++; break } + 'iosCompliancePolicy$' { $platformCounts.iOS++; break } + 'macOSCompliancePolicy$' { $platformCounts.macOS++; break } + 'windows10CompliancePolicy$' { $platformCounts.Windows++; break } + '(androidDeviceOwner|aospDeviceOwner)CompliancePolicy$' { + $platformCounts.GuidedBeta++ + break + } + default { $platformCounts.Unknown++ } + } + + $assignmentUri = Resolve-IntuneGraphUri -BaseUri $Config.Api.GraphBaseUri ` + -RelativePath "deviceManagement/deviceCompliancePolicies/$($id.Value)/assignments" + $assignmentCount += @(Get-ComplianceCollection -InitialUri $assignmentUri ` + -GraphBaseUri $Config.Api.GraphBaseUri ` + -EvidenceTarget 'device compliance policy assignments').Count + + if ($scheduledActionReadback -eq 'Available') { + $actionsUri = Resolve-IntuneGraphUri -BaseUri $Config.Api.GraphBaseUri ` + -RelativePath "deviceManagement/deviceCompliancePolicies/$($id.Value)/scheduledActionsForRule" + try { + $scheduledActionCount += @(Get-ComplianceCollection -InitialUri $actionsUri ` + -GraphBaseUri $Config.Api.GraphBaseUri ` + -EvidenceTarget 'device compliance scheduled actions' ` + -ExpectedStatusCodes @(400)).Count + } + catch { + $status = Get-IntuneHttpStatusCode -ErrorRecord $_ + if ($status -ne 400) { + throw + } + $scheduledActionReadback = 'Unavailable' + Add-IntuneRunLogEntry -Module 'Setup-DeviceCompliancePolicies' ` + -Action 'ScheduledActionReadback' ` + -BestPracticeKey $bestPracticeKey -Status 'Info' ` + -HttpStatusCode $status ` + -Detail 'Microsoft Graph v1.0 returned 400 because no GET route matched the documented scheduledActionsForRule relationship. Policy and assignment inventory will continue without a scheduled-action count.' + } + } + } + + $scheduledActionCountText = if ($scheduledActionReadback -eq 'Available') { + [string] $scheduledActionCount + } + else { + 'Unavailable' + } + $detail = 'PolicyCount={0}; AndroidDeviceAdministrator={1}; AndroidWorkProfile={2}; iOS={3}; macOS={4}; Windows={5}; GuidedBeta={6}; Unknown={7}; AssignmentCount={8}; ScheduledActionCount={9}; ScheduledActionReadback={10}; CatalogComplianceCandidates={11}; CatalogBaselineCandidates={12}; CatalogAdvancedCandidates={13}; CatalogWindowsCandidates={14}; CatalogStatus={15}; CatalogApplyStatus=Blocked; Assessment=InventoryOnly.' -f ` + $policies.Count, + $platformCounts.AndroidDeviceAdministrator, + $platformCounts.AndroidWorkProfile, + $platformCounts.iOS, + $platformCounts.macOS, + $platformCounts.Windows, + $platformCounts.GuidedBeta, + $platformCounts.Unknown, + $assignmentCount, + $scheduledActionCountText, + $scheduledActionReadback, + $complianceCandidates.Count, + $baselineCandidates.Count, + $advancedCandidates.Count, + $windowsCandidates.Count, + $catalogStatus + Add-IntuneRunLogEntry -Module 'Setup-DeviceCompliancePolicies' ` + -Action 'Assessment' -BestPracticeKey $bestPracticeKey ` + -Status 'Succeeded' -Disposition 'GuidedOnly' -Detail $detail + + # ---- Write phase ---- + if (@($Context.WriteBlockedItemKeys) -contains $bestPracticeKey -or + -not $IncludeHighRisk -or + -not [bool] $Context.IncludeHighRisk) { + Add-IntuneRunLogEntry -Module 'Setup-DeviceCompliancePolicies' ` + -Action 'CompliancePolicies' -BestPracticeKey $bestPracticeKey ` + -Status 'Succeeded' -Disposition 'GuidedOnly' ` + -Detail 'Assessment only: creating compliance policies is withheld until IncludeHighRisk is supplied. A new compliance policy can mark devices noncompliant and deny access under compliant-device Conditional Access.' + return + } + + if ([string] $Context.AssignmentScope -eq 'TenantWide') { + if ($Context.RollbackAcknowledged -ne $true) { + $reason = 'Tenant-wide device compliance assignment requires Context.RollbackAcknowledged from the verified run context.' + Add-IntuneRunLogEntry -Module 'Setup-DeviceCompliancePolicies' ` + -Action 'CompliancePolicies' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'NotAttempted' ` + -Detail $reason + throw $reason + } + if ($Config.Assignment.AllowTenantWideAssignmentForHighRisk -isnot [bool] -or + -not $Config.Assignment.AllowTenantWideAssignmentForHighRisk) { + $reason = 'Tenant-wide device compliance assignment is disabled by Assignment.AllowTenantWideAssignmentForHighRisk in configuration.' + Add-IntuneRunLogEntry -Module 'Setup-DeviceCompliancePolicies' ` + -Action 'CompliancePolicies' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'NotAttempted' ` + -Detail $reason + throw $reason + } + } + elseif ([string] $Context.AssignmentScope -ne 'PilotGroup' -or + [string]::IsNullOrWhiteSpace([string] $Context.PilotGroupId)) { + $reason = 'Device compliance policy creation requires a verified PilotGroup assignment scope and Context.PilotGroupId unless tenant-wide assignment was explicitly authorized.' + Add-IntuneRunLogEntry -Module 'Setup-DeviceCompliancePolicies' ` + -Action 'CompliancePolicies' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'NotAttempted' ` + -Detail $reason + throw $reason + } + + $productRoot = Split-Path -Parent $PSScriptRoot + $payloadDir = Join-Path $productRoot $Config.DeviceCompliance.PayloadDirectory + $payloads = @( + Get-ChildItem -LiteralPath $payloadDir -Filter *.json -File | + Sort-Object Name | + ForEach-Object { Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json } + ) + $expectedNames = @($payloads | ForEach-Object displayName) + $tag = $Config.ManagedByTag + $existingNames = @{} + foreach ($p in $policies) { + $dn = Get-ComplianceProperty -InputObject $p -Name 'displayName' + if ($dn.Exists -and $dn.Value) { + $description = Get-ComplianceProperty -InputObject $p -Name 'description' + $isManaged = $description.Exists -and + $description.Value -is [string] -and + ([string] $description.Value).Contains($tag, [StringComparison]::Ordinal) + $name = [string] $dn.Value + if ($isManaged -and $name -notin $expectedNames) { + $reason = 'A toolkit-managed compliance policy has an unexpected name. Reconcile its recorded object ID and configured name before creating more policies; renamed managed objects are not duplicated.' + Add-IntuneRunLogEntry -Module 'Setup-DeviceCompliancePolicies' ` + -Action 'Collision' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'NotAttempted' ` + -Detail $reason + throw $reason + } + if (-not $existingNames.ContainsKey($name)) { + $existingNames[$name] = [pscustomobject] @{ + ManagedCount = 0 + UnmanagedCount = 0 + PolicyId = [string] (Get-ComplianceProperty $p 'id').Value + } + } + if ($isManaged) { + $existingNames[$name].ManagedCount++ + } + else { + $existingNames[$name].UnmanagedCount++ + } + } + } + + $assignmentTarget = $null + $assignmentDetail = $null + if ($Context.AssignmentScope -eq 'TenantWide') { + $assignmentTarget = @{ '@odata.type' = '#microsoft.graph.allLicensedUsersAssignmentTarget' } + $assignmentDetail = 'all licensed users' + } + elseif (-not [string]::IsNullOrWhiteSpace([string] $Context.PilotGroupId)) { + $assignmentTarget = @{ '@odata.type' = '#microsoft.graph.groupAssignmentTarget'; groupId = $Context.PilotGroupId } + $assignmentDetail = "pilot group $($Context.PilotGroupId)" + } + + $createUri = Resolve-IntuneGraphUri -BaseUri $Config.Api.GraphBaseUri ` + -RelativePath 'deviceManagement/deviceCompliancePolicies' + + foreach ($payload in $payloads) { + $dn = [string] $payload.displayName + + if ($existingNames.ContainsKey($dn) -and + $existingNames[$dn].UnmanagedCount -gt 0) { + $reason = "A compliance policy named '$dn' already exists but is not managed by the toolkit. Rename it or choose a different payload name; the create-only writer will not adopt or overwrite it." + Add-IntuneRunLogEntry -Module 'Setup-DeviceCompliancePolicies' ` + -Action 'Create' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Target $dn ` + -Readback 'NotAttempted' -Detail $reason + throw $reason + } + + if ($payload.PSObject.Properties.Name -contains 'description') { + $payload.description = ("$($payload.description) $tag").Trim() + } + else { + $payload | Add-Member -NotePropertyName description -NotePropertyValue $tag -Force + } + + if ($existingNames.ContainsKey($dn)) { + if ($existingNames[$dn].ManagedCount -gt 1) { + Add-IntuneRunLogEntry -Module 'Setup-DeviceCompliancePolicies' ` + -Action 'Collision' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'NotAttempted' ` + -Detail 'Multiple managed compliance policies have the configured name. Reconcile duplicates before rerunning.' + throw 'Duplicate managed compliance policies block the create-only writer.' + } + Assert-ComplianceReadback -Payload $payload -PolicyId $existingNames[$dn].PolicyId ` + -ExpectedTarget $assignmentTarget -Config $Config -Existing + continue + } + + if (-not $PSCmdlet.ShouldProcess($dn, 'Create device compliance policy and assign')) { + Add-IntuneRunLogEntry -Module 'Setup-DeviceCompliancePolicies' ` + -Action 'Create' -BestPracticeKey $bestPracticeKey ` + -Status 'Info' -Disposition 'WillChange' -Target $dn -Readback 'NotAttempted' ` + -Detail "WhatIf: would create compliance policy '$dn' and assign to $([string]::IsNullOrWhiteSpace($assignmentDetail) ? 'no target (none provided)' : $assignmentDetail)." + continue + } + + $created = $null + try { + $created = Invoke-WithTransientRetry -Description "Create compliance policy '$dn'" -Action { + Invoke-MgGraphRequest -Method POST -Uri $createUri -Body ($payload | ConvertTo-Json -Depth 20) -ContentType 'application/json' + } + Add-IntuneRunLogEntry -Module 'Setup-DeviceCompliancePolicies' ` + -Action 'Create' -BestPracticeKey $bestPracticeKey ` + -Status 'Created' -Disposition 'Applicable' -Target $dn ` + -Detail "Created device compliance policy '$dn'." + } + catch { + Add-IntuneRunLogEntry -Module 'Setup-DeviceCompliancePolicies' ` + -Action 'Create' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Applicable' -Target $dn ` + -HttpStatusCode (Get-IntuneHttpStatusCode -ErrorRecord $_) ` + -Detail "Failed to create compliance policy '$dn': $($_.Exception.Message)" + throw + } + + $createdId = [string] $created.id + if ([string]::IsNullOrWhiteSpace($createdId)) { + throw "Microsoft Graph returned no ID for the created compliance policy '$dn'." + } + $encodedCreatedId = [uri]::EscapeDataString($createdId) + + if ($assignmentTarget) { + $assignUri = Resolve-IntuneGraphUri -BaseUri $Config.Api.GraphBaseUri ` + -RelativePath "deviceManagement/deviceCompliancePolicies/$encodedCreatedId/assign" + $assignBody = @{ assignments = @(@{ target = $assignmentTarget }) } | ConvertTo-Json -Depth 10 + try { + Invoke-WithTransientRetry -Description "Assign compliance policy '$dn'" -Action { + Invoke-MgGraphRequest -Method POST -Uri $assignUri -Body $assignBody -ContentType 'application/json' | Out-Null + } + Add-IntuneRunLogEntry -Module 'Setup-DeviceCompliancePolicies' ` + -Action 'Assign' -BestPracticeKey $bestPracticeKey ` + -Status 'Succeeded' -Disposition 'Applicable' -Target $dn ` + -Detail "Assigned '$dn' to $assignmentDetail." + } + catch { + Add-IntuneRunLogEntry -Module 'Setup-DeviceCompliancePolicies' ` + -Action 'Assign' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Applicable' -Target $dn ` + -HttpStatusCode (Get-IntuneHttpStatusCode -ErrorRecord $_) ` + -Detail "Created '$dn' but failed to assign it: $($_.Exception.Message)" + throw + } + } + + Assert-ComplianceReadback -Payload $payload -PolicyId $createdId ` + -ExpectedTarget $assignmentTarget -Config $Config + } +} +catch { + $status = Get-IntuneHttpStatusCode -ErrorRecord $_ + Add-IntuneRunLogEntry -Module 'Setup-DeviceCompliancePolicies' ` + -Action 'Assessment' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -HttpStatusCode $status ` + -Detail "Device compliance policy assessment failed. Reason=$($_.Exception.Message)" + throw +} diff --git a/Products/Intune/Modules/Setup-DeviceConditionalAccess.ps1 b/Products/Intune/Modules/Setup-DeviceConditionalAccess.ps1 new file mode 100644 index 0000000..06e5cfd --- /dev/null +++ b/Products/Intune/Modules/Setup-DeviceConditionalAccess.ps1 @@ -0,0 +1,592 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Device-based Conditional Access for Intune enrollment (guide task 10). + +.DESCRIPTION + Creates the Conditional Access policy that requires multifactor + authentication and a compliant device for the Microsoft Intune Enrollment + application, and ensures that application's service principal exists first + (newer tenants do not create it automatically). + + This is the highest blast-radius action in the baseline. The orchestrator + only lets this module run after IncludeHighRisk, EnableConditionalAccessEnforcement, + BreakGlassExclusionsConfirmed, and RollbackAcknowledged are all supplied; + otherwise the item is blocked upstream and this module returns immediately. + The policy is created in the state named by ConditionalAccess.DefaultState, + which is restricted to report-only in the current release so a first run + cannot deny access. The operator-supplied emergency-access user and group + IDs are written into the exclusion list. Promoting the policy to enabled is + a deliberate portal change. Equivalent same-name policies are idempotent; + collisions are blocked. All changes are gated by ShouldProcess and wrapped + in the shared retry. +#> + +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +param( + [Parameter(Mandatory)] [hashtable] $Config, + [hashtable] $Context, + [switch] $IncludeHighRisk, + [switch] $EnableConditionalAccessEnforcement, + [switch] $BreakGlassExclusionsConfirmed, + [switch] $RollbackAcknowledged +) + +$ErrorActionPreference = 'Stop' +$ConfirmPreference = 'None' + +. (Join-Path $PSScriptRoot 'IntuneRunLog.ps1') +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') + +function Get-IntuneCaProperty { + param([AllowNull()] $InputObject, [Parameter(Mandatory)] [string] $Name) + + if ($null -eq $InputObject) { return $null } + if ($InputObject -is [System.Collections.IDictionary]) { + return $InputObject[$Name] + } + $property = $InputObject.PSObject.Properties[$Name] + if ($null -eq $property) { return $null } + return $property.Value +} + +function Get-IntuneCaPropertyResult { + param([AllowNull()] $InputObject, [Parameter(Mandatory)] [string] $Name) + + if ($null -eq $InputObject) { + return [pscustomobject] @{ Exists = $false; Value = $null } + } + if ($InputObject -is [System.Collections.IDictionary]) { + $exists = $InputObject.Contains($Name) + $value = $null + if ($exists) { + $value = $InputObject[$Name] + if ($value -is [array] -and $value.Count -eq 0) { + $value = [object[]]::new(0) + } + } + return [pscustomobject] @{ + Exists = $exists + Value = $value + } + } + $property = $InputObject.PSObject.Properties[$Name] + $value = $null + if ($null -ne $property) { + $value = $property.Value + if ($value -is [array] -and $value.Count -eq 0) { + $value = [object[]]::new(0) + } + } + return [pscustomobject] @{ + Exists = $null -ne $property + Value = $value + } +} + +function Test-IntuneCaStringSetEqual { + param([object[]] $Actual, [string[]] $Expected) + + $actualStrings = @($Actual | ForEach-Object { [string] $_ } | Sort-Object -Unique) + $expectedStrings = @($Expected | ForEach-Object { [string] $_ } | Sort-Object -Unique) + if ($actualStrings.Count -ne $expectedStrings.Count) { return $false } + foreach ($value in $expectedStrings) { + if ($value -notin $actualStrings) { return $false } + } + return $true +} + +function Test-IntuneCaOnlyAllowedProperties { + param( + [AllowNull()] $InputObject, + [Parameter(Mandatory)] [string[]] $AllowedNames + ) + + if ($null -eq $InputObject) { return $false } + $names = if ($InputObject -is [System.Collections.IDictionary]) { + @($InputObject.Keys | ForEach-Object { [string] $_ }) + } + else { + @($InputObject.PSObject.Properties.Name) + } + + foreach ($name in $names) { + if ($name -in $AllowedNames) { continue } + $property = Get-IntuneCaPropertyResult $InputObject $name + if (-not $property.Exists -or $null -eq $property.Value) { continue } + if ($property.Value -is [string] -and + [string]::IsNullOrWhiteSpace([string] $property.Value)) { + continue + } + if ($property.Value -is [System.Collections.IEnumerable] -and + $property.Value -isnot [string] -and + @($property.Value).Count -eq 0) { + continue + } + return $false + } + return $true +} + +function Get-IntuneConditionalAccessPolicies { + param([Parameter(Mandatory)] [string] $GraphBaseUri) + + $base = [uri] $GraphBaseUri + $expectedPathPrefix = $base.AbsolutePath.TrimEnd('/') + '/' + $visited = [System.Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase + ) + $policies = [System.Collections.Generic.List[object]]::new() + $nextUri = "$GraphBaseUri/identity/conditionalAccess/policies" + + while (-not [string]::IsNullOrWhiteSpace($nextUri)) { + if (-not $visited.Add($nextUri)) { + throw 'Microsoft Graph returned a pagination cycle for Conditional Access policies.' + } + $parsedNext = $null + if (-not [uri]::TryCreate($nextUri, [UriKind]::Absolute, [ref] $parsedNext) -or + $parsedNext.Scheme -ne 'https' -or + -not [string]::Equals( + $parsedNext.Host, + $base.Host, + [StringComparison]::OrdinalIgnoreCase + ) -or + -not $parsedNext.AbsolutePath.StartsWith( + $expectedPathPrefix, + [StringComparison]::OrdinalIgnoreCase + )) { + throw 'Microsoft Graph returned a Conditional Access next link outside the configured Graph API path.' + } + + $response = Invoke-WithTransientRetry -Description 'Read existing Conditional Access policies' -Action { + Invoke-MgGraphRequest -Method GET -Uri $nextUri + } + $value = Get-IntuneCaPropertyResult $response 'value' + if (-not $value.Exists -or $null -eq $value.Value) { + throw 'Microsoft Graph returned no Conditional Access policy collection.' + } + foreach ($policy in @($value.Value)) { + if ($null -eq $policy) { + throw 'Microsoft Graph returned a null Conditional Access policy.' + } + $policies.Add($policy) + } + $nextLink = Get-IntuneCaPropertyResult $response '@odata.nextLink' + $nextUri = if ($nextLink.Exists) { [string] $nextLink.Value } else { $null } + } + + return @($policies) +} + +function Get-IntuneEnrollmentServicePrincipals { + param( + [Parameter(Mandatory)] [string] $GraphBaseUri, + [Parameter(Mandatory)] [string] $AppId + ) + + $spResponse = Invoke-WithTransientRetry -Description 'Read Intune enrollment service principal' -Action { + Invoke-MgGraphRequest -Method GET -Uri "$GraphBaseUri/servicePrincipals?`$filter=appId eq '$AppId'" + } + $spCollection = Get-IntuneCaPropertyResult $spResponse 'value' + if (-not $spCollection.Exists -or $null -eq $spCollection.Value) { + throw 'Microsoft Graph returned no Intune enrollment service principal collection.' + } + foreach ($servicePrincipal in @($spCollection.Value)) { + if ($null -eq $servicePrincipal) { + throw 'Microsoft Graph returned a null Intune enrollment service principal.' + } + } + return @($spCollection.Value) +} + +function Wait-IntuneEnrollmentServicePrincipal { + param( + [Parameter(Mandatory)] [string] $GraphBaseUri, + [Parameter(Mandatory)] [string] $AppId + ) + + return @(Invoke-WithTransientRetry ` + -Description 'Verify Intune enrollment service principal propagation' ` + -MaxAttempts 5 ` + -AdditionalTransientStatusCodes @(404) ` + -Action { + $principals = @(Get-IntuneEnrollmentServicePrincipals ` + -GraphBaseUri $GraphBaseUri -AppId $AppId) + if ($principals.Count -eq 0) { + $exception = [Exception]::new( + 'Microsoft Graph has not projected the Intune enrollment service principal yet.' + ) + $exception | Add-Member -NotePropertyName Response ` + -NotePropertyValue ([pscustomobject] @{ StatusCode = 404 }) + throw $exception + } + return $principals + }) +} + +function Test-IntuneEnrollmentCaEquivalent { + param( + [Parameter(Mandatory)] $Policy, + [Parameter(Mandatory)] [string] $EnrollmentAppId, + [Parameter(Mandatory)] [string] $AssignmentScope, + [string] $PilotGroupId, + [string[]] $BreakGlassUserIds = @(), + [string[]] $BreakGlassGroupIds = @(), + [Parameter(Mandatory)] [string] $ExpectedState + ) + + if ([string] (Get-IntuneCaProperty $Policy 'state') -ne $ExpectedState) { + return $false + } + + $conditions = Get-IntuneCaProperty $Policy 'conditions' + if (-not (Test-IntuneCaOnlyAllowedProperties ` + -InputObject $conditions ` + -AllowedNames @('applications', 'users', 'clientAppTypes', '@odata.type'))) { + return $false + } + $applications = Get-IntuneCaProperty $conditions 'applications' + if (-not (Test-IntuneCaOnlyAllowedProperties ` + -InputObject $applications ` + -AllowedNames @('includeApplications', '@odata.type'))) { + return $false + } + $includeApplications = @() + $includeApplicationsValue = Get-IntuneCaProperty $applications 'includeApplications' + if ($null -ne $includeApplicationsValue) { + $includeApplications = @($includeApplicationsValue) + } + if ($includeApplications.Count -ne 1 -or + [string] $includeApplications[0] -ne $EnrollmentAppId) { + return $false + } + + $users = Get-IntuneCaProperty $conditions 'users' + if (-not (Test-IntuneCaOnlyAllowedProperties ` + -InputObject $users ` + -AllowedNames @( + 'includeUsers', + 'includeGroups', + 'excludeUsers', + 'excludeGroups', + '@odata.type' + ))) { + return $false + } + $includeUsersValue = Get-IntuneCaProperty $users 'includeUsers' + $includeGroupsValue = Get-IntuneCaProperty $users 'includeGroups' + $excludeUsersValue = Get-IntuneCaProperty $users 'excludeUsers' + $excludeGroupsValue = Get-IntuneCaProperty $users 'excludeGroups' + $includeUsers = @() + $includeGroups = @() + $excludeUsers = @() + $excludeGroups = @() + if ($null -ne $includeUsersValue) { $includeUsers = @($includeUsersValue) } + if ($null -ne $includeGroupsValue) { $includeGroups = @($includeGroupsValue) } + if ($null -ne $excludeUsersValue) { $excludeUsers = @($excludeUsersValue) } + if ($null -ne $excludeGroupsValue) { $excludeGroups = @($excludeGroupsValue) } + if ($AssignmentScope -eq 'TenantWide') { + if ($includeUsers.Count -ne 1 -or + [string] $includeUsers[0] -ne 'All' -or + $includeGroups.Count -ne 0) { + return $false + } + } + elseif ($includeUsers.Count -ne 0 -or + $includeGroups.Count -ne 1 -or + [string] $includeGroups[0] -ne $PilotGroupId) { + return $false + } + + if (-not (Test-IntuneCaStringSetEqual ` + -Actual $excludeUsers -Expected $BreakGlassUserIds) -or + -not (Test-IntuneCaStringSetEqual ` + -Actual $excludeGroups -Expected $BreakGlassGroupIds)) { + return $false + } + + $clientAppTypesValue = Get-IntuneCaProperty $conditions 'clientAppTypes' + $clientAppTypes = @() + if ($null -ne $clientAppTypesValue) { + $clientAppTypes = @($clientAppTypesValue) + } + if ($clientAppTypes.Count -ne 1 -or [string] $clientAppTypes[0] -ne 'all') { + return $false + } + + $grantControls = Get-IntuneCaProperty $Policy 'grantControls' + if (-not (Test-IntuneCaOnlyAllowedProperties ` + -InputObject $grantControls ` + -AllowedNames @('operator', 'builtInControls', '@odata.type'))) { + return $false + } + $builtInControlsValue = Get-IntuneCaProperty $grantControls 'builtInControls' + $builtInControls = @() + if ($null -ne $builtInControlsValue) { + $builtInControls = @($builtInControlsValue) + } + if ([string] (Get-IntuneCaProperty $grantControls 'operator') -ne 'AND' -or + $builtInControls.Count -ne 2 -or + 'mfa' -notin $builtInControls -or + 'compliantDevice' -notin $builtInControls) { + return $false + } + + $sessionControls = Get-IntuneCaProperty $Policy 'sessionControls' + if (-not (Test-IntuneCaOnlyAllowedProperties ` + -InputObject $sessionControls ` + -AllowedNames @('signInFrequency', '@odata.type'))) { + return $false + } + $signInFrequency = Get-IntuneCaProperty $sessionControls 'signInFrequency' + if (-not (Test-IntuneCaOnlyAllowedProperties ` + -InputObject $signInFrequency ` + -AllowedNames @( + 'isEnabled', + 'frequencyInterval', + 'authenticationType', + '@odata.type' + ))) { + return $false + } + return [bool] (Get-IntuneCaProperty $signInFrequency 'isEnabled') -and + [string] (Get-IntuneCaProperty $signInFrequency 'frequencyInterval') -eq 'everyTime' -and + [string] (Get-IntuneCaProperty $signInFrequency 'authenticationType') -eq + 'primaryAndSecondaryAuthentication' +} + +$bestPracticeKey = 'device-conditional-access' +$module = 'Setup-DeviceConditionalAccess' + +# The orchestrator blocks this item unless every high-risk gate was supplied. +if ($Context -and @($Context.BlockedItemKeys) -contains $bestPracticeKey) { + Add-IntuneRunLogEntry -Module $module -Action 'ConditionalAccess' -BestPracticeKey $bestPracticeKey ` + -Status 'Skipped' -Disposition 'Blocked' ` + -Detail "Conditional Access was withheld: policy creation requires IncludeHighRisk, EnableConditionalAccessEnforcement, BreakGlassExclusionsConfirmed, and RollbackAcknowledged. Configured default state is '$($Config.ConditionalAccess.DefaultState)'." + return +} + +try { + if (-not $Context -or [string]::IsNullOrWhiteSpace([string] $Context.TenantAdminUpn)) { + throw 'Setup-DeviceConditionalAccess.ps1 requires Context.TenantAdminUpn from a pre-authenticated Graph connection.' + } + if (-not $IncludeHighRisk -or + -not $EnableConditionalAccessEnforcement -or + -not $BreakGlassExclusionsConfirmed -or + -not $RollbackAcknowledged -or + -not [bool] $Context.IncludeHighRisk -or + -not [bool] $Context.RollbackAcknowledged) { + $reason = 'Conditional Access creation requires IncludeHighRisk, EnableConditionalAccessEnforcement, BreakGlassExclusionsConfirmed, and RollbackAcknowledged in both the module call and verified context.' + Add-IntuneRunLogEntry -Module $module -Action 'ConditionalAccess' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'NotAttempted' ` + -Detail $reason + throw $reason + } + if ([string] $Config.ConditionalAccess.DefaultState -ne + 'enabledForReportingButNotEnforced') { + $reason = 'ConditionalAccess.DefaultState must remain enabledForReportingButNotEnforced in the current release.' + Add-IntuneRunLogEntry -Module $module -Action 'ConditionalAccess' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'NotAttempted' ` + -Detail $reason + throw $reason + } + + $v1 = $Config.Api.GraphBaseUri + $appId = $Config.ConditionalAccess.EnrollmentAppId + $displayName = $Config.ConditionalAccess.DisplayName + $state = $Config.ConditionalAccess.DefaultState + $assignmentScope = [string] $Context.AssignmentScope + $breakGlassUserIds = @($Context.BreakGlassUserIds) + $breakGlassGroupIds = @($Context.BreakGlassGroupIds) + if ($assignmentScope -eq 'TenantWide') { + if ($Config.Assignment.AllowTenantWideAssignmentForHighRisk -isnot [bool] -or + -not $Config.Assignment.AllowTenantWideAssignmentForHighRisk) { + throw 'Tenant-wide Conditional Access assignment is disabled by Assignment.AllowTenantWideAssignmentForHighRisk in configuration.' + } + } + elseif ($assignmentScope -ne 'PilotGroup' -or + [string]::IsNullOrWhiteSpace([string] $Context.PilotGroupId)) { + throw 'Conditional Access creation requires Context.PilotGroupId unless tenant-wide assignment was explicitly approved.' + } + if ($breakGlassUserIds.Count -eq 0 -and $breakGlassGroupIds.Count -eq 0) { + throw 'Conditional Access creation requires at least one emergency-access user or group exclusion.' + } + + # Read the prerequisite state, but resolve policy collisions before making + # any tenant change. + $servicePrincipalExists = @(Get-IntuneEnrollmentServicePrincipals ` + -GraphBaseUri $v1 -AppId $appId).Count -gt 0 + + # Check name collisions and semantic equivalents, including renamed + # policies, before any prerequisite or policy write. + $existingPolicies = @(Get-IntuneConditionalAccessPolicies -GraphBaseUri $v1) + $sameNamePolicies = @( + $existingPolicies | + Where-Object { $_.displayName -eq $displayName } + ) + if ($sameNamePolicies.Count -gt 1) { + $reason = "Multiple Conditional Access policies named '$displayName' exist. Resolve the duplicate collision before rerunning." + Add-IntuneRunLogEntry -Module $module -Action 'ConditionalAccess' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Target $displayName ` + -Readback 'NotAttempted' -Detail $reason + throw $reason + } + $equivalentPolicyExists = $false + if ($sameNamePolicies.Count -eq 1) { + if (-not (Test-IntuneEnrollmentCaEquivalent ` + -Policy $sameNamePolicies[0] ` + -EnrollmentAppId $appId ` + -AssignmentScope $assignmentScope ` + -PilotGroupId ([string] $Context.PilotGroupId) ` + -BreakGlassUserIds $breakGlassUserIds ` + -BreakGlassGroupIds $breakGlassGroupIds ` + -ExpectedState $state)) { + $reason = "A Conditional Access policy named '$displayName' already exists but does not match the toolkit's report-only pilot definition. The create-only writer will not adopt or overwrite it." + Add-IntuneRunLogEntry -Module $module -Action 'ConditionalAccess' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Target $displayName ` + -Readback 'NotAttempted' -Detail $reason + throw $reason + } + $equivalentPolicyExists = $true + } + else { + $equivalentPolicyExists = @($existingPolicies | Where-Object { + Test-IntuneEnrollmentCaEquivalent ` + -Policy $_ ` + -EnrollmentAppId $appId ` + -AssignmentScope $assignmentScope ` + -PilotGroupId ([string] $Context.PilotGroupId) ` + -BreakGlassUserIds $breakGlassUserIds ` + -BreakGlassGroupIds $breakGlassGroupIds ` + -ExpectedState $state + }).Count -gt 0 + } + + # Ensure the Microsoft Intune Enrollment service principal exists only + # after a policy collision has been ruled out. + if (-not $servicePrincipalExists) { + if ($PSCmdlet.ShouldProcess("Intune Enrollment service principal ($appId)", 'Create service principal')) { + Invoke-WithTransientRetry -Description 'Create Intune enrollment service principal' -Action { + Invoke-MgGraphRequest -Method POST -Uri "$v1/servicePrincipals" ` + -Body (@{ appId = $appId } | ConvertTo-Json) -ContentType 'application/json' | Out-Null + } + Add-IntuneRunLogEntry -Module $module -Action 'ServicePrincipal' -BestPracticeKey $bestPracticeKey ` + -Status 'Created' -Disposition 'Applicable' ` + -Detail 'Created the Microsoft Intune Enrollment service principal.' + try { + $createdPrincipals = @(Wait-IntuneEnrollmentServicePrincipal ` + -GraphBaseUri $v1 -AppId $appId) + } + catch { + $reason = 'Created the Microsoft Intune Enrollment service principal, but Microsoft Graph did not return it after bounded propagation checks. Confirm the Enterprise application exists and rerun before creating Conditional Access.' + Add-IntuneRunLogEntry -Module $module -Action 'ServicePrincipal' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' ` + -Detail $reason + throw $reason + } + Add-IntuneRunLogEntry -Module $module -Action 'ServicePrincipal' -BestPracticeKey $bestPracticeKey ` + -Status 'Info' -Disposition 'Applicable' -Readback 'Verified' ` + -Detail 'Verified the Microsoft Intune Enrollment service principal after creation.' + } + else { + Add-IntuneRunLogEntry -Module $module -Action 'ServicePrincipal' -BestPracticeKey $bestPracticeKey ` + -Status 'Info' -Disposition 'WillChange' -Readback 'NotAttempted' ` + -Detail 'WhatIf: would create the Microsoft Intune Enrollment service principal.' + } + } + else { + Add-IntuneRunLogEntry -Module $module -Action 'ServicePrincipal' -BestPracticeKey $bestPracticeKey ` + -Status 'Skipped' -Disposition 'AlreadyCompliant' -Readback 'Verified' ` + -Detail 'The Microsoft Intune Enrollment service principal already exists.' + } + + if ($equivalentPolicyExists) { + Add-IntuneRunLogEntry -Module $module -Action 'ConditionalAccess' -BestPracticeKey $bestPracticeKey ` + -Status 'Skipped' -Disposition 'AlreadyCompliant' -Target $displayName -Readback 'Verified' ` + -Detail 'An equivalent report-only Conditional Access policy already exists; leaving its name and settings unchanged.' + return + } + + if (-not $PSCmdlet.ShouldProcess($displayName, "Create Conditional Access policy (state=$state)")) { + Add-IntuneRunLogEntry -Module $module -Action 'ConditionalAccess' -BestPracticeKey $bestPracticeKey ` + -Status 'Info' -Disposition 'WillChange' -Target $displayName -Readback 'NotAttempted' ` + -Detail "WhatIf: would create '$displayName' requiring MFA and a compliant device for app $appId, sign-in frequency every time, state=$state." + return + } + + $userConditions = if ($assignmentScope -eq 'TenantWide') { + @{ includeUsers = @('All') } + } + else { + @{ includeGroups = @([string] $Context.PilotGroupId) } + } + if ($breakGlassUserIds.Count -gt 0) { + $userConditions['excludeUsers'] = $breakGlassUserIds + } + if ($breakGlassGroupIds.Count -gt 0) { + $userConditions['excludeGroups'] = $breakGlassGroupIds + } + + $policyBody = @{ + displayName = $displayName + state = $state + conditions = @{ + applications = @{ includeApplications = @($appId) } + users = $userConditions + clientAppTypes = @('all') + } + grantControls = @{ + operator = 'AND' + builtInControls = @('mfa', 'compliantDevice') + } + sessionControls = @{ + signInFrequency = @{ + isEnabled = $true + frequencyInterval = 'everyTime' + authenticationType = 'primaryAndSecondaryAuthentication' + } + } + } | ConvertTo-Json -Depth 10 + + $created = Invoke-WithTransientRetry -Description "Create Conditional Access policy '$displayName'" -Action { + Invoke-MgGraphRequest -Method POST -Uri "$v1/identity/conditionalAccess/policies" ` + -Body $policyBody -ContentType 'application/json' + } + $createdId = [string] $created.id + if ([string]::IsNullOrWhiteSpace($createdId)) { + throw "Microsoft Graph returned no ID for the created Conditional Access policy '$displayName'." + } + $encodedCreatedId = [uri]::EscapeDataString($createdId) + Add-IntuneRunLogEntry -Module $module -Action 'ConditionalAccess' -BestPracticeKey $bestPracticeKey ` + -Status 'Created' -Disposition 'Applicable' -Target $displayName ` + -Detail "Created Conditional Access policy requiring MFA and a compliant device for Intune enrollment (state=$state) with the operator-supplied emergency-access exclusions. Verify the exclusions in the portal before promoting it to enabled." + + $verify = Invoke-WithTransientRetry -Description "Read back Conditional Access policy '$displayName'" -Action { + Invoke-MgGraphRequest -Method GET -Uri "$v1/identity/conditionalAccess/policies/$encodedCreatedId" + } -MaxAttempts 5 -AdditionalTransientStatusCodes @(404) + $readback = if ($verify -and (Test-IntuneEnrollmentCaEquivalent ` + -Policy $verify ` + -EnrollmentAppId $appId ` + -AssignmentScope $assignmentScope ` + -PilotGroupId ([string] $Context.PilotGroupId) ` + -BreakGlassUserIds $breakGlassUserIds ` + -BreakGlassGroupIds $breakGlassGroupIds ` + -ExpectedState $state)) { 'Verified' } else { 'Mismatch' } + if ($readback -ne 'Verified') { + $reason = 'Conditional Access readback did not match the configured report-only pilot definition.' + Add-IntuneRunLogEntry -Module $module -Action 'Readback' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Target $displayName -Readback $readback ` + -Detail $reason + throw $reason + } + Add-IntuneRunLogEntry -Module $module -Action 'Readback' -BestPracticeKey $bestPracticeKey ` + -Status 'Info' -Disposition 'Applicable' -Target $displayName -Readback $readback ` + -Detail "Read-back returned state='$($verify.state)'." +} +catch { + $status = Get-IntuneHttpStatusCode -ErrorRecord $_ + Add-IntuneRunLogEntry -Module $module -Action 'ConditionalAccess' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -HttpStatusCode $status ` + -Detail "Conditional Access configuration failed. Reason=$($_.Exception.Message)" + throw +} diff --git a/Products/Intune/Modules/Setup-EnrollmentPrerequisites.ps1 b/Products/Intune/Modules/Setup-EnrollmentPrerequisites.ps1 new file mode 100644 index 0000000..47ee29a --- /dev/null +++ b/Products/Intune/Modules/Setup-EnrollmentPrerequisites.ps1 @@ -0,0 +1,163 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Enrollment prerequisites for supported device platforms (guide tasks 1-3). + +.DESCRIPTION + Read-only until each operation's supported API, permissions, licensing, + readback, and recovery contract is verified. Covers Windows automatic MDM + enrollment, the Apple MDM push certificate, and the managed Google Play + connection. + + This module now performs a GET-only health assessment for the Apple MDM push + certificate. Managed Google Play remains guided-only because it requires a + human credential and an interactive browser flow with no unattended path. +#> + +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +# smb-quality-gate: read-only +param( + [Parameter(Mandatory)] [hashtable] $Config, + [hashtable] $Context, + [switch] $AdoptExisting +) + +. (Join-Path $PSScriptRoot 'IntuneRunLog.ps1') +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') +. (Join-Path $PSScriptRoot 'IntuneGraphClient.ps1') + +function ConvertTo-IntuneUtcDateTime { + [CmdletBinding()] + [OutputType([datetime])] + param( + [AllowNull()] [string] $Value, + [Parameter(Mandatory)] [string] $FieldName + ) + + if ([string]::IsNullOrWhiteSpace($Value)) { + throw "Microsoft Graph returned no $FieldName value; Apple MDM push certificate state is unknown." + } + + $parsed = [datetimeoffset]::MinValue + $styles = [System.Globalization.DateTimeStyles]::AssumeUniversal -bor ` + [System.Globalization.DateTimeStyles]::AdjustToUniversal + if (-not [datetimeoffset]::TryParse( + $Value, + [System.Globalization.CultureInfo]::InvariantCulture, + $styles, + [ref] $parsed + )) { + throw "Microsoft Graph returned a malformed $FieldName value; Apple MDM push certificate state is unknown." + } + + return $parsed.UtcDateTime +} + +function Get-IntuneApplePushCertificateState { + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)] [string] $GraphBaseUri + ) + + $uri = Resolve-IntuneGraphUri -BaseUri $GraphBaseUri ` + -RelativePath 'deviceManagement/applePushNotificationCertificate' + $response = Invoke-WithTransientRetry ` + -Description 'Get Apple MDM push certificate' ` + -ExpectedStatusCodes @(404) ` + -Action { + Invoke-IntuneGraphRequest -Method 'GET' -Uri $uri ` + -EvidenceTarget 'Apple MDM push certificate' ` + -ExpectedStatusCodes @(404) ` + -DeferFailureEvidence + } + + if ($null -eq $response) { + throw 'Microsoft Graph returned no Apple MDM push certificate response; certificate state is unknown.' + } + + $expirationUtc = ConvertTo-IntuneUtcDateTime ` + -Value ([string] $response.expirationDateTime) ` + -FieldName 'expirationDateTime' + $lastModifiedUtc = ConvertTo-IntuneUtcDateTime ` + -Value ([string] $response.lastModifiedDateTime) ` + -FieldName 'lastModifiedDateTime' + + return [pscustomobject]@{ + ExpirationDateTime = $expirationUtc + LastModifiedDateTime = $lastModifiedUtc + IsExpired = $expirationUtc -le [datetime]::UtcNow + } +} + +$bestPracticeKey = 'apple-push-certificate' + +if ($Context -and @($Context.BlockedItemKeys) -contains $bestPracticeKey) { + Add-IntuneRunLogEntry -Module 'Setup-EnrollmentPrerequisites' ` + -Action 'Assessment' -BestPracticeKey $bestPracticeKey ` + -Status 'Skipped' -Disposition 'Skipped' ` + -Detail 'Skipped because preflight or operator gating blocked apple-push-certificate.' + return +} + +try { + if (-not $Context) { + throw 'Setup-EnrollmentPrerequisites.ps1 requires -Context from a pre-authenticated Graph connection. Run it through Deploy-IntuneBestPractice.ps1 (the orchestrator), or connect to Microsoft Graph yourself and supply -Context.' + } + + if (-not $Config.ContainsKey('ApplePushCertificate') -or + -not $Config.ApplePushCertificate.ContainsKey('RenewalWarningDays') -or + $Config.ApplePushCertificate.RenewalWarningDays -isnot [int] -or + $Config.ApplePushCertificate.RenewalWarningDays -lt 1 -or + $Config.ApplePushCertificate.RenewalWarningDays -gt 90) { + throw 'ApplePushCertificate.RenewalWarningDays must be an integer from 1 through 90.' + } + + if ([string]::IsNullOrWhiteSpace([string] $Context.TenantAdminUpn)) { + throw 'Setup-EnrollmentPrerequisites.ps1 requires Context.TenantAdminUpn for Graph authentication.' + } + + $state = Get-IntuneApplePushCertificateState -GraphBaseUri $Config.Api.GraphBaseUri + $renewalWarningDays = [int] $Config.ApplePushCertificate.RenewalWarningDays + $renewalDue = -not $state.IsExpired -and + $state.ExpirationDateTime -le [datetime]::UtcNow.AddDays($renewalWarningDays) + $disposition = if ($state.IsExpired -or $renewalDue) { 'GuidedOnly' } else { 'AlreadyCompliant' } + $detail = if ($state.IsExpired) { + 'Apple MDM push certificate has expired. Renew it through the supported annual workflow in the Intune admin center and the Apple Push Certificates Portal. ExpirationUtc={0}; LastModifiedUtc={1}.' -f ` + $state.ExpirationDateTime.ToString('o'), + $state.LastModifiedDateTime.ToString('o') + } + elseif ($renewalDue) { + 'Apple MDM push certificate expires within the configured {0}-day renewal warning window. Renew it before expiration through the supported workflow in the Intune admin center and the Apple Push Certificates Portal. ExpirationUtc={1}; LastModifiedUtc={2}.' -f ` + $renewalWarningDays, + $state.ExpirationDateTime.ToString('o'), + $state.LastModifiedDateTime.ToString('o') + } + else { + 'Apple MDM push certificate is configured and unexpired. ExpirationUtc={0}; LastModifiedUtc={1}.' -f ` + $state.ExpirationDateTime.ToString('o'), + $state.LastModifiedDateTime.ToString('o') + } + + Add-IntuneRunLogEntry -Module 'Setup-EnrollmentPrerequisites' ` + -Action 'Assessment' -BestPracticeKey $bestPracticeKey ` + -Status 'Succeeded' ` + -Disposition $disposition ` + -Detail $detail +} +catch { + $status = Get-IntuneHttpStatusCode -ErrorRecord $_ + if ($status -eq 404) { + Add-IntuneRunLogEntry -Module 'Setup-EnrollmentPrerequisites' ` + -Action 'Assessment' -BestPracticeKey $bestPracticeKey ` + -Status 'Succeeded' -Disposition 'GuidedOnly' ` + -HttpStatusCode $status ` + -Detail 'Microsoft Graph returned 404 for the Apple MDM push certificate singleton. Microsoft does not document this response, so it does not prove whether a certificate is configured. Verify the state in the Intune admin center and complete the supported Apple certificate setup or renewal workflow.' + return + } + Add-IntuneRunLogEntry -Module 'Setup-EnrollmentPrerequisites' ` + -Action 'Assessment' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -HttpStatusCode $status ` + -Detail $_.Exception.Message + throw +} diff --git a/Products/Intune/Modules/Setup-EnrollmentRestrictions.ps1 b/Products/Intune/Modules/Setup-EnrollmentRestrictions.ps1 new file mode 100644 index 0000000..ae6f5e1 --- /dev/null +++ b/Products/Intune/Modules/Setup-EnrollmentRestrictions.ps1 @@ -0,0 +1,521 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Device platform enrollment restrictions (guide task 5). + +.DESCRIPTION + Inventories existing device enrollment platform restrictions, then creates + the restrictions defined in EnrollmentRestrictions.Restrictions and assigns + them. Safe by default: the shipped payload blocks only personally owned + enrollment on used platforms, so corporate enrollment continues to work. + + A misconfigured restriction can block the enrollment the rest of the + baseline depends on, so this is High risk: the write only runs when the + orchestrator has cleared the item (IncludeHighRisk + EnableEnrollmentRestrictions); + otherwise the module assesses and reports. Idempotent on display name; every + change is gated by ShouldProcess, wrapped in the shared transient-retry + boundary, and read back. +#> + +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +param( + [Parameter(Mandatory)] [hashtable] $Config, + [hashtable] $Context, + [switch] $AdoptExisting, + # High-risk authorization; the orchestrator also records the item in + # Context.WriteBlockedItemKeys when these are absent. + [switch] $IncludeHighRisk, + [switch] $EnableEnrollmentRestrictions +) + +$ErrorActionPreference = 'Stop' +$ConfirmPreference = 'None' + +. (Join-Path $PSScriptRoot 'IntuneRunLog.ps1') +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') +. (Join-Path $PSScriptRoot 'IntuneAssignmentScope.ps1') +. (Join-Path $PSScriptRoot 'IntuneGraphClient.ps1') +. (Join-Path $PSScriptRoot 'IntunePolicyReadback.ps1') + +function Get-IntuneEnrollmentProperty { + [CmdletBinding()] + param( + [AllowNull()] $InputObject, + [Parameter(Mandatory)] [string] $Name + ) + + return Get-IntuneReadbackProperty -InputObject $InputObject -Name $Name +} + +function New-IntuneEnrollmentSafeException { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $EvidenceTarget, + [Parameter(Mandatory)] $ErrorRecord + ) + + $status = Get-IntuneHttpStatusCode -ErrorRecord $ErrorRecord + $exception = [Exception]::new("Microsoft Graph request failed for $EvidenceTarget.") + if ($status) { + $exception | Add-Member -NotePropertyName Response ` + -NotePropertyValue ([pscustomobject] @{ StatusCode = $status }) + } + return $exception +} + +function Assert-IntuneEnrollmentReadback { + param( + [Parameter(Mandatory)] [hashtable] $Payload, + [Parameter(Mandatory)] [hashtable] $ExpectedTarget, + [Parameter(Mandatory)] [string] $ConfigurationId, + [Parameter(Mandatory)] [hashtable] $Config, + [switch] $Existing + ) + + $readback = 'NotAttempted' + try { + $encodedId = [uri]::EscapeDataString($ConfigurationId) + $uri = Resolve-IntuneGraphUri -BaseUri $Config.Api.GraphBetaBaseUri ` + -RelativePath "deviceManagement/deviceEnrollmentConfigurations/$encodedId" + $actual = Invoke-WithTransientRetry -Description 'Read enrollment restriction managed fields' -Action { + Invoke-IntuneGraphRequest -Method GET -Uri $uri ` + -EvidenceTarget 'enrollment restriction readback' -DeferFailureEvidence + } + $fieldsMatch = Test-IntuneManagedValue $Payload $actual + $assignments = @(Get-IntuneEnrollmentCollection -InitialUri "$uri/assignments" ` + -GraphBaseUri $Config.Api.GraphBetaBaseUri -EvidenceTarget 'enrollment restriction assignment readback') + $assignmentMatch = Test-IntuneExactAssignment $ExpectedTarget $assignments + $readback = 'Mismatch' + if (-not ($fieldsMatch -and $assignmentMatch)) { + throw "ManagedFieldsMatch=$fieldsMatch; ExactAssignmentMatch=$assignmentMatch." + } + Add-IntuneRunLogEntry -Module 'Setup-EnrollmentRestrictions' -Action 'Readback' ` + -BestPracticeKey 'enrollment-restrictions' -Target $Payload.displayName ` + -Status $(if ($Existing) { 'Skipped' } else { 'Info' }) ` + -Disposition $(if ($Existing) { 'AlreadyCompliant' } else { 'Applicable' }) ` + -Readback 'Verified' -Detail 'Managed payload fields, ownership, and exact assignment verified. No existing settings were changed.' + } + catch { + Add-IntuneRunLogEntry -Module 'Setup-EnrollmentRestrictions' -Action 'Readback' ` + -BestPracticeKey 'enrollment-restrictions' -Target $Payload.displayName ` + -Status 'Failed' -Disposition 'Blocked' -Readback $readback ` + -HttpStatusCode (Get-IntuneHttpStatusCode -ErrorRecord $_) ` + -Detail "Verification failed. The create-only writer will not repair existing restrictions. Review the type, platform settings, ownership, and assignments in Intune before rerunning. Reason=$($_.Exception.Message)" + throw + } +} + +function Get-IntuneEnrollmentCollection { + [CmdletBinding()] + [OutputType([object[]])] + param( + [Parameter(Mandatory)] [string] $InitialUri, + [Parameter(Mandatory)] [string] $GraphBaseUri, + [Parameter(Mandatory)] [string] $EvidenceTarget + ) + + $base = [uri] $GraphBaseUri + $expectedPathPrefix = $base.AbsolutePath.TrimEnd('/') + '/' + $visited = [System.Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase + ) + $results = [System.Collections.Generic.List[object]]::new() + $nextUri = $InitialUri + + while (-not [string]::IsNullOrWhiteSpace($nextUri)) { + if (-not $visited.Add($nextUri)) { + throw "Microsoft Graph returned a pagination cycle for $EvidenceTarget." + } + $parsedNext = $null + if (-not [uri]::TryCreate($nextUri, [UriKind]::Absolute, [ref] $parsedNext) -or + $parsedNext.Scheme -ne 'https' -or -not $parsedNext.IsDefaultPort -or + $parsedNext.UserInfo -ne '' -or $parsedNext.Fragment -ne '' -or + -not [string]::Equals($parsedNext.Host, $base.Host, [StringComparison]::OrdinalIgnoreCase) -or + $parsedNext.AbsolutePath -ne ([uri] $InitialUri).AbsolutePath -or + -not $parsedNext.AbsolutePath.StartsWith( + $expectedPathPrefix, + [StringComparison]::OrdinalIgnoreCase + )) { + throw "Microsoft Graph returned a next link outside the configured Graph API path for $EvidenceTarget." + } + + $response = Invoke-WithTransientRetry -Description "Get $EvidenceTarget" -Action { + try { + Invoke-IntuneGraphRequest -Method 'GET' -Uri $nextUri ` + -EvidenceTarget $EvidenceTarget -DeferFailureEvidence + } + catch { + throw (New-IntuneEnrollmentSafeException ` + -EvidenceTarget $EvidenceTarget -ErrorRecord $_) + } + } + $value = Get-IntuneEnrollmentProperty -InputObject $response -Name 'value' + if (-not $value.Exists -or $value.Value -isnot [System.Collections.IList]) { + throw "Microsoft Graph returned no value collection for $EvidenceTarget." + } + foreach ($item in @($value.Value)) { + if ($null -eq $item) { + throw "Microsoft Graph returned a null collection item for $EvidenceTarget." + } + $results.Add($item) + } + $nextLink = Get-IntuneEnrollmentProperty ` + -InputObject $response -Name '@odata.nextLink' + if ($nextLink.Exists -and $null -ne $nextLink.Value -and $nextLink.Value -isnot [string]) { + throw "Microsoft Graph returned a malformed next link for $EvidenceTarget." + } + $nextUri = if ($nextLink.Exists) { [string] $nextLink.Value } else { $null } + } + return @($results) +} + +function Get-IntuneEnrollmentAssignmentScope { + <# + Thin wrapper over the shared classifier in IntuneAssignmentScope.ps1. + Enrollment restrictions report exclusions to the operator, so exclusion + targets are counted rather than treated as Unknown. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [AllowEmptyCollection()] [object[]] $Assignments, + [string] $PilotGroupId + ) + + return Get-IntuneAssignmentScopeResult ` + -Assignments $Assignments ` + -PilotGroupId $PilotGroupId ` + -ExclusionBehavior 'Count' ` + -MissingTargetMessage 'Microsoft Graph returned an enrollment restriction assignment without a target.' +} + +$bestPracticeKey = 'enrollment-restrictions' + +if ($Context -and @($Context.BlockedItemKeys) -contains $bestPracticeKey) { + Add-IntuneRunLogEntry -Module 'Setup-EnrollmentRestrictions' ` + -Action 'Assessment' -BestPracticeKey $bestPracticeKey ` + -Status 'Skipped' -Disposition 'Skipped' ` + -Detail 'Skipped because preflight or operator gating blocked enrollment-restrictions.' + return +} + +try { + if (-not $Context) { + throw 'Setup-EnrollmentRestrictions.ps1 requires -Context from a pre-authenticated Graph connection. Run it through Deploy-IntuneBestPractice.ps1 or connect to Microsoft Graph yourself and supply -Context.' + } + if ([string]::IsNullOrWhiteSpace([string] $Context.TenantAdminUpn)) { + throw 'Setup-EnrollmentRestrictions.ps1 requires Context.TenantAdminUpn for Graph authentication.' + } + + # Beta exposes both the single-platform writer's type and legacy multi-platform restrictions. + $collectionUri = Resolve-IntuneGraphUri -BaseUri $Config.Api.GraphBetaBaseUri ` + -RelativePath 'deviceManagement/deviceEnrollmentConfigurations' + $allConfigurations = @(Get-IntuneEnrollmentCollection ` + -InitialUri $collectionUri -GraphBaseUri $Config.Api.GraphBetaBaseUri ` + -EvidenceTarget 'device enrollment configurations') + $configurations = @($allConfigurations | Where-Object { + $type = Get-IntuneEnrollmentProperty -InputObject $_ -Name '@odata.type' + [string] $type.Value -in @( + '#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration', + '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration' + ) + }) + + $assignments = [System.Collections.Generic.List[object]]::new() + $assignedPlatformBlockedSignals = 0 + $assignedPersonalBlockedSignals = 0 + $unassignedPlatformBlockedSignals = 0 + $unassignedPersonalBlockedSignals = 0 + $assignedConfigurationCount = 0 + $unassignedConfigurationCount = 0 + $legacyRestrictionProperties = @( + 'iosRestriction', + 'windowsRestriction', + 'windowsMobileRestriction', + 'androidRestriction', + 'macOSRestriction' + ) + foreach ($configuration in $configurations) { + $id = Get-IntuneEnrollmentProperty -InputObject $configuration -Name 'id' + if (-not $id.Exists -or $id.Value -isnot [string] -or + [string]::IsNullOrWhiteSpace([string] $id.Value)) { + throw 'Microsoft Graph returned a malformed enrollment restriction configuration ID.' + } + $encodedConfigurationId = [uri]::EscapeDataString([string] $id.Value) + $type = (Get-IntuneEnrollmentProperty $configuration '@odata.type').Value + $restrictionProperties = $legacyRestrictionProperties + if ($type -eq '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration') { + $platform = (Get-IntuneEnrollmentProperty $configuration 'platformType').Value + if ($platform -isnot [string] -or [string]::IsNullOrWhiteSpace($platform)) { + throw 'Microsoft Graph returned a single-platform restriction without a platformType.' + } + $restrictionProperties = @('platformRestriction') + } + else { + foreach ($optional in @('androidForWorkRestriction', 'macRestriction', 'windowsHomeSkuRestriction', 'visionOSRestriction', 'tvosRestriction')) { + $extra = Get-IntuneEnrollmentProperty $configuration $optional + if ($extra.Exists -and $null -ne $extra.Value) { $restrictionProperties += $optional } + } + } + foreach ($propertyName in $restrictionProperties) { + $restriction = Get-IntuneEnrollmentProperty ` + -InputObject $configuration -Name $propertyName + if (-not $restriction.Exists -or $null -eq $restriction.Value) { + throw "Microsoft Graph returned no $propertyName value for an enrollment restriction configuration." + } + foreach ($flagName in @('platformBlocked', 'personalDeviceEnrollmentBlocked')) { + $flag = Get-IntuneEnrollmentProperty ` + -InputObject $restriction.Value -Name $flagName + if (-not $flag.Exists -or $flag.Value -isnot [bool]) { + throw "Microsoft Graph returned a malformed $propertyName.$flagName value." + } + } + } + + $assignmentUri = Resolve-IntuneGraphUri -BaseUri $Config.Api.GraphBetaBaseUri ` + -RelativePath "deviceManagement/deviceEnrollmentConfigurations/$encodedConfigurationId/assignments" + $configurationAssignments = @(Get-IntuneEnrollmentCollection ` + -InitialUri $assignmentUri ` + -GraphBaseUri $Config.Api.GraphBetaBaseUri ` + -EvidenceTarget 'enrollment restriction assignments') + foreach ($assignment in $configurationAssignments) { + $assignments.Add($assignment) + } + $isAssigned = $configurationAssignments.Count -gt 0 + if ($isAssigned) { $assignedConfigurationCount++ } else { $unassignedConfigurationCount++ } + foreach ($propertyName in $restrictionProperties) { + $restriction = (Get-IntuneEnrollmentProperty ` + -InputObject $configuration -Name $propertyName).Value + if ((Get-IntuneEnrollmentProperty -InputObject $restriction -Name 'platformBlocked').Value) { + if ($isAssigned) { $assignedPlatformBlockedSignals++ } else { $unassignedPlatformBlockedSignals++ } + } + if ((Get-IntuneEnrollmentProperty -InputObject $restriction -Name 'personalDeviceEnrollmentBlocked').Value) { + if ($isAssigned) { $assignedPersonalBlockedSignals++ } else { $unassignedPersonalBlockedSignals++ } + } + } + } + + $scopeResult = Get-IntuneEnrollmentAssignmentScope ` + -Assignments @($assignments) -PilotGroupId $Context.PilotGroupId + $scope = $scopeResult.Scope + $exclusionCount = $scopeResult.ExclusionCount + $detail = 'ConfigurationCount={0}; AssignedConfigurationCount={1}; UnassignedConfigurationCount={2}; AssignedScope={3}; ExclusionCount={4}; AssignedPlatformBlockedSignals={5}; AssignedPersonalBlockedSignals={6}; UnassignedPlatformBlockedSignals={7}; UnassignedPersonalBlockedSignals={8}; Assessment=InventoryOnly.' -f ` + $configurations.Count, + $assignedConfigurationCount, + $unassignedConfigurationCount, + $scope, + $exclusionCount, + $assignedPlatformBlockedSignals, + $assignedPersonalBlockedSignals, + $unassignedPlatformBlockedSignals, + $unassignedPersonalBlockedSignals + Add-IntuneRunLogEntry -Module 'Setup-EnrollmentRestrictions' ` + -Action 'Assessment' -BestPracticeKey $bestPracticeKey ` + -Status 'Succeeded' -Disposition 'GuidedOnly' -Detail $detail + + # ---- Write phase ---- + if (@($Context.WriteBlockedItemKeys) -contains $bestPracticeKey -or + -not $IncludeHighRisk -or + -not $EnableEnrollmentRestrictions -or + -not [bool] $Context.IncludeHighRisk) { + Add-IntuneRunLogEntry -Module 'Setup-EnrollmentRestrictions' ` + -Action 'EnrollmentRestrictions' -BestPracticeKey $bestPracticeKey ` + -Status 'Succeeded' -Disposition 'GuidedOnly' ` + -Detail 'Assessment only: creating enrollment restrictions is withheld until IncludeHighRisk and EnableEnrollmentRestrictions are supplied. A misconfigured restriction can block the enrollment the rest of the baseline depends on.' + return + } + + if ([string] $Context.AssignmentScope -eq 'TenantWide') { + if ($Context.RollbackAcknowledged -ne $true) { + $reason = 'Tenant-wide enrollment restriction assignment requires Context.RollbackAcknowledged from the verified run context.' + Add-IntuneRunLogEntry -Module 'Setup-EnrollmentRestrictions' ` + -Action 'EnrollmentRestrictions' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'NotAttempted' ` + -Detail $reason + throw $reason + } + if ($Config.Assignment.AllowTenantWideAssignmentForHighRisk -isnot [bool] -or + -not $Config.Assignment.AllowTenantWideAssignmentForHighRisk) { + $reason = 'Tenant-wide enrollment restriction assignment is disabled by Assignment.AllowTenantWideAssignmentForHighRisk in configuration.' + Add-IntuneRunLogEntry -Module 'Setup-EnrollmentRestrictions' ` + -Action 'EnrollmentRestrictions' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'NotAttempted' ` + -Detail $reason + throw $reason + } + } + elseif ([string] $Context.AssignmentScope -ne 'PilotGroup' -or + [string]::IsNullOrWhiteSpace([string] $Context.PilotGroupId)) { + $reason = 'Enrollment restriction creation requires a verified PilotGroup assignment scope and Context.PilotGroupId unless tenant-wide assignment was explicitly authorized.' + Add-IntuneRunLogEntry -Module 'Setup-EnrollmentRestrictions' ` + -Action 'EnrollmentRestrictions' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'NotAttempted' ` + -Detail $reason + throw $reason + } + + $tag = $Config.ManagedByTag + $expectedNames = @($Config.EnrollmentRestrictions.Restrictions | ForEach-Object DisplayName) + $existingNames = @{} + foreach ($c in $allConfigurations) { + $dn = Get-IntuneEnrollmentProperty -InputObject $c -Name 'displayName' + if ($dn.Exists -and $dn.Value) { + $description = Get-IntuneEnrollmentProperty -InputObject $c -Name 'description' + $isManaged = $description.Exists -and + $description.Value -is [string] -and + ([string] $description.Value).Contains($tag, [StringComparison]::Ordinal) + $name = [string] $dn.Value + if ($isManaged -and $name -notin $expectedNames) { + $reason = 'A toolkit-managed enrollment configuration has an unexpected name. Reconcile its recorded object ID and configured name before creating more restrictions; renamed managed objects are not duplicated.' + Add-IntuneRunLogEntry -Module 'Setup-EnrollmentRestrictions' ` + -Action 'Collision' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'NotAttempted' ` + -Detail $reason + throw $reason + } + if (-not $existingNames.ContainsKey($name)) { + $existingNames[$name] = [pscustomobject] @{ + ManagedCount = 0 + UnmanagedCount = 0 + ConfigurationId = [string] (Get-IntuneEnrollmentProperty $c 'id').Value + } + } + if ($isManaged) { + $existingNames[$name].ManagedCount++ + } + else { + $existingNames[$name].UnmanagedCount++ + } + } + } + + $createUri = Resolve-IntuneGraphUri -BaseUri $Config.Api.GraphBetaBaseUri ` + -RelativePath 'deviceManagement/deviceEnrollmentConfigurations' + $assignmentTarget = $null + $assignmentDetail = $null + if ($Context.AssignmentScope -eq 'TenantWide') { + $assignmentTarget = @{ '@odata.type' = '#microsoft.graph.allLicensedUsersAssignmentTarget' } + $assignmentDetail = 'all licensed users' + } + elseif (-not [string]::IsNullOrWhiteSpace([string] $Context.PilotGroupId)) { + $assignmentTarget = @{ '@odata.type' = '#microsoft.graph.groupAssignmentTarget'; groupId = $Context.PilotGroupId } + $assignmentDetail = "pilot group $($Context.PilotGroupId)" + } + + foreach ($restriction in @($Config.EnrollmentRestrictions.Restrictions)) { + $dn = [string] $restriction.DisplayName + if ($restriction.PlatformBlocked -isnot [bool] -or + $restriction.PersonalDeviceEnrollmentBlocked -isnot [bool]) { + throw 'Enrollment restriction flags must be Boolean configuration values.' + } + $payload = @{ + '@odata.type' = '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration' + displayName = $dn + description = $tag + platformType = $restriction.PlatformType + platformRestriction = @{ + '@odata.type' = 'microsoft.graph.deviceEnrollmentPlatformRestriction' + platformBlocked = $restriction.PlatformBlocked + personalDeviceEnrollmentBlocked = $restriction.PersonalDeviceEnrollmentBlocked + osMinimumVersion = '' + osMaximumVersion = '' + blockedManufacturers = @() + blockedSkus = @() + } + } + + if ($existingNames.ContainsKey($dn) -and + $existingNames[$dn].UnmanagedCount -gt 0) { + $reason = "An enrollment restriction named '$dn' already exists but is not managed by the toolkit. Rename it or choose a different configured name; the create-only writer will not adopt or overwrite it." + Add-IntuneRunLogEntry -Module 'Setup-EnrollmentRestrictions' ` + -Action 'Create' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Target $dn ` + -Readback 'NotAttempted' -Detail $reason + throw $reason + } + + if ($existingNames.ContainsKey($dn) -and + $existingNames[$dn].ManagedCount -gt 0) { + if ($existingNames[$dn].ManagedCount -gt 1) { + Add-IntuneRunLogEntry -Module 'Setup-EnrollmentRestrictions' ` + -Action 'Collision' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'NotAttempted' ` + -Detail 'Multiple managed enrollment restrictions have the configured name. Reconcile duplicates before rerunning.' + throw 'Duplicate managed enrollment restrictions block the create-only writer.' + } + Assert-IntuneEnrollmentReadback -Payload $payload ` + -ConfigurationId $existingNames[$dn].ConfigurationId ` + -ExpectedTarget $assignmentTarget -Config $Config -Existing + continue + } + + if (-not $PSCmdlet.ShouldProcess($dn, 'Create enrollment platform restriction and assign')) { + Add-IntuneRunLogEntry -Module 'Setup-EnrollmentRestrictions' ` + -Action 'Create' -BestPracticeKey $bestPracticeKey ` + -Status 'Info' -Disposition 'WillChange' -Target $dn -Readback 'NotAttempted' ` + -Detail "WhatIf: would create enrollment restriction '$dn' (platform=$($restriction.PlatformType), platformBlocked=$([bool] $restriction.PlatformBlocked), personalBlocked=$([bool] $restriction.PersonalDeviceEnrollmentBlocked)) and assign to $([string]::IsNullOrWhiteSpace($assignmentDetail) ? 'no target (none provided)' : $assignmentDetail)." + continue + } + + $body = $payload | ConvertTo-Json -Depth 10 + + $created = $null + try { + $created = Invoke-WithTransientRetry -Description "Create enrollment restriction '$dn'" -Action { + Invoke-MgGraphRequest -Method POST -Uri $createUri -Body $body -ContentType 'application/json' + } + Add-IntuneRunLogEntry -Module 'Setup-EnrollmentRestrictions' ` + -Action 'Create' -BestPracticeKey $bestPracticeKey ` + -Status 'Created' -Disposition 'Applicable' -Target $dn ` + -Detail "Created enrollment restriction '$dn'." + } + catch { + Add-IntuneRunLogEntry -Module 'Setup-EnrollmentRestrictions' ` + -Action 'Create' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Applicable' -Target $dn ` + -HttpStatusCode (Get-IntuneHttpStatusCode -ErrorRecord $_) ` + -Detail "Failed to create enrollment restriction '$dn': $($_.Exception.Message)" + throw + } + + $createdIdProperty = Get-IntuneEnrollmentProperty -InputObject $created -Name 'id' + if (-not $createdIdProperty.Exists -or + [string]::IsNullOrWhiteSpace([string] $createdIdProperty.Value)) { + throw "Microsoft Graph returned no ID for the created enrollment restriction '$dn'." + } + $encodedCreatedId = [uri]::EscapeDataString([string] $createdIdProperty.Value) + + if ($assignmentTarget) { + $assignBody = @{ enrollmentConfigurationAssignments = @(@{ target = $assignmentTarget }) } | ConvertTo-Json -Depth 10 + try { + Invoke-WithTransientRetry -Description "Assign enrollment restriction '$dn'" -Action { + Invoke-MgGraphRequest -Method POST -Uri "$createUri/$encodedCreatedId/assign" -Body $assignBody -ContentType 'application/json' | Out-Null + } + Add-IntuneRunLogEntry -Module 'Setup-EnrollmentRestrictions' ` + -Action 'Assign' -BestPracticeKey $bestPracticeKey ` + -Status 'Succeeded' -Disposition 'Applicable' -Target $dn ` + -Detail "Assigned '$dn' to $assignmentDetail." + } + catch { + Add-IntuneRunLogEntry -Module 'Setup-EnrollmentRestrictions' ` + -Action 'Assign' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Applicable' -Target $dn ` + -HttpStatusCode (Get-IntuneHttpStatusCode -ErrorRecord $_) ` + -Detail "Created '$dn' but failed to assign it: $($_.Exception.Message)" + throw + } + } + + Assert-IntuneEnrollmentReadback -Payload $payload ` + -ConfigurationId ([string] $createdIdProperty.Value) ` + -ExpectedTarget $assignmentTarget -Config $Config + } +} +catch { + $status = Get-IntuneHttpStatusCode -ErrorRecord $_ + Add-IntuneRunLogEntry -Module 'Setup-EnrollmentRestrictions' ` + -Action 'Assessment' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -HttpStatusCode $status ` + -Detail "Enrollment restriction assessment failed. Reason=$($_.Exception.Message)" + throw +} diff --git a/Products/Intune/Modules/Setup-EnterpriseStateRoaming.ps1 b/Products/Intune/Modules/Setup-EnterpriseStateRoaming.ps1 new file mode 100644 index 0000000..746e6f8 --- /dev/null +++ b/Products/Intune/Modules/Setup-EnterpriseStateRoaming.ps1 @@ -0,0 +1,26 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Enterprise State Roaming through Windows Backup for Organizations (task 9). + +.DESCRIPTION + Microsoft moved Enterprise State Roaming management from the Entra portal + to Windows Backup for Organizations policy management after June 2026. + This module records the current supported guided workflow while policy API, + payload, assignment, readback, and rollback behavior are verified. +#> + +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +# smb-quality-gate: read-only +param( + [Parameter(Mandatory)] [hashtable] $Config, + [hashtable] $Context +) + +. (Join-Path $PSScriptRoot 'IntuneRunLog.ps1') +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') + +Add-IntuneRunLogEntry -Module 'Setup-EnterpriseStateRoaming' ` + -Action 'GuidedConfiguration' -BestPracticeKey 'enterprise-state-roaming' ` + -Status 'Info' -Disposition 'GuidedOnly' ` + -Detail 'Enterprise State Roaming management moved to Windows Backup for Organizations after June 2026. Use Intune admin center > Devices > Device onboarding > Enrollment > Windows > Windows Backup and Restore, confirm supported Windows builds and licensing, configure a pilot policy, and verify backup and restore status before broader assignment. The former Entra ID > Devices > Enterprise State Roaming path is obsolete. Automated policy creation remains blocked until the supported API payload, assignment, readback, and rollback contract is verified.' diff --git a/Products/Intune/Modules/Setup-IntunePreflight.ps1 b/Products/Intune/Modules/Setup-IntunePreflight.ps1 new file mode 100644 index 0000000..7b8edfb --- /dev/null +++ b/Products/Intune/Modules/Setup-IntunePreflight.ps1 @@ -0,0 +1,243 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Read-only Intune capability, license, and readiness checks. + +.DESCRIPTION + Establishes what the tenant can support before any state-changing module + runs, and records an explicit disposition for every configured baseline + item. Items whose licence capability is absent are recorded as Skipped with + the reason rather than silently omitted. + + Items that Microsoft does not expose through a stable, least-privilege, + unattended path are recorded as GuidedOnly and are never attempted. +#> + +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +# smb-quality-gate: read-only +param( + [Parameter(Mandatory)] [hashtable] $Config, + [hashtable] $Context, + [Parameter(Mandatory)] [string] $TenantAdminUpn, + [switch] $NoLicenseAutoDetect +) + +. (Join-Path $PSScriptRoot 'IntuneRunLog.ps1') +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') +. (Join-Path $PSScriptRoot 'IntuneGraphClient.ps1') + +function Get-IntuneLicenseInventory { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $GraphBaseUri + ) + + try { + $uri = Resolve-IntuneGraphUri -BaseUri $GraphBaseUri ` + -RelativePath 'subscribedSkus?$select=skuId,skuPartNumber,capabilityStatus,consumedUnits,prepaidUnits,servicePlans' + $response = Invoke-WithTransientRetry -Description 'Get subscribed SKUs' -Action { + Invoke-IntuneGraphRequest -Method 'GET' -Uri $uri ` + -EvidenceTarget 'subscribed SKUs' ` + -DeferFailureEvidence + } + $records = @($response.value | ForEach-Object { + [pscustomobject]@{ + SkuId = $_.skuId + SkuPartNumber = [string] $_.skuPartNumber + CapabilityStatus = [string] $_.capabilityStatus + ConsumedUnits = [int] $_.consumedUnits + PrepaidUnitsEnabled = [int] $_.prepaidUnits.enabled + ServicePlanNames = @($_.servicePlans | ForEach-Object { [string] $_.servicePlanName }) + } + }) + if ($records.Count -eq 0) { + throw 'Microsoft Graph returned no subscribed SKU records; license readiness is unknown.' + } + return $records + } + catch { + $status = Get-IntuneHttpStatusCode -ErrorRecord $_ + Add-IntuneRunLogEntry -Module 'Setup-IntunePreflight' ` + -Action 'LicenseCheck' -Status 'Failed' -HttpStatusCode $status ` + -Detail $_.Exception.Message + throw + } +} + +function Test-IntuneLicenseCapability { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [hashtable] $CapabilityDefinition, + [Parameter(Mandatory)] [object[]] $Inventory + ) + + $identified = @($Inventory | Where-Object { + ($_.SkuPartNumber -in @($CapabilityDefinition.SkuPartNumbers)) -or + (@($_.ServicePlanNames) | Where-Object { + $_ -in @($CapabilityDefinition.ServicePlanNames) + }) + }) + $skuMatches = @($identified | Where-Object { + $_.CapabilityStatus -eq 'Enabled' -and + ($_.ConsumedUnits -gt 0 -or $_.PrepaidUnitsEnabled -gt 0) + }) + return [pscustomobject]@{ + Available = $skuMatches.Count -gt 0 + Matches = $skuMatches + Identified = $identified + } +} + +function Test-IntuneApiCapability { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [hashtable] $ApiDefinition + ) + + $missing = @($ApiDefinition.RequiredCommands | Where-Object { + -not (Get-Command -Name $_ -ErrorAction SilentlyContinue) + }) + if ($missing.Count -gt 0) { + throw "Required Intune API commands are unavailable: $($missing -join ', ')" + } + + $graphContext = Get-MgContext + if (-not $graphContext) { + throw 'Microsoft Graph context is not available after connection.' + } + $account = Protect-IntuneIdentity -Value ([string] $graphContext.Account) + Add-IntuneRunLogEntry -Module 'Setup-IntunePreflight' ` + -Action 'ApiCapability' -Status 'Succeeded' ` + -Detail "Graph context available. AuthenticationType=$($graphContext.AuthType); Account=$account." +} + +function Write-IntuneGuidedReadiness { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Action, + [Parameter(Mandatory)] [string] $Detail, + [string] $BestPracticeKey, + [ValidateSet('Applicable','AlreadyCompliant','WillChange','GuidedOnly','Skipped','Blocked')] + [string] $Disposition = 'GuidedOnly' + ) + + $entry = @{ + Module = 'Setup-IntunePreflight' + Action = $Action + Status = 'Info' + Disposition = $Disposition + Detail = $Detail + } + if ($BestPracticeKey) { $entry['BestPracticeKey'] = $BestPracticeKey } + Add-IntuneRunLogEntry @entry +} + +Add-IntuneRunLogEntry -Module 'Setup-IntunePreflight' ` + -Action 'Preflight' -Status 'Started' -Detail 'Read-only capability checks started.' + +# Baseline items whose licence capability is absent. Written out at the end so +# the orchestrator can stop later modules acting on them. Recording a finding +# without being able to act on it is not a preflight, it is a comment. +$unavailableKeys = @() + +try { + if (-not $Context) { + throw 'Setup-IntunePreflight.ps1 requires -Context from a pre-authenticated Graph connection. Run it through Deploy-IntuneBestPractice.ps1 (the orchestrator), or connect to Microsoft Graph yourself and supply -Context.' + } + Test-IntuneApiCapability -ApiDefinition $Config.Api + + if (-not $NoLicenseAutoDetect) { + $inventory = Get-IntuneLicenseInventory -GraphBaseUri $Config.Api.GraphBaseUri + Add-IntuneRunLogEntry -Module 'Setup-IntunePreflight' ` + -Action 'LicenseCheck' -Status 'Succeeded' ` + -Detail "Retrieved $(@($inventory).Count) subscribed SKU records." + + foreach ($item in @($Config.BestPracticeItems | Sort-Object Priority, GuideTask)) { + $capabilityKey = [string] $item.LicenseCapability + $definition = $Config.LicenseCapabilities[$capabilityKey] + if (-not $definition) { + Add-IntuneRunLogEntry -Module $item.Module ` + -Action 'LicenseDisposition' -BestPracticeKey $item.Key ` + -Status 'Skipped' -Disposition 'GuidedOnly' ` + -Detail 'No configured license capability mapping; treat as GuidedOnly.' + continue + } + $result = Test-IntuneLicenseCapability ` + -CapabilityDefinition $definition -Inventory $inventory + $disposition = if ($result.Available) { 'Applicable' } else { 'Skipped' } + # An unavailable capability is a skipped outcome, not an + # informational one. Recording it as Info left it uncounted in the + # report's skipped total and made the run look cleaner than it was. + $status = if ($result.Available) { 'Info' } else { 'Skipped' } + if (-not $result.Available) { $unavailableKeys += $item.Key } + Add-IntuneRunLogEntry -Module $item.Module ` + -Action 'LicenseDisposition' -BestPracticeKey $item.Key ` + -Status $status -Disposition $disposition ` + -Detail "Disposition=$disposition; Priority=$($item.Priority); LicenseCapability=$capabilityKey; IdentifiedSubscriptions=$(@($result.Identified).Count); EnabledSubscriptions=$(@($result.Matches).Count)." + } + } + else { + Add-IntuneRunLogEntry -Module 'Setup-IntunePreflight' ` + -Action 'LicenseCheck' -Status 'Skipped' ` + -Detail 'Automatic license detection disabled by NoLicenseAutoDetect.' + } + + # Managed Google Play requires a human credential and an interactive browser + # flow. There is no unattended path, so the toolkit records the requirement + # and never attempts it. + Write-IntuneGuidedReadiness -Action 'ManagedGooglePlay' ` + -BestPracticeKey 'managed-google-play' ` + -Disposition $Config.Preflight.ManagedGooglePlayDisposition ` + -Detail 'Managed Google Play binding is GuidedOnly. In the Intune admin center, use Devices > Device onboarding > Enrollment > Android > Managed Google Play with a customer-controlled Microsoft Entra account that has an active mailbox and either Intune Administrator or custom Intune organization read and update permissions. Complete the interactive Google consent flow, add a second Google enterprise owner, and verify that the four core Android apps appear in Intune. GDAP does not replace the customer-owned Google identity or interactive consent.' + + # These two remain guided until a supported, generally available API + # surface is verified and documented in the product supportability record. + Write-IntuneGuidedReadiness -Action 'WindowsAutoEnrollment' ` + -BestPracticeKey 'windows-auto-enrollment' ` + -Disposition $Config.Preflight.WindowsAutoEnrollmentDisposition ` + -Detail 'Windows automatic MDM enrollment scope has no verified supported API in this product yet. Leave the Windows Information Protection scope set to None; WIP is deprecated.' + Write-IntuneGuidedReadiness -Action 'EnterpriseStateRoaming' ` + -BestPracticeKey 'enterprise-state-roaming' ` + -Disposition $Config.Preflight.EnterpriseStateRoamingDisposition ` + -Detail 'Enterprise State Roaming management moved to Windows Backup for Organizations after June 2026. In the Intune admin center, use Devices > Device onboarding > Enrollment > Windows > Windows Backup and Restore; the former Entra portal path is obsolete. Automated policy creation remains blocked pending API, assignment, readback, and rollback verification.' + + Write-IntuneGuidedReadiness -Action 'EmergencyAccessSafety' ` + -Disposition $Config.Preflight.EmergencyAccessDisposition ` + -Detail 'Emergency-access account coverage and break-glass exclusions require operator confirmation before any Conditional Access enforcement.' + + # The combined effect of these two items is an access denial, and the + # ordering is what makes it safe or unsafe. This is operator guidance, not + # a blocked action, so it is recorded as informational. A Blocked + # disposition on a successful step would be success-shaped evidence of a + # block that never happened. + Add-IntuneRunLogEntry -Module 'Setup-IntunePreflight' ` + -Action 'EnforcementCoupling' -Status 'Info' ` + -Detail 'Marking unevaluated devices as not compliant is inert until a device-based Conditional Access policy exists, and becomes an access denial once it does. Apply compliance policies and confirm device evaluation before enabling Conditional Access enforcement.' + + # Persist the applicability outcome so the orchestrator can act on it. + # Modules run as separate processes, so an in-memory result would not + # survive the process boundary. + if ($Context.ApplicabilityPath) { + $applicability = [ordered]@{ + generatedUtc = [datetime]::UtcNow.ToString('o') + unavailableItemKeys = @($unavailableKeys) + } + $applicabilityDirectory = Split-Path -Parent $Context.ApplicabilityPath + if ($applicabilityDirectory -and -not (Test-Path -LiteralPath $applicabilityDirectory)) { + New-Item -ItemType Directory -Path $applicabilityDirectory -Force -WhatIf:$false | Out-Null + } + $applicability | ConvertTo-Json -Depth 5 | + Set-Content -LiteralPath $Context.ApplicabilityPath -Encoding utf8 -WhatIf:$false + } + + Add-IntuneRunLogEntry -Module 'Setup-IntunePreflight' ` + -Action 'Preflight' -Status 'Succeeded' ` + -Detail "Preflight framework completed without state changes. UnavailableItems=$(@($unavailableKeys).Count)." +} +catch { + $status = Get-IntuneHttpStatusCode -ErrorRecord $_ + Add-IntuneRunLogEntry -Module 'Setup-IntunePreflight' ` + -Action 'Preflight' -Status 'Failed' -HttpStatusCode $status ` + -Detail $_.Exception.Message + throw +} diff --git a/Products/Intune/Modules/Write-IntuneHtmlReport.ps1 b/Products/Intune/Modules/Write-IntuneHtmlReport.ps1 new file mode 100644 index 0000000..b9d6092 --- /dev/null +++ b/Products/Intune/Modules/Write-IntuneHtmlReport.ps1 @@ -0,0 +1,149 @@ +#requires -Version 7.0 +# Get-IntuneModuleVerdict and the identity redaction helpers live in +# IntuneRunLog.ps1 so the JSON sidecar and this report use the same verdict and +# privacy rules. Callers dot-source that file before invoking this function. +function Write-IntuneHtmlReport { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Path, + [Parameter(Mandatory)] [hashtable[]] $Entries, + [guid] $RunId, + [datetime] $StartTime, + [datetime] $EndTime = [datetime]::UtcNow, + [string] $TenantId, + [string] $TenantAdminUpn, + [string] $ScriptVersion = 'unknown', + [switch] $WhatIfRun + ) + + function Encode([object] $Value) { + if ($null -eq $Value) { return '' } + return [System.Net.WebUtility]::HtmlEncode([string] $Value) + } + + function Get-StatusClass([string] $Status) { + switch ($Status) { + 'Failed' { return 'failed' } + 'Skipped' { return 'skipped' } + 'Retried' { return 'retried' } + 'Succeeded' { return 'succeeded' } + 'Created' { return 'succeeded' } + 'Updated' { return 'succeeded' } + 'Adopted' { return 'succeeded' } + default { return 'info' } + } + } + + try { + $failed = @($Entries | Where-Object Status -eq 'Failed').Count + $skipped = @($Entries | Where-Object Status -eq 'Skipped').Count + $guided = @($Entries | Where-Object Disposition -eq 'GuidedOnly').Count + $succeeded = @($Entries | Where-Object Status -in @('Succeeded','Created','Updated','Adopted')).Count + $duration = if ($StartTime) { [int] (($EndTime - $StartTime).TotalSeconds) } else { $null } + $startText = if ($StartTime) { $StartTime.ToString('o') } else { $null } + $modeBanner = if ($WhatIfRun) { + '' + } + else { + '' + } + + # The operator report requires a final summary showing + # each module as OK, FAILED, SKIPPED, or BLOCKED. Entry counts alone do + # not satisfy it: an operator scanning a long run needs the per-module + # verdict without reading every row. + $moduleRows = foreach ($group in ($Entries | Group-Object Module | Sort-Object Name)) { + $items = @($group.Group) + $verdict = Get-IntuneModuleVerdict -Entries $items + $verdictClass = switch ($verdict) { + 'FAILED' { 'failed' } + 'BLOCKED' { 'skipped' } + 'SKIPPED' { 'skipped' } + default { 'succeeded' } + } + '{0}{1}{2}' -f ` + (Encode $group.Name), + ('{1}' -f $verdictClass, $verdict), + $items.Count + } + + $rows = foreach ($entry in $Entries) { + $statusClass = Get-StatusClass $entry.Status + '{0}{1}{2}{3}{4}{5}{6}{7}{8}{9}{10}' -f ` + (Encode $entry.Timestamp), (Encode $entry.Module), (Encode $entry.Action), + (Encode $entry.BestPracticeKey), (Encode $entry.Disposition), (Encode $entry.Target), + ('{1}' -f $statusClass, (Encode $entry.Status)), + (Encode $entry.Detail), (Encode $entry.Readback), + (Encode $entry.HttpStatusCode), (Encode $entry.ElapsedMs) + } + + $html = @" + + + + +Intune deployment report + + +
+

Microsoft Intune Best Practice Deployment Report

Read-only and state-changing evidence for one deployment run.

+
$modeBanner

Run metadata

+
Run ID
$(Encode $RunId)
+
Script version
$(Encode $ScriptVersion)
+
Tenant ID
$(Encode (Protect-IntuneTenantId -Value $TenantId))
+
Tenant administrator
$(Encode (Protect-IntuneIdentity -Value $TenantAdminUpn))
+
Started
$(Encode $startText)
+
Ended
$(Encode $EndTime.ToString('o'))
+
Duration (seconds)
$(Encode $duration)
+
+

Evidence summary

+
$($Entries.Count)Total entries
+
$succeededSuccessful/change entries
+
$skippedSkipped
+
$guidedGuided only
+
$failedFailed
+
+

Module summary

+ +$($moduleRows -join [Environment]::NewLine) +
ModuleResultEntries
+

Decision-point evidence

+ +$($rows -join [Environment]::NewLine) +
TimestampModuleActionBP keyDispositionTargetStatusDetail / reasonReadbackHTTPElapsed ms
+
+"@ + # Evidence output is local, not a tenant change; never suppress it under + # -WhatIf. A dry run must still produce its report. + Set-Content -LiteralPath $Path -Value $html -Encoding utf8 -WhatIf:$false + } + catch { + throw "Unable to write Intune HTML report '$Path': $($_.Exception.Message)" + } +} diff --git a/Products/Intune/README.md b/Products/Intune/README.md new file mode 100644 index 0000000..4282dde --- /dev/null +++ b/Products/Intune/README.md @@ -0,0 +1,595 @@ +--- +title: Intune +layout: default +nav_order: 4 +has_children: true +permalink: /intune/ +--- + +# Microsoft Intune Best Practice Deployment Toolkit + +> ⚠️ **Write-capable. Pilot first, and read the safety gates below.** +> This product applies real tenant changes for six baseline tasks. Every write +> is gated by `-WhatIf`/`ShouldProcess`, wrapped in the shared transient-retry +> boundary, and recorded in structured evidence. Modules use supported +> readback where the service exposes a reliable projection. High-risk items +> (default compliance, device compliance, enrollment restrictions, Conditional +> Access) stay behind `-IncludeHighRisk` and their category switches, and the +> Conditional Access policy is created report-only by default. Preview with +> `-WhatIf`, run against a pilot group, and confirm break-glass exclusions +> before enforcing. The hash-verified policy catalog remains apply-blocked. + +PowerShell automation that assesses and deploys Microsoft's recommended +**device enrollment and device management baseline** for a Microsoft 365 +**Business Premium** tenant. Built for anyone who needs a repeatable way to +understand current state, apply the supported baseline to a pilot, complete +guided tasks, and retain deployment evidence, whether that is a partner, an +MSP, or an in-house IT team. + +The intended experience is a guided onboarding flow rather than a collection of +unconnected scripts. The toolkit will assess prerequisites and licenses, +preview the recommended baseline, apply supported settings safely, guide you +through steps that require human sign-in or have no supported API, verify the +result, and produce client-ready deployment evidence. + +The configuration is taken from the Microsoft **Device Management Deployment +Guide for Small Business** (Device Enrollment Best Practices, Business Premium / +Intune Plan 1). + +## First time here? Start with a preview + +You do not need to read every guide before evaluating the toolkit. This first +run reads tenant state and writes local reports; it does not deploy a baseline. + +1. Download the repository version containing `Products\Intune` (on GitHub, + **Code > Download ZIP**, then **Extract All**), or use your approved clone. + Keep the whole product folder, including `Modules` and `Config`. If Intune + is absent from the downloaded release, stop and obtain a release that + includes it. Do not mix scripts and documentation from different versions. +2. Open **PowerShell 7** in an interactive desktop terminal, not Windows + PowerShell 5.1. Change directory to the extracted repository root. +3. Review [prerequisites](#prerequisites), especially the tenant roles and + permissions requested during Microsoft sign-in. +4. Replace the sample UPN and run the command below. `-AutoInstallModules` + installs missing Graph modules for your Windows user; omit it if your + organization manages those dependencies. + +```powershell +cd .\Products\Intune +.\Deploy-IntuneBestPractice.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com ` + -AutoInstallModules ` + -WhatIf +``` + +Use the printed open command for the saved HTML report, by default +`Reports\intune-run-report.html`. No tenant action should be `Created`, +`Updated`, or `Adopted`. High-risk writes being withheld is expected. +`GuidedOnly` means follow-up work, not completed configuration; any `FAILED` +module needs investigation. + +If a prerequisite or sign-in fails before setup, the baseline has not been +assessed or deployed. `ConfiguredItem` messages describe selected +recommendations, not completed work. Follow the +[module-version recovery guidance](docs/Evidence-Troubleshooting.md#graph-module-version-conflicts) +for an Authentication/DeviceManagement assembly conflict. + +No pilot group is needed for this discovery run. A preview saying **no target +(none provided)** is not an assignment plan. Before removing `-WhatIf`, follow +the [pilot group setup](docs/Operator-Guide.md#prepare-a-pilot-group) and +[standard pilot apply](docs/Operator-Guide.md#apply-to-a-pilot) instructions. +For sign-in or report problems, start with +[troubleshooting](docs/Evidence-Troubleshooting.md). + +## What the baseline covers + +Ten tasks across three priorities. + +| # | Task | Priority | Configured in | +|---|---|---|---| +| 1 | Automatic MDM enrollment for Windows | 1 | Entra | +| 2 | Apple MDM push certificate | 1 | Intune + Apple | +| 3 | Managed Google Play connection | 1 | Intune + Google | +| 4 | Treat devices with no compliance policy as not compliant | 1 | Intune | +| 5 | Device platform enrollment restrictions | 2 | Intune | +| 6 | App protection policies for core Microsoft apps | 2 | Intune | +| 7 | Per-platform device compliance policies | 2 | Intune | +| 8 | Microsoft 365 Apps deployment | 2 | Intune | +| 9 | Enterprise State Roaming | 2 | Entra | +| 10 | Require MFA and a compliant device for Intune enrollment | 3 | Entra | + +Task 10 is configured in the Entra admin center. Microsoft moved task 9, +Enterprise State Roaming, to Windows Backup for Organizations policy management +after June 2026. Both remain in this product because the source guide treats +them as part of the same endpoint baseline, and task 10 is what makes tasks 4 +and 7 enforce access. + +## Current guided and partial-automation boundaries + +**The Apple MDM push certificate** needs an Apple ID, a downloaded certificate +signing request, a session on the Apple Push Certificates Portal, and a manual +certificate exchange. It is also an annual renewal owned by the customer. +Graph v1.0 can read certificate status, download a signing request, and upload +a certificate, but the overall task remains guided. Optional upload automation +would require separate pilot proof because replacement has no safe automatic +rollback. The certificate status read is now implemented as an automated, +read-only assessment with a configurable pre-expiry renewal warning (see below). + +**The managed Google Play connection** needs an interactive browser consent flow +binding a Google account to the tenant. Use a customer-controlled Microsoft +Entra account with an active mailbox and either the Intune Administrator role +or a custom Intune role with organization read and update permissions. Google +recommends at least two enterprise owners for redundancy. A GDAP relationship +can provide the Intune role boundary, but it does not replace the +customer-owned Google identity, mailbox, or interactive consent. + +No GA Graph connect and readback pair exists; the candidate resource is +beta-only. The toolkit therefore records `GuidedOnly` and directs the operator +to **Devices > Device onboarding > Enrollment > Android > Managed Google +Play**. After connection, verify that Microsoft Intune, Microsoft +Authenticator, Intune Company Portal, and Managed Home Screen appear as +managed Google Play apps. Disconnect is a destructive recovery action: +Microsoft documents that it disables Android Enterprise management and +unenrolls Android Enterprise devices, so the toolkit does not automate it. + +Official source capture on 10 August 2026 also found no supported GA write and +readback pair for **Windows automatic MDM enrollment** or **Enterprise State +Roaming**. Those tasks remain `GuidedOnly`. + +The Microsoft 365 Apps and Android/iOS app-protection write paths use the +specific beta resources validated by the current release. That approval does +not extend to the imported 19-payload policy catalog, modern Windows MAM, or +other beta-only resources. Those broader paths remain blocked or guided. + +See [Future Intune write capabilities](docs/Future-Write-Capabilities.md) for a +source-conscious roadmap of API gaps, pilot blockers, and the stakeholder help +needed before each task can gain tenant-write behavior. + +## Choose your starting point + +- **Evaluating coverage:** use + [scenarios and capabilities](docs/Scenarios.md) for the current status, + tenant impact, and operator action for all ten guide tasks. +- **Running the assessment:** use the + [operator guide](docs/Operator-Guide.md) for prerequisites, commands, reports, + and outcome interpretation. +- **Planning the rollout:** use the + [deployment framework](docs/Deployment-Framework.md) for task dependencies + and the [change-management playbook](docs/Change-Management-Playbook.md) for + customer decisions, pilot execution, portal verification, and closure. +- **Completing portal work:** use the + [device enrollment deployment guide](docs/Device-Enrollment-Deployment-Guide.md) + for the ordered Intune, Entra, Apple, and Google procedures. +- **Preparing support and users:** use the + [support-team guide](docs/Support-Team-Guide.md) for triage and ownership and + the [end-user adoption guide](docs/End-User-Adoption-Guide.md) for approved + pilot and enforcement communications. +- **Reviewing configuration:** use the + [configuration reference](docs/Configuration-Reference.md) for active, + classification-only, reserved, and blocked controls. +- **Using copy-ready commands:** use the + [assessment and deployment examples](Examples/Read-Only-Assessment.md). +- **Resolving a failure:** use + [evidence and troubleshooting](docs/Evidence-Troubleshooting.md). +- **Assessing future automation:** use + [future write capabilities](docs/Future-Write-Capabilities.md) for the API, + permission, pilot, readback, and rollback gates. + +The safe first run is the orchestrator with `-WhatIf`. It connects to the +expected tenant, performs the supported assessments, previews every selected +write, records guided boundaries, and writes local evidence without changing +the tenant. It does not complete guided portal tasks and it does not authorize +a later manual change. + +## Read-only assessments implemented today + +Five guide task areas now run automated, read-only assessments as part of this +toolkit. None of these reads changes tenant state, and each fails closed rather +than guessing when a result is missing or malformed. + +The repository also contains a normalized 19-payload candidate policy catalog: +seven Baseline mobile payloads, seven Advanced mobile payloads, and five +Windows compliance or hardening payloads. The catalog was imported from a +contributor implementation, stripped of export-only object metadata, assigned stable +file names, and protected by SHA-256 digests. Three Settings Catalog payloads +that the original script silently skipped now carry the required Graph +discriminator. + +This import does not make the payloads deployable. Every catalog entry is +marked `Blocked`, and the source used Microsoft Graph beta commands. +`-EnablePolicyCatalogWrite` therefore always denies the run until API and +tenant-permission review, pilot assignment, readback, preservation, rollback, +and recovery evidence are recorded. + +**Guide task 2, Apple MDM push certificate health.** +`Setup-EnrollmentPrerequisites.ps1` reads +`GET /deviceManagement/applePushNotificationCertificate`. It reports +`AlreadyCompliant` when a valid certificate is outside the configured renewal +warning window, and `GuidedOnly` with renewal direction when the certificate is +within that window or expired. `ApplePushCertificate.RenewalWarningDays` +defaults to 30 days. Microsoft Graph can return `404` when the certificate +singleton is unavailable. Because Microsoft does not document that response as +proof that setup is incomplete, the assessment records `GuidedOnly`, retains +only the status code, and directs the operator to verify the Intune admin +center. Missing, null, or malformed certificate fields and authorization +failures still fail closed. +The certificate signing request download and certificate upload described +above remain guided and are out of scope for this assessment. The required +permission is `DeviceManagementServiceConfig.Read.All`. Confirm the operator's +Intune role and GDAP mapping for the target customer before relying on it. + +**Guide task 4, default compliance setting.** `Setup-ComplianceBaseline.ps1` +reads the `secureByDefault` setting through the documented +`Get-MgDeviceManagement` cmdlet. It reports `AlreadyCompliant` when the +setting is already true, `GuidedOnly` when Graph returns a nullable or missing +projection that cannot prove the current state, and an explicit false state as +drift. An unknown state never triggers a tenant write. With +`-IncludeHighRisk -EnableComplianceEnforcement -CustomerApprovalId `, the +module sets `secureByDefault` to true, preserves the configured check-in +period, and reads the setting back into evidence. If Graph does not return a +verifiable post-write value, the module blocks the run and directs portal +verification. Without those gates, the assessment still runs and records that +the write was withheld. + +Withholding `-IncludeHighRisk` or `-EnableComplianceEnforcement` does not block +the task 4 read. It is recorded as informational `WriteRiskGate` evidence and +a `WriteBlockedItemKeys` entry, separate from the assessment result. + +**Guide task 5, device enrollment restriction inventory.** +`Setup-EnrollmentRestrictions.ps1` uses the configured Graph beta endpoint for +inventory, writes and readback. It recognizes both single-platform +`deviceEnrollmentPlatformRestrictionConfiguration` objects and legacy +multi-platform `deviceEnrollmentPlatformRestrictionsConfiguration` objects, +using each type's own settings shape and assignment collection. It +reports only normalized configuration count, assignment scope, platform-blocked +signals, and personal-enrollment-blocked signals. It ignores the unrelated +device enrollment limit resource and does not retain policy or group +identifiers. Intune can return opaque 64-character configuration IDs rather +than GUIDs. The toolkit validates them as non-empty identifiers, URL-encodes +them before assignment reads, and never writes them to evidence. The assessment +result remains `GuidedOnly` because the inventory does not by itself prove the +desired platform baseline. With +`-IncludeHighRisk -EnableEnrollmentRestrictions -CustomerApprovalId `, the +module creates the configured iOS/iPadOS and Android personal-enrollment +restrictions, assigns them to the selected scope, and reads them back. The +required read permission is `DeviceManagementServiceConfig.Read.All`. + +Post-write verification checks the discriminator, platform, managed settings, +ownership marker and exact assignment target. Reruns verify those same fields +before reporting `AlreadyCompliant`; a name match is not enough. Extra targets, +filters, exclusions, duplicate managed names or changed settings stop the +create-only writer rather than triggering an automatic repair. An assignment +collection containing only exclusions is reported as `Unknown`, not `PilotOnly`. +The beta API remains subject to change and needs approved pilot validation. + +Withholding `-IncludeHighRisk` or `-EnableEnrollmentRestrictions` does not block +the inventory. Missing write authorization is recorded separately as +`WriteRiskGate` evidence. + +**Guide task 6, Android and iOS/iPadOS app protection inventory.** +`Setup-AppProtectionPolicies.ps1` reads Android and iOS/iPadOS managed app +protection policies, each policy's assignments, and the documented per-policy +targeted-app list. It emits one assessment entry per platform with normalized +policy count, toolkit-managed policy count, assignment scope, and target-app +readback state. The assessment never calls the app protection `assign` or +`targetApps` actions, never changes a policy, and never treats target-app +readback unavailability as compliance. Intune can return opaque non-GUID policy +IDs. The toolkit requires a non-empty value, URL-encodes it for relationship +reads, and excludes it from evidence. The required read permission is +`DeviceManagementApps.Read.All`. Confirm the operator's Intune role, GDAP +mapping, and targeted-user licensing for the customer tenant. + +The same module creates the configured Level 1 policy for each platform, +targets the configured core Microsoft applications, and assigns it to the +pilot group or approved tenant-wide scope. It skips an existing toolkit-managed +policy by default. The current create-only path does not update or merge an +existing app-protection policy. Modern Windows MAM remains outside this write +path. + +An existing managed app-protection policy is reported as `GuidedOnly` with +`Readback=NotAttempted`, not verified compliance. Review its settings, targeted +apps and assignment in Intune. An unmanaged same-name policy still blocks +creation rather than being adopted or duplicated. + +Renamed toolkit-managed app-protection and Microsoft 365 Apps objects are left +unchanged. Renamed managed compliance or enrollment objects block further +creation until their recorded IDs and configured names are reconciled. +An exactly equivalent report-only Conditional Access policy is left unchanged +even if renamed. Do not remove management markers to bypass these protections. + +**Guide task 7, per-platform device compliance policy inventory.** +`Setup-DeviceCompliancePolicies.ps1` independently reads the GA policy and +assignment collections. Microsoft documents a scheduled-action collection, +but a pilot iOS policy returned `400` because the v1.0 service had no matching +GET route. The toolkit records scheduled-action readback as unavailable rather +than reporting a false zero or failing the remaining inventory. It reports +normalized counts for Android device administrator, Android work profile, +iOS/iPadOS, macOS, Windows, beta-only, and unknown policy types without +retaining policy names, IDs, settings, groups, rules, or raw responses. Android +Device Owner and AOSP policy types remain guided because their v1.0 +documentation links fall back to beta. The required read permission is +`DeviceManagementConfiguration.Read.All`. + +Creation and managed-policy reruns require verified payload fields, scheduled +actions and their child configurations, and the exact assignment target. +Scheduled action children are read separately from their documented endpoint. +Unavailable child reads block verification and expansion; the inventory-only +fallback described above does not turn an unverified write into success. +Existing policies are not automatically repaired or overwritten. + +The task 7 evidence also reports the seven candidate compliance payloads in +the imported catalog: three Baseline, three Advanced, and one Windows. The +write path uses the separately approved payload set under +`Config/CompliancePayloads`; it does not authorize the broader imported policy +catalog. + +Withholding `-IncludeHighRisk` does not hide this inventory. Missing write +authorization is recorded separately as `WriteRiskGate` evidence. When the +gate is supplied, the module creates the approved per-platform compliance +policies, assigns them to the selected scope, and verifies each created object. +Scheduled-action mutation, unsupported platform types, and the broader catalog +remain outside the supported path. + +Each run now produces per-task assessment evidence for these items, alongside +the existing preflight and safety-gate evidence. + +App protection and compliance are platform-specific. Android and iOS/iPadOS +Level 1 app-protection writes are supported in the validated release, but +modern Windows MAM remains beta-only. The approved compliance payloads cover +the supported release platforms; Android Device Owner, AOSP, and the imported +advanced catalog remain guided or blocked. + +## Where this deliberately differs from the guide + +The guide repeatedly says to assign policies to **all users**. For a first +automated run against a live tenant that is an unacceptable blast radius, +particularly for enrollment restrictions, compliance enforcement, and +Conditional Access. + +**This toolkit defaults to a pilot group.** Supply `-PilotGroupId`. Assigning +tenant-wide is an explicit opt-in through `-AssignTenantWide`, which also +requires `-RollbackAcknowledged`. If you want the guide's behavior literally, +you have to ask for it. + +## Safety gates + +Four tasks can affect enrollment or access, so their write paths are gated +rather than default-on. + +| Gate | Required for | Also requires | +|---|---|---| +| `-IncludeHighRisk` | Any high-risk item, including task 7 compliance policy creation | `-CustomerApprovalId` | +| `-EnableComplianceEnforcement` | Task 4 | `-IncludeHighRisk` | +| `-EnableEnrollmentRestrictions` | Task 5 | `-IncludeHighRisk` | +| `-EnableConditionalAccessEnforcement` | Task 10 policy creation; the configured default remains report-only | `-IncludeHighRisk`, `-BreakGlassExclusionsConfirmed`, at least one `-BreakGlassUserIds` or `-BreakGlassGroupIds` value, `-RollbackAcknowledged` | +| `-AssignTenantWide` | Tenant-wide assignment | `-RollbackAcknowledged`, plus `Assignment.AllowTenantWideAssignmentForHighRisk` in configuration | +| `-EnablePolicyCatalogWrite` | Reserved imported-catalog apply path | Always blocked pending API, permission, pilot, and rollback evidence | + +Four of the ten baseline items are classified high risk: the default +compliance setting (task 4), enrollment restrictions (task 5), device +compliance policies (task 7), and Conditional Access (task 10). + +Conditional Access is blocked unless `-IncludeHighRisk` is supplied. A blocked +item is recorded in the report with a `Blocked` disposition and the reason, +rather than silently omitted. + +The default compliance setting, enrollment restriction inventory, and device +compliance policy inventory always run their reads when licensed, even when +high-risk write authorization is withheld. Missing write authorization is +recorded separately as informational `WriteRiskGate` evidence, so the current +tenant state remains visible. + +**Task 7 is high risk even though the guide presents it as routine.** Creating a +compliance policy can mark existing devices noncompliant, and if the customer +already operates Conditional Access requiring a compliant device, whether or not +this toolkit created it, those users lose access immediately. + +**Conditional Access is the highest blast-radius action in the baseline.** A +policy requiring a compliant device, applied before devices are enrolled and +evaluated, denies access to users and can lock administrators out of the tenant. +The current module creates the policy only after every high-risk gate is +supplied. It ensures the Microsoft Intune Enrollment service principal exists, +creates the policy in the configured default state +(`enabledForReportingButNotEnforced`), and reads the state back. Promotion to +enforcement remains a separate decision after emergency-access exclusions, +device compliance, sign-in evidence, propagation, and rollback are verified. + +**Task 4 and task 10 are coupled.** Marking unevaluated devices as not compliant +does nothing on its own, and becomes an access denial the moment the Conditional +Access policy exists. Apply compliance policies and confirm devices are actually +being evaluated before enabling enforcement. + +A run refused by a gate still writes its report. The refusal, and the customer +approval reference when one was supplied, are recorded as evidence. + +## Prerequisites + +- PowerShell 7 or later. Windows PowerShell 5.1 is not supported. +- Microsoft 365 Business Premium or Intune Plan 1. Task 10 additionally needs + Microsoft Entra ID P1, which Business Premium includes. +- An administrator with the appropriate roles for device management and, for + task 10, Conditional Access. +- Delegated consent for the full configured Graph permission set in + `Config\IntuneConfig.psd1`'s `Api.GraphScopes`: six read scopes: + `User.Read`, `LicenseAssignment.Read.All`, + `DeviceManagementConfiguration.Read.All`, `DeviceManagementApps.Read.All`, + `DeviceManagementServiceConfig.Read.All`, and `Policy.Read.All` -- plus + five write scopes tied to the supported, gated write paths -- + `DeviceManagementApps.ReadWrite.All`, + `DeviceManagementServiceConfig.ReadWrite.All`, + `DeviceManagementConfiguration.ReadWrite.All`, + `Policy.ReadWrite.ConditionalAccess`, and `Application.ReadWrite.All`. + `Connect-IntuneServices.ps1` requests every one of these scopes at connect + time for every run, including `-WhatIf`, because Graph consent is fixed at + authentication rather than re-evaluated per module. The safety gates and + `ShouldProcess` still decide whether a state-changing call runs. + Conditional Access listing and readback require the documented + `Policy.Read.All` plus `Policy.ReadWrite.ConditionalAccess` pair. + Interactive sign-in also uses the standard `openid`, `profile`, and `email` + identity scopes. See + [Authentication](#authentication) for the version history behind this + scope set. +- The `Microsoft.Graph.Authentication` and `Microsoft.Graph.DeviceManagement` + PowerShell modules from the same SDK release. Use the + [version-aligned installation commands](docs/Operator-Guide.md#before-you-run), + or pass `-AutoInstallModules` to install a missing matching module to + `CurrentUser` without a prompt. The toolkit keeps an already-loaded + Authentication version and pins DeviceManagement to it, rather than loading + an incompatible newer release. + +## Authentication + +Intune uses the same delegated Microsoft Graph sign-in behavior as Purview. +Supply the administrator UPN for a direct tenant run, or add the customer +tenant's verified domain through `-DelegatedOrganization` for GDAP: + +```powershell +.\Deploy-IntuneBestPractice.ps1 ` + -TenantAdminUpn delegatedadmin@partner.onmicrosoft.com ` + -DelegatedOrganization customer.onmicrosoft.com ` + -WhatIf +``` + +The orchestrator connects once before any setup module runs. It requests the +full configured scope set in `Config\IntuneConfig.psd1`'s `Api.GraphScopes` up +front, before any task runs and regardless of `-WhatIf`, and reuses a cached +Graph context only when the account, every required scope, and the live +tenant (confirmed through `/organization`) all match the run's target -- the +administrator UPN's domain for a direct run, or the customer domain for GDAP. +This tenant check runs for every reuse attempt, not GDAP alone, so a cached +context left over from a different tenant is rejected and reconnected on a +direct run too. For GDAP, the toolkit authenticates directly to the customer +domain. + +**Scope-set version history.** Intune 0.2.0's pilot requested only the five +read scopes needed by the read-only assessments implemented at the time. +Intune 0.3.0's authentication rewrite requests the full eleven-scope set as a +single up-front connect, matching Purview's one-connection-per-run model. +Consenting to those scopes does not bypass the product gates. Standard writes +remain subject to `ShouldProcess` and assignment scope, high-risk writes +require the documented approval switches, and +`-EnablePolicyCatalogWrite` still fails closed. + +Current Microsoft Graph Authentication modules use Windows Authentication +Manager (WAM) on a supported interactive Windows desktop. A first consent, +missing scope, account change, or customer-tenant switch can still require a +browser prompt. Use `-AutoInstallModules` to install a missing Graph module +(`Microsoft.Graph.Authentication` or `Microsoft.Graph.DeviceManagement`) to +`CurrentUser` without a toolkit prompt. + +If a different SDK version or assembly is already loaded, the toolkit stops +with fresh-session recovery guidance instead of reinstalling repeatedly or +attempting to replace loaded assemblies. See +[Graph module version conflicts](docs/Evidence-Troubleshooting.md#graph-module-version-conflicts). +The session guard records the selected SDK version or prerequisite failure; +tenant scopes and sign-in behavior are unchanged. + +If sign-in reports **A window handle must be configured**, use the +[interactive-terminal recovery steps](docs/Evidence-Troubleshooting.md#windows-sign-in-needs-a-window-handle). +The toolkit does not silently switch to device-code or app-only authentication. + +`-NonInteractive` suppresses toolkit confirmation and module-install prompts. +It does not select application authentication and cannot guarantee a +prompt-free first sign-in. Pre-authenticate in an approved interactive session +before using it. Client IDs, certificates, and client secrets are not accepted; +authentication is delegated UPN/GDAP only. + +Setup modules use the orchestrator's verified context and do not reconnect. To +run a setup module directly, establish the Graph connection first in the same +PowerShell session. The [operator guide](docs/Operator-Guide.md) documents the +supported connection and invocation sequence. + +## Usage + +Preview every assessment and selected write without changing the tenant: + +```powershell +cd Products/Intune +.\Deploy-IntuneBestPractice.ps1 -TenantAdminUpn admin@contoso.onmicrosoft.com -WhatIf +``` + +Apply the standard Microsoft 365 Apps and Android/iOS app-protection baseline +to an approved pilot group: + +```powershell +.\Deploy-IntuneBestPractice.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com ` + -PilotGroupId '' +``` + +Do not combine the first high-risk changes into one command. Use the +[staged high-risk commands](docs/Operator-Guide.md#add-high-risk-changes-one-at-a-time) +after customer approval and a [recovery record](docs/Evidence-Troubleshooting.md#recover-a-partial-pilot-deployment) +are complete. Default compliance is a tenant-wide setting even when a pilot +group is supplied. Conditional Access creation stays report-only; enforcement +is a later, separately approved portal decision. + +For a GDAP-delegated preview against a customer tenant: + +```powershell +.\Deploy-IntuneBestPractice.ps1 ` + -TenantAdminUpn admin@partner.onmicrosoft.com ` + -DelegatedOrganization customer.onmicrosoft.com ` + -AutoInstallModules ` + -WhatIf +``` + +Every module is independently runnable and takes `-Config` plus its own +switches, so you can iterate on one task without running the orchestrator, +but it requires an already-authenticated Graph context in the same session +(a setup module no longer reconnects Graph itself). + +## Output + +Each run writes an HTML report and a JSON sidecar to `Reports/`. Both record +every started, created, updated, adopted, skipped, retried, and failed action +with its disposition and reason. Secrets, tokens, and raw tenant exports are +not retained. Pattern-based redaction protects tenant identifiers, tenant +domains, administrator identities, and GUID-shaped identifiers. Review any +service-authored error excerpt before sharing it because arbitrary future error +text is not guaranteed to match a known redaction pattern. Microsoft service +endpoints stay readable so a failure remains diagnosable. + +Run reports contain tenant evidence. Keep them in the approved private evidence +location and do not commit or publish them. + +The final output prints the full paths of successfully saved +`intune-run-report.html` and `intune-run-log.json`, plus a copyable +`Invoke-Item -LiteralPath` command to open the HTML. The browser never opens +automatically, including with `-NonInteractive`. Reporting also runs during +`-WhatIf`, blocked work, and failures after logging has initialized. +Report-write warnings remain visible and do not replace an earlier deployment +error. An existing file is not proof that the current run saved it. + +The `Report` configuration controls the directory and filenames; defaults +remain under the product's `Reports` folder. Reruns overwrite these files. +Preserve the evidence privately before rerunning. + +## Configuration + +All tunables live in `Config/IntuneConfig.psd1`: the baseline item inventory, +license capability mapping, Graph scopes, assignment defaults, policy catalog +manifest, preflight dispositions, and Conditional Access defaults. Do not +hardcode a policy name, identifier, or duration in a module. + +The license SKU and service plan names in that file are **candidates, not +verified facts**. Microsoft catalog names drift, and verifying them is part of +the outstanding work. + +## Status and what is outstanding + +The product is **Available** at the documented pilot-first scope. Six guide tasks have write-capable paths with `ShouldProcess`, retry, and +structured evidence. Five paths have direct post-write readback; app-protection +targeting and assignment require portal verification. The remaining guided +boundaries are Windows automatic +MDM enrollment, Apple certificate setup or replacement, Managed Google Play, +Windows Backup for Organizations, modern Windows MAM, unsupported compliance +platforms, and the imported policy catalog. + +Before broader rollout, complete the per-customer role and GDAP review, confirm +licensing and service availability, and retain pilot evidence. Historical +direct-tenant validation applies to the original write implementation, not to +later authentication or safety-contract changes. The current alignment is +fixture-tested, but a fresh direct/GDAP cold and warm sign-in record and a new +pilot `-WhatIf` remain release follow-ups. Promote Conditional Access only +after report-only sign-in results and emergency-access recovery are verified. diff --git a/Products/Intune/docs/Change-Management-Playbook.md b/Products/Intune/docs/Change-Management-Playbook.md new file mode 100644 index 0000000..34dfd2b --- /dev/null +++ b/Products/Intune/docs/Change-Management-Playbook.md @@ -0,0 +1,188 @@ +--- +title: Intune change-management playbook +layout: default +parent: Intune +--- + +# Intune change-management playbook + +This playbook is for the deployment lead responsible for an Intune onboarding +or baseline review. It covers the customer conversation, assessment, guided +portal work, enrollment pilot, support handoff, and closure record. + +The current script is write-capable. A `-WhatIf` run changes nothing, while an +apply run can create applications, policies, assignments, and a report-only +Conditional Access policy. Neither mode replaces customer approval or proves +every guide task is complete. + +## Phase 0: scope the deployment + +Complete this before authenticating to the tenant. + +| Action | Owner | Required output | +| --- | --- | --- | +| Confirm tenant and licensing. | Deployment lead | Expected tenant, Business Premium or Intune Plan 1 scope, and Entra licensing for later Conditional Access. | +| Select supported platforms and ownership models. | Customer IT owner | Company-owned, personal, or mixed decision for Windows, Android, Apple, and macOS. | +| Decide MDM, MAM, or both for personal devices. | Security and privacy owners | Data-protection and enrollment approach for each platform. | +| Select pilot users and representative devices. | Customer IT owner | Pilot group, device matrix, exclusions, and success measures. | +| Name external identity owners. | Customer IT owner | Apple certificate owner and at least two appropriate Google enterprise owners. | +| Identify emergency-access accounts. | Identity owner | Tested accounts excluded from any later Conditional Access policy. | +| Select evidence and support locations. | Deployment lead | Private report location, change record, support queue, and escalation contacts. | +| Agree rollback and stop conditions. | Change approver | Recovery owner and criteria that stop expansion. | + +Do not put tenant IDs, UPNs, group IDs, Apple IDs, screenshots, or approval +records in the repository. + +Use [pilot group setup](Operator-Guide.md#prepare-a-pilot-group) to create the +assigned security group and locate its object ID. Follow the +[object-specific recovery checklist](Evidence-Troubleshooting.md#recover-a-partial-pilot-deployment) +when recording before-state and stop conditions. + +## Phase 1: prepare the workstation + +1. Use PowerShell 7 in a normal interactive desktop session. +2. Install the documented Microsoft Graph modules for the current user, or plan + to pass `-AutoInstallModules` so the toolkit installs a missing module + without a prompt. +3. Close unrelated PowerShell sessions that may hold a stale Graph context. +4. Confirm the intended administrator account and expected tenant. +5. Create the private evidence location before the run. +6. Review the eleven configured delegated scopes (six read, five write) in the + [configuration reference](Configuration-Reference.md). The orchestrator + requests all eleven at connect time for every run; the five write scopes are + consented ahead of their write paths, which remain gated separately. +7. For a GDAP-delegated administration of a separate customer tenant, plan to + pass `-DelegatedOrganization` with the customer's verified domain. + +The deployment team enters credentials only in the Microsoft sign-in +experience. Do not capture a password in a script, transcript, issue, or chat. + +## Phase 2: preview the intended deployment + +```powershell +cd Products/Intune +./Deploy-IntuneBestPractice.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com ` + -PilotGroupId '' ` + -WhatIf +``` + +After sign-in: + +1. Confirm the displayed tenant before continuing. +2. Allow the supported assessments and write previews to finish. +3. Open `Reports/intune-run-report.html` locally. +4. Confirm no outcome is `Created`, `Updated`, or `Adopted`. +5. Treat every `Failed` module as unresolved. +6. Confirm every `WillChange` target, assignment, and safety gate matches the + approved pilot. +7. Record each guided task with an owner and target date. +8. Move reports to the approved private location. + +The generated reports are tenant evidence. Do not commit them. + +## Phase 3: apply the supported pilot baseline + +The standard apply creates Microsoft 365 Apps and the Android/iOS app +protection policies. High-risk changes require the category switches and +customer approval in the operator guide's +[separate stage commands](Operator-Guide.md#add-high-risk-changes-one-at-a-time). +Default compliance changes the entire tenant, regardless of the pilot group; +it requires its own tenant-wide impact review. + +After the run: + +1. Review every `Created`, `Updated`, and assignment entry. +2. Confirm supported readback is `Verified`; verify app-protection targeting + and assignment in the portal. +3. Confirm the pilot group, not a wider audience, received the assignment. +4. Stop if a partial failure created an object without its intended assignment. +5. Rerun with the same parameters and confirm the result is idempotent. +6. Move the apply and rerun reports to the approved private location. + +Do not combine the first high-risk apply with Conditional Access promotion. + +## Phase 4: verify portal and device state + +Portal navigation can change. Use the linked Microsoft Learn page when a label +has moved. + +| Assessment | Portal verification | Result to compare | +| --- | --- | --- | +| Apple certificate | **Devices > Device onboarding > Enrollment > Apple > Apple MDM Push Certificate** | Active state and renewal date. | +| Default compliance | **Devices > Manage devices > Compliance > Compliance settings** or **Endpoint security > Device compliance > Compliance policy settings** | Treatment of devices without an assigned compliance policy. | +| Enrollment restrictions | **Devices > Device onboarding > Enrollment > Enrollment options > Device platform restriction** | Supported platforms, personal-device rules, priority, and assignments. | +| App protection | **Apps > Manage apps > Protection** | Android and iOS/iPadOS policy presence and assignment scope. | +| Device compliance | **Devices > Manage devices > Compliance > Policies** or **Endpoint security > Device compliance > Policies** | Platform policy presence, assignments, and actions for noncompliance. | + +If the report and portal do not agree, retain sanitized diagnostics, stop the +rollout, and investigate before changing the tenant again. + +## Phase 5: complete one guided change at a time + +Use this cycle for a separately approved portal action: + +1. Identify the exact guide task and Microsoft Learn procedure. +2. Record current state without exporting unnecessary customer data. +3. Confirm approval, pilot scope, owner, maintenance window, and recovery. +4. Apply only the approved change in the portal. +5. Verify portal readback and, where relevant, device receipt. +6. Stop if the result differs from the approved intent. +7. Rerun the toolkit when it has a supported read for that task. +8. Record the outcome and remaining exceptions in the customer change record. + +Do not batch enrollment restrictions, compliance policy, and Conditional +Access into one change. Separate changes make impact and recovery observable. + +## Phase 6: enroll and observe a pilot + +| Check | Evidence | +| --- | --- | +| User can start and finish enrollment. | Enrollment timestamp and supported device method. | +| Device appears in the expected tenant and ownership class. | Portal readback retained privately. | +| Required applications arrive. | Device-side confirmation. | +| Platform policy arrives. | Intune device status and device-side result. | +| Expected compliance issues are understandable and remediable. | Support case or pilot feedback record. | +| Emergency-access accounts remain usable. | Identity-owner confirmation before any access enforcement. | +| Support team can route failures. | Named queue, owner, and escalation path. | + +Use representative devices. One successful Windows enrollment does not prove +Android, Apple, or macOS readiness. + +## Phase 7: make the access decision separately + +Conditional Access is not a day-zero enrollment setting. The toolkit can +create the enrollment policy report-only after every high-risk gate is +supplied. Run that path only after the selected platform policies reach pilot +devices and compliance results are stable. + +Before enforcement, require: + +- approved pilot users and explicit exclusions; +- verified emergency-access accounts whose object IDs were supplied through + `-BreakGlassUserIds` or `-BreakGlassGroupIds`; +- the correct Microsoft Intune Enrollment target resource; +- MFA and compliant-device grant controls reviewed together; +- report-only sign-in evidence; +- propagation time and helpdesk readiness; +- a tested disable or exclusion recovery path; and +- a named human approval record. + +The toolkit does not promote the policy to enforcement by default. + +## Closure checklist + +- [ ] Every supported platform has an owner and current disposition. +- [ ] Every failed assessment is resolved or explicitly blocks closure. +- [ ] Every selected write has supported readback or documented portal/device + verification, plus an idempotent rerun. +- [ ] Guided tasks have portal evidence, an owner, and a review date. +- [ ] Pilot enrollment and device-side policy receipt are recorded. +- [ ] Support ownership and user communications are active. +- [ ] Reports are stored privately and absent from source control. +- [ ] Remaining risks and deferred platforms have named owners. +- [ ] Any later enforcement decision is recorded separately. + +For user communications, use the +[end-user adoption guide](End-User-Adoption-Guide.md). For technical failures, +use [evidence and troubleshooting](Evidence-Troubleshooting.md). \ No newline at end of file diff --git a/Products/Intune/docs/Configuration-Reference.md b/Products/Intune/docs/Configuration-Reference.md new file mode 100644 index 0000000..0f30420 --- /dev/null +++ b/Products/Intune/docs/Configuration-Reference.md @@ -0,0 +1,184 @@ +--- +title: Intune configuration reference +layout: default +parent: Intune +--- + +# Intune configuration reference + +`Config/IntuneConfig.psd1` is the source of product behavior. Modules must not +hardcode policy names, licenses, assignment scope, or report paths. + +| Section | Purpose | Release note | +| --- | --- | --- | +| `ProductName`, `ProductVersion`, `ManagedByTag` | Product identity and future managed-object ownership marker. | The tag never authorizes adoption by itself. | +| `BestPracticeItems` | Ten guide tasks, priority, module, license capability, risk, and guide mapping. | Task keys are toolkit identifiers, not Microsoft recommendation IDs. | +| `LicenseCapabilities` | Candidate SKU and service-plan mappings. | Candidates require human and pilot verification; catalog names can change. | +| `Api` | Graph v1.0 and beta base URIs, delegated scopes, and required commands. | Requests all eleven configured scopes (six read, five write) at connect time for every run. Runtime gates and `ShouldProcess` still control each write. | +| `Assignment` | Pilot-first and tenant-wide safety policy. | Tenant-wide high-risk assignment is disabled by default. | +| `PolicyCatalog` | Path and required entry count for the normalized 19-payload candidate catalog. | Payload hashes are verified at load time. Every entry remains blocked for apply. | +| `Preflight` | Guided dispositions for unsupported or interactive tasks. | Values must use the approved disposition vocabulary. | +| `ApplePushCertificate` | Pre-expiry warning interval. | `RenewalWarningDays` must be an integer from 1 through 90. | +| `DefaultCompliance` | Device-without-policy treatment and compliance check-in threshold. | The write is high risk; `CheckinThresholdDays` defaults to 30. | +| `ConditionalAccess` | Report-only state, emergency-access review requirement, policy name, and Intune Enrollment app ID. | The current release rejects any default state other than `enabledForReportingButNotEnforced`; promotion to enforcement is a separate portal decision. | +| `AppDeployment`, `AppProtection`, `DeviceCompliance`, `EnrollmentRestrictions` | Desired state for the six supported write paths. | Names, settings, payload paths, assignments, and application targets remain configuration-driven. | +| `Report` | HTML, JSON, and output-directory names. | Reports contain private tenant evidence. | + +## Command-line controls + +`-Skip*` switches omit modules intentionally; they are not fixes for module +failures. `-NoLicenseAutoDetect` disables automatic SKU checks and reduces the +strength of applicability evidence. `-PilotGroupId` is the safe default +assignment scope for supported writes and is also used to classify assessment +results. A separately approved tenant-wide path requires rollback +acknowledgment and the configuration permission for tenant-wide high-risk +assignment. + +`-ClientId`, `-TenantId`, and `-CertificateThumbprint` are not accepted. +Authentication is delegated UPN/GDAP only: supply `-TenantAdminUpn` for the +target administrator identity, and add `-DelegatedOrganization` when the run +is a GDAP-delegated administration of a separate customer tenant. See +[Authentication](../README.md#authentication) for the session-guard and +reconnect behavior. + +`-AutoInstallModules` installs a missing `Microsoft.Graph.Authentication` or +`Microsoft.Graph.DeviceManagement` module to `CurrentUser` without prompting. +DeviceManagement is pinned to the Authentication version selected for the +run. An already-loaded Authentication version takes precedence over newer +installed copies; a conflicting loaded module requires a fresh PowerShell 7 +session. No module is removed or upgraded in place to bypass that conflict. +`-NonInteractive` suppresses toolkit confirmation and module-install prompts; +without `-AutoInstallModules` it fails fast instead of installing a missing +module, and it never selects application/certificate authentication. + +`-IncludeHighRisk`, category enable switches, customer approval, emergency +access confirmation, and rollback acknowledgment are independent gates. One +gate never substitutes for another. + +`Report.OutputDirectory`, `Report.HtmlReportFileName`, and +`Report.JsonLogFileName` keep their existing behavior. The final console output +prints the resolved paths only after successful writes and includes a manual +open command for the HTML. Report-write warnings do not turn failed +persistence into a success message or hide an earlier deployment failure. + +`-EnablePolicyCatalogWrite` is a reserved fail-closed switch. It never enables +a tenant write today. The catalog source used Graph beta commands, so apply +behavior remains blocked. + +### Control state in version 0.3.0 + +| Control | Current state | Effect | +| --- | --- | --- | +| `-TenantAdminUpn` | Active, required | Selects the expected administrator identity for delegated sign-in and tenant verification. | +| `-DelegatedOrganization` | Active | Supplies the expected customer domain for a GDAP-delegated administration run; Graph authenticates directly to this tenant. | +| `-AutoInstallModules` | Active | Installs a missing `Microsoft.Graph.Authentication` or `Microsoft.Graph.DeviceManagement` module to `CurrentUser` without prompting. | +| `-ConfigPath` | Active | Loads an alternate validated Intune configuration file. | +| `-WhatIf` | Active | Performs assessments and records selected writes as `WillChange` without mutating the tenant. | +| `-NoLicenseAutoDetect` | Active, evidence-reducing | Skips automatic SKU discovery and weakens applicability evidence. | +| `-Skip*` | Active | Intentionally omits the selected module. It does not fix a module failure. | +| `-PilotGroupId` | Active | Assigns supported policies and applications to the approved pilot group and narrows assessment scope. | +| `-AdoptExisting` | Reserved compatibility parameter | Current Intune writers are create-only, leave same-purpose existing objects unchanged, and do not emit `Adopted`. | +| `-IncludeHighRisk` and `-CustomerApprovalId` | Active gate | Opens task 7 and is required by the task 4, 5, and 10 category switches. | +| `-EnableComplianceEnforcement` | Active high-risk gate | Allows `secureByDefault=True` after assessment. | +| `-EnableEnrollmentRestrictions` | Active high-risk gate | Allows the configured Android and iOS/iPadOS restrictions to be created and assigned. | +| `-EnableConditionalAccessEnforcement` | Active high-risk gate | Selects creation of the enrollment Conditional Access policy. The configured default state remains report-only. | +| `-BreakGlassExclusionsConfirmed` | Active high-risk gate | Records the operator's emergency-access confirmation before Conditional Access creation. | +| `-BreakGlassUserIds`, `-BreakGlassGroupIds` | Active, required for task 10 | Writes at least one operator-verified emergency-access user or group into the Conditional Access exclusion list. | +| `-AssignTenantWide` | Active, explicit | Uses all licensed users instead of the pilot group when configuration permits it and rollback is acknowledged. | +| `-EnablePolicyCatalogWrite` | Reserved, fail closed | Always refuses the run. | + +`-TenantId`, `-ClientId`, and `-CertificateThumbprint` were removed in version +0.3.0. They no longer exist as parameters on the orchestrator or the Graph +connect helper; supplying them fails parameter binding. + +Do not include dormant switches in a normal assessment command. Their presence +does not make a future operation supported. + +## Delegated Microsoft Graph permissions + +`Connect-IntuneServices.ps1` requests all eleven scopes configured in +`Api.GraphScopes` at connect time, up front, for every run -- including a +`-WhatIf` preview -- because Graph consent is fixed at authentication and is +not re-evaluated per module. Six are read scopes used by assessments, +preflight, and Conditional Access readback: + +- `User.Read` for the selected tenant identity fields (`id`, `displayName` and + `verifiedDomains`) from `/organization`. These fields do not require + `Organization.Read.All` in a delegated session. +- `LicenseAssignment.Read.All` for subscribed SKU discovery. +- `DeviceManagementConfiguration.Read.All` for compliance-policy inventory. +- `DeviceManagementApps.Read.All` for app-protection inventory and the + documented `Get-MgDeviceManagement` settings read. +- `DeviceManagementServiceConfig.Read.All` for Apple certificate and enrollment + restriction inventory. +- `Policy.Read.All` for Conditional Access policy listing and readback. + +The tenant-identity permission follows [List organizations](https://learn.microsoft.com/graph/api/organization-list?view=graph-rest-1.0), +checked September 25, 2026. Do not add broader consent solely because the +endpoint is `/organization`; verify the requested fields and authentication +mode first. Application permissions are a different contract. + +The remaining five are write scopes used by the supported, gated write paths: + +- `DeviceManagementApps.ReadWrite.All` for the M365 Apps and app protection + write paths. +- `DeviceManagementServiceConfig.ReadWrite.All` for the enrollment restriction + write path. +- `DeviceManagementConfiguration.ReadWrite.All` for the default compliance and + device compliance policy write paths. +- `Policy.ReadWrite.ConditionalAccess` for the device-based Conditional Access + write path. +- `Application.ReadWrite.All` to ensure the Intune enrollment service + principal exists. + +Consenting to a write scope does not execute a write. `ShouldProcess`, license +applicability, assignment scope, ownership checks, and the high-risk gates are +evaluated after connection. Earlier Intune 0.2.0 pilot builds requested only +the original five read scopes above. The 0.3.0 authentication rewrite requests +the full eleven-scope set as a single up-front connect, matching Purview's +one-connection-per-run model. Conditional Access reads use Microsoft's +documented `Policy.Read.All` plus `Policy.ReadWrite.ConditionalAccess` +permission pair; consenting only the write scope can produce `403` responses +when listing or reading policies. + +Interactive authentication also uses the standard `openid`, `profile`, and +`email` identity scopes. These support sign-in identity and do not add an +Intune resource permission. + +Delegated connections reuse a cached Graph context only when its account, +every required scope, and the live tenant (confirmed through `/organization`) +match the run's target -- exactly like Purview, and unconditionally for both +a direct run and a GDAP run. There is no `ContextScope Process` override: the +default Microsoft Graph PowerShell authentication persistence behavior +applies. Authentication is delegated UPN/GDAP only; no other authentication +mode is available. + +## Policy catalog + +`Config/PolicyCatalog/catalog.psd1` is the catalog manifest. It records every +payload's stable key, relative path, tier, platform, policy kind, source Graph +profile, original SHA-256 digest, normalized SHA-256 digest, and apply status. + +`Modules/Get-IntunePolicyCatalog.ps1` verifies the manifest and payloads before +returning catalog metadata. It rejects path traversal, duplicate keys or paths, +hash mismatches, invalid JSON, unexpected Graph discriminators, exported +root-level IDs and timestamps, assignments, and group IDs. + +The 19 JSON payloads are candidate configuration input, not tenant evidence and +not an approved desired state. They are separate from the approved compliance +payloads under `Config/CompliancePayloads`. Contributor reuse is approved. Do +not change an entry's `ApplyStatus` without the recorded product-owner, +security, permission, API, and pilot decisions. + +## Changing configuration + +Syntax-check the data file after editing it: + +```powershell +pwsh -NoProfile -Command "Import-PowerShellDataFile '.\Config\IntuneConfig.psd1' | Out-Null" +``` + +Changing a default, task scope, permission, guided workflow, risk, assignment, +or report field requires a full `-WhatIf` review and an approved pilot before +production use. Repository contributors must also follow the private +maintainer verification process before promotion. diff --git a/Products/Intune/docs/Deployment-Framework.md b/Products/Intune/docs/Deployment-Framework.md new file mode 100644 index 0000000..40967f5 --- /dev/null +++ b/Products/Intune/docs/Deployment-Framework.md @@ -0,0 +1,86 @@ +--- +title: Intune deployment framework +layout: default +parent: Intune +--- + +# Intune deployment framework + +The Intune baseline is a sequence, not a collection of independent settings. +Enrollment prerequisites must exist before devices enroll. Platform policies +must reach pilot devices before compliance is used for access. Conditional +Access belongs at the end, after enrollment and compliance are proven. + +The current toolkit automates assessment plus six supported write paths. Guided +portal changes, device enrollment, Conditional Access promotion, and broader +assignment remain separate customer-approved work. + +## The safe sequence + +| Stage | Purpose | Current toolkit role | Exit evidence | +| --- | --- | --- | --- | +| 0. Decide | Select supported platforms, ownership model, pilot users, support owner, and evidence location. | Documents scope and validates local configuration. | Approved scope and named owners. | +| 1. Preview | Establish tenant identity, licensing, five assessments, six planned writes, and remaining guided boundaries. | Runs assessments under `-WhatIf`, records `WillChange`, and writes HTML and JSON reports. | No failed reads, no blocked prerequisite, and no applied tenant write. | +| 2. Prepare enrollment | Complete Windows, Apple, and Android prerequisites under change control. | Reads Apple certificate health; guides other work. | Portal evidence for each supported platform. | +| 3. Apply pilot policy | Configure Microsoft 365 Apps, app protection, default compliance, enrollment restrictions, and per-platform compliance for the approved pilot. | Applies the supported standard and high-risk paths; Windows backup remains guided. | Supported readback, portal or device verification, assignment proof, idempotent rerun, and recovery owner. | +| 4. Enroll a pilot | Enroll representative devices and confirm applications, policy check-in, and support readiness. | Does not enroll devices. | Successful enrollment and device-side validation. | +| 5. Observe compliance | Confirm devices receive policy and resolve expected noncompliance before access enforcement. | Reruns assessment and verifies the applied objects. | Stable compliant-device population and exception list. | +| 6. Protect access | Create the enrollment Conditional Access policy report-only, then make a separate enforcement decision. | Creates and reads back the policy only after every high-risk gate and at least one emergency-access exclusion ID are supplied. | Report-only review, exclusion proof, approval, and tested rollback. | + +Do not skip from Stage 1 to Stage 6. Requiring a compliant device before a +pilot device can enroll, receive policy, and become compliant can deny access +and block the enrollment process the baseline is meant to protect. + +## Decision flow for each guide task + +1. **Can the current script assess or apply the task?** Review the task + disposition in + [scenarios and capabilities](Scenarios.md). +2. **Did the assessment succeed?** Resolve `Failed` outcomes before relying on + the report. +3. **Is the task guided or blocked?** Follow the official source and the + [device enrollment deployment guide](Device-Enrollment-Deployment-Guide.md). +4. **Would the action change tenant or device behavior?** Require customer + approval, a pilot group, before-state evidence, and a recovery plan. +5. **Can the result be verified?** Capture portal and device-side readback. +6. **Did the result match intent?** Rerun the toolkit where it has a supported + read, record guided closure elsewhere, and retain evidence privately. + +## Safety gates + +| Gate | Protects against | Required decision | +| --- | --- | --- | +| Expected tenant identity | Cross-tenant work | Confirm tenant before any assessment or portal action. | +| Least-privilege delegated scopes | Excess consent | Use only the eleven scopes documented in the configuration reference; write scopes are used only after applicability, `ShouldProcess`, assignment, and high-risk gates. | +| Pilot group | Tenant-wide user or device impact | Use representative users and devices before broader assignment. | +| Customer approval | Unowned risk | Record who approved the change and its scope. | +| Emergency-access exclusions | Administrator lockout | Test excluded accounts before Conditional Access enforcement. | +| Readback | False success | Verify portal state and device receipt after a change. | +| Recovery plan | Extended outage | Define how to remove assignment, restore state, or stop rollout. | + +`-WhatIf` previews the active write paths without changing the tenant. It does +not convert `-EnablePolicyCatalogWrite` into an approved capability; that +reserved switch continues to refuse the run. + +## Evidence flow + +The toolkit creates three local artifacts under `Products/Intune/Reports/`: + +- `intune-run-report.html`, the operator summary. +- `intune-run-log.json`, the structured action record. +- `intune-applicability.json`, the task applicability record. + +Move tenant reports to the approved private customer location. Do not commit +them, attach them to a public issue, or paste raw contents into an AI prompt. +The repository may contain only sanitized validation conclusions. + +## First-party references + +- [Device management and application management in Microsoft 365 Business Premium](https://learn.microsoft.com/microsoft-365/admin/security-and-compliance/m365bp-devices-enrollment) +- [Microsoft Intune enrollment guide](https://learn.microsoft.com/intune/device-enrollment/guide) +- [Microsoft Intune planning guide](https://learn.microsoft.com/intune/fundamentals/planning-guide) +- [Microsoft Intune setup deployment guide](https://learn.microsoft.com/intune/fundamentals/setup-migration) + +These pages remain the product authority. The toolkit narrows their rollout to +a pilot-first sequence and blocks automation where supportability or recovery +is not established. \ No newline at end of file diff --git a/Products/Intune/docs/Device-Enrollment-Deployment-Guide.md b/Products/Intune/docs/Device-Enrollment-Deployment-Guide.md new file mode 100644 index 0000000..152b72f --- /dev/null +++ b/Products/Intune/docs/Device-Enrollment-Deployment-Guide.md @@ -0,0 +1,283 @@ +--- +title: Intune device enrollment deployment guide +layout: default +parent: Intune +--- + +# Intune device enrollment deployment guide + +Use this guide to prepare a customer tenant for device enrollment using the +Device Management Deployment Guide for Small Business. It separates the guide's +required outcome from the current toolkit capability. + +The current toolkit is **write-capable and pilot-first**. Run the single +orchestrator with `-WhatIf` first to assess available state, preview the +selected writes, and create private HTML and JSON evidence: + +```powershell +cd Products/Intune +.\Deploy-IntuneBestPractice.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com ` + -PilotGroupId '' ` + -WhatIf +``` + +Do not interpret a successful preview as confirmation that all guide tasks are +complete. Review the report, apply the approved pilot scope, complete guided +portal and third-party work, retain customer change approval, and rerun to +confirm readback and idempotency. + +For your first run, follow [pilot group setup](Operator-Guide.md#prepare-a-pilot-group). +For automation, use the [separate high-risk stage commands](Operator-Guide.md#add-high-risk-changes-one-at-a-time), +not all portal changes on this page in one session. The guide's references to +All users are source recommendations, not the safe scope for a first pilot. + +## Before starting + +1. Confirm the customer tenant, intended platforms, and deployment owner. +2. Confirm Business Premium or Intune Plan 1 licensing for the affected users. +3. Identify emergency-access accounts before changing compliance or Conditional + Access settings. +4. Decide which platforms are supported. Do not configure a platform that the + customer will not enroll and support. +5. Store reports and change evidence in the approved private customer location. + Do not commit reports, exports, credentials, or approval records to source + control. + +## Priority 1: enrollment foundation + +### Windows automatic enrollment + +In the Intune admin center, go to **Devices > Device onboarding > Enrollment > +Windows > Automatic Enrollment**. Set the MDM user scope for the Microsoft +Entra users whose Windows devices the customer intends to manage. Use `All` or +`Some` when Windows BYOD enrollment is intended. + +Leave the WIP user scope set to `None`. Windows Information Protection is +deprecated and is not a substitute for Microsoft Purview Information Protection +or Endpoint DLP. + +**Toolkit boundary:** guided. The repository has no approved GA shell +write-and-readback path for this setting. The report records the work as guided +until the operation is verified. + +### Apple MDM push certificate + +For iOS/iPadOS and macOS, go to **Devices > Device onboarding > Enrollment > +Apple > Apple MDM Push Certificate**: + +1. Grant Microsoft permission to send user and device information to Apple. +2. Download and securely retain the certificate signing request. +3. Use the Apple Push Certificates Portal to create or renew the certificate. +4. Return to Intune, enter the Apple ID that owns the certificate, and upload + the certificate. + +Use a customer-owned Apple ID with a documented renewal owner. A certificate +expiry can interrupt Apple device management. + +See [Get an Apple MDM push certificate](https://learn.microsoft.com/intune/device-enrollment/apple/create-mdm-push-certificate) +for the current Microsoft procedure and annual renewal requirements. + +**Toolkit boundary:** the script reads certificate health and warns before +expiry. It does not automate Apple sign-in, certificate creation, or certificate +upload because those recovery and ownership steps require separate validation. + +### Managed Google Play + +For Android Enterprise, go to **Devices > Device onboarding > Enrollment > +Android > Managed Google Play**: + +1. Grant Microsoft permission to send user and device information to Google. +2. Select **Launch Google**. +3. Confirm the Microsoft Entra account associated with Android Enterprise + management for this tenant. +4. Complete Google Admin account creation and select **Allow and create + account** when prompted. + +Use a customer-controlled identity with a working mailbox and record at least +two appropriate Google enterprise owners for continuity. + +**Toolkit boundary:** guided. Google consent and account association are +interactive. The toolkit does not automate browser steps or a destructive +disconnect. + +### Default compliance policy settings + +Go to **Devices > Manage devices > Compliance > Compliance settings**. The same +settings are also available under **Endpoint security > Device compliance > +Compliance policy settings**. Set **Mark devices with no compliance policy +assigned as** `Not compliant`. The guide's default compliance-status period is +30 days. Retain it unless the deployment team has a documented reason to select +a value from 1 through 120 days. + +This setting becomes access-impacting once device-based Conditional Access is +enforced. Confirm enrolled devices receive the intended compliance policy before +enabling Priority 3 enforcement. It affects the whole tenant even when +`-PilotGroupId` is supplied; review existing Conditional Access and devices +outside the pilot before changing it. + +**Toolkit boundary:** the script always assesses `secureByDefault`. With +`-IncludeHighRisk -EnableComplianceEnforcement` and a customer approval ID, it +sets the value to true and reads it back. + +## Priority 2: device policies + +### Enrollment restrictions + +Go to **Devices > Device onboarding > Enrollment > Enrollment options > Device +platform restriction**. Create a restriction for each platform that needs one: + +1. Select Windows, Android, macOS, or iOS/iPadOS. +2. Name and describe the restriction. +3. Configure platform enrollment, personally owned enrollment, Android + manufacturer blocks, and allowed operating-system version range as needed. +4. Add scope tags only when the customer's RBAC design requires them. +5. Assign at least one group. The guide permits All users. + +When MAM is used for personal devices, consider blocking personal-device MDM +enrollment so users do not enroll devices unintentionally. + +**Toolkit boundary:** the script inventories existing restrictions and +assignments. With +`-IncludeHighRisk -EnableEnrollmentRestrictions -CustomerApprovalId `, it +creates the configured Android and iOS/iPadOS personal-enrollment restrictions +and assigns the selected scope. + +### App protection policies + +Go to **Apps > Manage apps > Protection > Create** and select the intended +platform. The guide's basic recommendation targets Core Microsoft Apps: + +- Block organization-data backup to iTunes and iCloud. +- Allow organization-data transfer only to policy-managed apps. +- Block saving organization-data copies. +- Allow OneDrive and SharePoint as selected save destinations. + +Configure the platform's access-requirement and conditional-launch settings, +then assign the policy to a user group. Android and iOS/iPadOS settings are +different and must be reviewed independently. + +**Toolkit boundary:** the script inventories Android and iOS/iPadOS policy, +assignment, and target-app state. It also creates the configured Level 1 +policies, targets the core Microsoft apps, and assigns the selected scope. +Modern Windows MAM remains outside the supported write scope. + +### Device compliance policies + +For Windows 10/11, create a Windows 10/11 compliance policy and require: + +- BitLocker, Secure Boot, and code integrity. +- Firewall, TPM, antivirus, and antispyware. +- Defender antimalware, current security intelligence, and real-time + protection. + +Set **Mark device noncompliant** to one day. The guide assigns the policy to +All users. For other platforms, require passcode, device encryption, and code +integrity where those controls are available. + +Do not apply a broad compliance policy until the customer understands the +effect on existing Conditional Access. A policy can immediately affect access +for devices that fail it. + +**Toolkit boundary:** the script inventories supported policy types. With +`-IncludeHighRisk -CustomerApprovalId `, it creates and assigns the +approved per-platform compliance payloads. It does not apply the broader +imported policy catalog or unsupported platform types. + +### Microsoft 365 Apps + +Go to **Apps > All Apps > Create**, select **Windows 10 and later** under +Microsoft 365 Apps, then: + +1. Review the suite information. +2. Set default file format to **Office Open Document Format**. +3. Set update channel to **Monthly Enterprise Channel**. +4. Assign the deployment. The guide uses All users. + +**Toolkit boundary:** the toolkit creates the configured Microsoft 365 Apps +deployment through the validated beta `officeSuiteApp` path, assigns the +selected scope, and reads back the display name and update channel. + +### Enterprise State Roaming + +The source guide enables **Users may sync settings and app data across devices** +for All users. Windows devices must use Windows 10 version 21H2 or later, or +Windows 11, and authenticate with a Microsoft Entra identity. Hybrid-joined +devices require the documented Entra hybrid setup. + +Microsoft has since moved this management area to Windows Backup for +Organizations policy management. Follow the current Microsoft administration +guidance for the customer's Windows estate. + +In the Intune admin center, open **Devices > Device onboarding > Enrollment > +Windows > Windows Backup and Restore**. The restore page requires backup to be +configured in Settings Catalog before it can restore a user's settings. + +**Toolkit boundary:** guided while the policy payload, assignment, readback, +and recovery contract remain unverified. + +## Priority 3: Conditional Access for Intune enrollment + +Create this policy only after Priority 1 and the selected Priority 2 compliance +policies are validated. Start with a small test group as the guide recommends. + +1. In Intune, go to **Devices > Conditional Access > Create new policy**. +2. Include the approved test users or groups and exclude emergency-access + accounts. +3. Under target resources, select **Microsoft Intune Enrollment**. +4. Grant access only when **Require multifactor authentication** and **Require + device to be marked compliant** are both satisfied. Select **Require all the + selected controls**. +5. Set sign-in frequency to **Every time**. +6. Validate sign-in behavior, enrollment, and emergency-access accounts before + enabling the policy for a broader group. + +New tenants may not have the Microsoft Intune Enrollment service principal. An +Entra administrator must create it before targeting the application. Its app ID +is `d4ebce55-015a-49b5-a083-c84d1797ae8c`. + +**Toolkit boundary:** with every documented high-risk gate, the module ensures +the service principal exists and creates the policy in the configured +report-only state. The operator supplies the verified exclusion object IDs +through `-BreakGlassUserIds` or `-BreakGlassGroupIds`; the module writes and +reads those exclusions back. Verify them and the sign-in results in the portal +before any separate enforcement decision. + +After creating a missing Microsoft Intune Enrollment service principal, the +toolkit verifies that Graph can read it back before submitting the Conditional +Access policy. If the service principal is not visible yet, wait for Entra +propagation, confirm the Enterprise application in the portal, and rerun. +Conditional Access policy readback can also briefly return `404` immediately +after creation; the toolkit retries that specific post-create readback before +failing closed. + +## Enroll devices and support adoption + +After the tenant baseline is ready, follow the platform-specific enrollment +guides: + +- [Windows enrollment guide](https://aka.ms/WindowsEnrollmentGuide) +- [iOS/iPadOS enrollment guide](https://aka.ms/iOSEnrollmentGuide) +- [Android enrollment guide](https://aka.ms/AndroidEnrollmentGuide) +- [macOS enrollment guide](https://aka.ms/macOSEnrollmentGuide) + +The [Intune Adoption Kit](https://aka.ms/intuneadoptionkit) provides additional +customer communication and demonstration material but requires a Microsoft +sign-in. Use the local [end-user adoption guide](End-User-Adoption-Guide.md) for +copy-ready pilot and enforcement templates. Confirm that the support desk, +customer deployment owner, and platform owners know where to route enrollment +failures before inviting users to enroll. + +## After each tenant + +1. Review the HTML and JSON reports with the customer deployment owner. +2. Record guided portal actions, target groups, exceptions, and validation + results in the customer change record. +3. Rerun with the same effective parameters after toolkit or portal changes + and confirm readback and idempotency. +4. Stop and escalate if a report shows a wrong tenant, missing role or license, + malformed response, blocked safety gate, or unexpected noncompliance. + +See [Evidence and troubleshooting](Evidence-Troubleshooting.md) for recovery +guidance and [Future Intune write capabilities](Future-Write-Capabilities.md) +for the verified-write release gates. diff --git a/Products/Intune/docs/End-User-Adoption-Guide.md b/Products/Intune/docs/End-User-Adoption-Guide.md new file mode 100644 index 0000000..178e0a7 --- /dev/null +++ b/Products/Intune/docs/End-User-Adoption-Guide.md @@ -0,0 +1,209 @@ +--- +title: Intune end-user adoption guide +layout: default +parent: Intune +--- + +# Intune end-user adoption guide + +This guide is for employees whose organization is preparing a Microsoft Intune +device or application-management rollout. It explains what enrollment can look +like, what information to have ready, and where to get help. + +## Important: a preview does not change your device + +When IT runs the toolkit with `-WhatIf`, it does not enroll your device, +install an app, change compliance, or block a sign-in. An approved apply can +assign applications and policies to a pilot group. You need to act only when +your organization sends a pilot or enrollment notice with a date and support +contact. + +The communication templates later in this guide are for that separately +approved rollout. Deployment teams must edit the bracketed fields and remove +sections that do not apply. + +## MDM and MAM in plain language + +Your organization can protect work in two common ways: + +- **Mobile device management (MDM)** enrolls the device in Intune. The + organization can apply device settings, require security controls, install + approved applications, and remove organization data according to its policy. +- **Mobile application management (MAM)** protects organization data inside + supported applications. A personal device does not have to be fully enrolled + for MAM to apply. + +Your invitation should say which approach applies. Ask the support team before +enrolling when the invitation and your device ownership do not match. + +## What to prepare for a pilot + +- Use a supported device and operating-system version. +- Install available operating-system updates. +- Know whether the device is company-owned or personal. +- Use the organization account named in the invitation. +- Have your MFA method available. +- Back up personal data according to your normal process. +- Use a reliable internet connection and allow time for applications and + policies to synchronize. +- Keep the support contact and pilot reference from the invitation. + +Do not remove another organization's management profile or reset a device +unless the support team has approved the migration plan. + +## What you might see + +### During enrollment + +You may be asked to sign in, complete MFA, install Company Portal, accept an +organization management profile, or wait while required applications and +policies arrive. The exact steps depend on platform, device ownership, and +enrollment method. + +### After enrollment + +Company Portal can show whether the device meets organization requirements. A +compliance issue might ask you to enable encryption, use a passcode, update the +operating system, or correct another security setting selected by your +organization. + +### Inside managed applications + +An app protection policy can require an application PIN and can limit copying, +saving, or opening organization data in personal applications. It protects +work data; it does not install the managed application by itself. + +### At sign-in + +A later Conditional Access rollout can require MFA and a compliant device. +Your organization should announce enforcement only after the enrollment pilot, +device-policy delivery, support process, and emergency-access testing are +complete. + +## Before contacting support + +Record: + +- the date and time of the problem; +- device platform and operating-system version; +- whether the device is company-owned or personal; +- the step you were completing; +- the application or portal that showed the message; +- the exact error code or correlation ID, if shown; and +- a screenshot only when your organization permits it and it contains no + password, token, QR code, or sensitive personal information. + +Do not send your password, MFA code, recovery key, certificate, or access token +to support. + +## Frequently asked questions + +**Does the toolkit enroll my device?** + +No. The toolkit can assign applications and policies, but enrollment remains a +separate user or provisioning process. A `-WhatIf` preview changes nothing. + +**Can IT see all personal content on my device?** + +Visibility and controls depend on platform, ownership, and enrollment method. +Review your organization's privacy notice and the information shown during +enrollment. Ask the support team when the notice is unclear. + +**Why does Company Portal say my device is noncompliant?** + +Open the device status and review the setting that needs attention. Policy +delivery and evaluation can take time after enrollment or a change. Follow only +the remediation approved by your organization. + +**Why can I not copy work data into a personal app?** + +An app protection policy can keep organization data inside approved managed +applications. Use the approved application or contact support for the business +workflow. + +**Should I factory reset or remove management to fix enrollment?** + +Not unless the support team instructs you through an approved recovery plan. +Those actions can remove data or make the problem harder to diagnose. + +## Communication templates + +### Template A: pilot invitation + +> **Subject:** Invitation to the [ORGANIZATION] Intune device pilot +> +> You are invited to the Microsoft Intune pilot beginning [DATE]. The pilot +> helps us confirm device enrollment, required applications, and security +> settings before a wider rollout. +> +> Device: [PLATFORM AND OWNERSHIP] +> +> Management approach: [MDM OR MAM] +> +> Enrollment method: [METHOD OR LINK] +> +> Complete by: [DATE] +> +> Before starting, install operating-system updates, have your MFA method +> available, and allow [EXPECTED TIME]. +> +> Support: [CHANNEL, HOURS, AND CASE INSTRUCTIONS] +> +> The earlier toolkit preview did not change your device. Your action starts +> only when you follow this approved pilot invitation. + +### Template B: enrollment day + +> **Subject:** Intune pilot starts today +> +> The Intune pilot is open. Follow [APPROVED ENROLLMENT INSTRUCTIONS] from your +> [COMPANY-OWNED OR PERSONAL] [PLATFORM] device. +> +> You may be asked to sign in, complete MFA, install Company Portal, accept a +> management profile, or wait for required applications and policies. Keep the +> device online until Company Portal finishes its first check. +> +> Do not factory reset the device or remove another management profile unless +> support confirms the migration step. +> +> Support: [CHANNEL AND CASE REFERENCE] + +### Template C: remediation notice + +> **Subject:** Action needed for your managed device +> +> Intune reports that your [PLATFORM] device needs attention for [APPROVED +> PLAIN-LANGUAGE REQUIREMENT]. Complete these steps by [DATE]: +> +> [APPROVED REMEDIATION STEPS] +> +> After the change, open Company Portal and select the approved device check or +> synchronization action. Policy evaluation can take time. +> +> Contact [SUPPORT CHANNEL] if the device still reports noncompliant. Do not +> send passwords, MFA codes, recovery keys, or access tokens. + +### Template D: access enforcement notice + +> **Subject:** Managed-device access requirements begin [DATE] +> +> Beginning [DATE], access to [RESOURCES] requires [MFA AND/OR A COMPLIANT +> DEVICE] for [APPROVED USER SCOPE]. This follows the completed pilot and +> report-only review. +> +> Before that date, open Company Portal and confirm your device has no pending +> compliance action. If you need help, contact [SUPPORT CHANNEL]. +> +> Do not send this notice until the identity owner has verified exclusions, +> emergency access, support readiness, and rollback approval. + +## Microsoft references + +- [Microsoft Intune enrollment guide](https://learn.microsoft.com/intune/device-enrollment/guide) +- [What information can my organization see when I enroll my device?](https://learn.microsoft.com/mem/intune/user-help/what-info-can-your-company-see-when-you-enroll-your-device-in-intune) +- [Check access in Company Portal](https://learn.microsoft.com/intune/user-help/compliance/validate-device-access-windows) +- [App protection policies overview](https://learn.microsoft.com/intune/app-management/protection/overview) + +For deployment-team controls, use the +[change-management playbook](Change-Management-Playbook.md). For service desk +triage, use the [support-team guide](Support-Team-Guide.md). \ No newline at end of file diff --git a/Products/Intune/docs/Evidence-Troubleshooting.md b/Products/Intune/docs/Evidence-Troubleshooting.md new file mode 100644 index 0000000..9f97e66 --- /dev/null +++ b/Products/Intune/docs/Evidence-Troubleshooting.md @@ -0,0 +1,296 @@ +--- +title: Intune evidence and troubleshooting +layout: default +parent: Intune +--- + +# Intune evidence and troubleshooting + +## Evidence handling + +HTML and JSON reports are tenant evidence. Keep them in the approved private +customer location. Do not commit or publish them. The toolkit applies +pattern-based redaction to tenant IDs, domains, administrator identities, +tokens, GUID-shaped identifiers, and implemented normalized response fields. +It does not prove that every possible service-authored message is safe to +share. Review sanitized excerpts before escalation. + +`-WhatIf` distinguishes preview intent from applied changes. A preview must not +contain `Created`, `Updated`, or `Adopted` tenant actions. An apply can contain +those statuses only for the selected supported write paths. + +## Common failures + +Compliance and enrollment restriction verification compares managed settings +and the exact assignment, including on reruns. A matching name, a successful +POST, or one matching group among several assignments is not proof of success. +The create-only writers stop on drift, duplicate ownership or missing evidence; +they do not repair existing policies automatically. + +| Symptom | Likely cause | Resolution | +| --- | --- | --- | +| Tenant identity mismatch | Wrong account, tenant ID, or delegated organization. | Stop. Reconnect to the intended tenant and verify its canonical domain. | +| `401` or `403` | Missing consent, Graph scope, Entra role, Intune RBAC, GDAP assignment, or expired session. | Compare the exact operation with the API permission matrix; do not add broad permissions speculatively. | +| Conditional Access list/read returns `403` | The signed-in Graph context lacks the documented Conditional Access read/write permission pair, or the operator lacks the required Entra role. | Reconnect with the configured `Api.GraphScopes`, including `Policy.Read.All` and `Policy.ReadWrite.ConditionalAccess`, and confirm the operator has Conditional Access Administrator or Security Administrator. | +| License disposition is skipped | Required SKU/service plan was not detected or mapping needs review. | Confirm active subscriptions and current Microsoft catalog metadata. | +| Missing Graph command | Required Microsoft Graph PowerShell module is absent. | Install the documented module for the current user and rerun PowerShell 7. | +| `Microsoft.Graph.Authentication` assembly with the same name is already loaded | Graph SDK module versions differ, or a previous import left a conflicting assembly in this process. | Use a fresh PowerShell 7 session and matching Authentication/DeviceManagement versions. Follow the module recovery steps below; do not change tenant permissions. | +| `A window handle must be configured` during Graph sign-in | WAM cannot attach its sign-in window to the current host. | Use the interactive-terminal recovery steps below; do not change scopes or switch to app-only authentication. | +| Apple certificate assessment returns `GuidedOnly` with `404` | Graph did not return the certificate singleton, and Microsoft does not document `404` as proof of an unconfigured certificate. | Verify Apple MDM Push certificate status in the Intune admin center, complete setup or renewal if needed, and retain the guided result. | +| Default compliance assessment reports an unknown projection | Graph returned a nullable or missing `SecureByDefault` projection, which does not prove the portal's effective state. | Verify the Compliance policy settings page and retain the `GuidedOnly` result. The toolkit does not write from an unknown pre-write state. | +| Default compliance post-write readback is unavailable | Graph accepted the update but did not return a verifiable Boolean `SecureByDefault` value. | Treat the run as blocked, preserve the evidence, and verify **Devices > Compliance > Compliance settings** before rerunning or continuing deployment. | +| Compliance inventory reports scheduled-action readback unavailable | The documented v1.0 `scheduledActionsForRule` relationship returned `400` because no GET route matched. | Retain policy and assignment inventory, do not interpret the action count as zero, and keep scheduled-action automation blocked. | +| Compliance write or rerun cannot verify scheduled actions | The rule collection or its `scheduledActionConfigurations` child read is unavailable or differs from the payload. | Stop rollout and inspect the policy and assignments in Intune. Inventory may still be useful, but this is not `Verified`. Do not create another policy to bypass the failed read. | +| Enrollment assignment scope is `Unknown` with only exclusions | No inclusion target proves pilot scope. | Inspect assignments in Intune and record the approved include target. Do not infer pilot-only coverage from an exclusion. | +| Enrollment restriction type differs between older and newer objects | Graph beta exposes both a single-platform type with `platformType`/`platformRestriction` and a legacy multi-platform type with per-platform fields. | Use the current toolkit's beta inventory and readback. It handles the shapes separately and does not silently convert an existing object. | +| Enrollment restriction ID is not a GUID | Intune returned a service-defined opaque configuration ID. | Use the current toolkit, which URL-encodes non-empty opaque IDs and excludes them from evidence. Do not rewrite or expose the identifier. | +| Enrollment restriction assessment fails on an assignment with no target | Graph returned an assignment object without a `target`, so targeting cannot be described. The assessment stops rather than reporting a scope it could not read. | Treat as failed, retain the sanitized evidence, and reconcile the restriction's assignments in the portal before rerunning. Earlier toolkit versions could skip this check depending on the order Graph returned assignments, so a run that previously succeeded may now surface it. | +| App-protection policy ID is not a GUID | Intune returned a service-defined opaque Android or iOS/iPadOS policy ID. | Use the current toolkit, which URL-encodes non-empty opaque IDs for relationship reads and excludes them from evidence. | +| Existing object name blocks a create | A Microsoft 365 Apps deployment, app-protection policy, compliance policy, enrollment restriction, or Conditional Access policy already uses the configured name but is not a verified equivalent toolkit object. | Do not use `-AdoptExisting` to bypass the collision. Audit or rename the existing object, or choose a different configured name. | +| Conditional Access readback returns `404` immediately after create | Graph accepted the create, but the Conditional Access read endpoint has not synchronized yet. | Let the bounded retry complete. If the terminal readback still fails, inspect the portal and preserve the report before rerunning. | +| Managed enrollment/compliance object has an unexpected name | A toolkit-managed object was renamed, or configured names changed after deployment. | Stop and reconcile the original object ID with the deployment record before aligning names. Do not strip its marker or create a replacement blindly. Renamed apps/app-protection objects and exactly equivalent CA policies are no-ops instead. | +| Post-write readback fails or mismatches | The service did not return the created object or its critical fields did not match the approved intent. | Treat the run as failed. Stop expansion, inspect the portal and prior evidence, and use the documented recovery path before rerunning. | +| `429`, `5xx`, or timeout | Transient throttling or service failure. | Allow the shared bounded retry helper to run; rerun if the terminal failure remains. | +| Malformed or incomplete response | API contract drift or unsupported tenant state. | Treat as failed, retain sanitized evidence, and raise with the module/API owner. | +| `GuidedOnly` | The action is interactive, beta-only, incompletely comparable, or unapproved for writes. | Follow the roadmap workflow and capture independent verification evidence. | +| Safety gate refusal | Missing approval, pilot scope, emergency access, rollback acknowledgment, or category opt-in. | Do not bypass the gate. Complete change control and rerun. | + +## Policy API references + +The following contracts were checked September 25, 2026. Offline tests do not +establish tenant support, effective permissions or service propagation behavior. + +- [Single-platform enrollment creation, beta](https://learn.microsoft.com/graph/api/intune-onboarding-deviceenrollmentplatformrestrictionconfiguration-create?view=graph-rest-beta) +- [Legacy multi-platform enrollment shape, beta](https://learn.microsoft.com/graph/api/resources/intune-onboarding-deviceenrollmentplatformrestrictionsconfiguration?view=graph-rest-beta) +- [Compliance scheduled-action rules, v1.0](https://learn.microsoft.com/graph/api/intune-deviceconfig-devicecompliancescheduledactionforrule-list?view=graph-rest-1.0) +- [Compliance scheduled-action children, v1.0](https://learn.microsoft.com/graph/api/intune-deviceconfig-devicecomplianceactionitem-list?view=graph-rest-1.0) + +## Graph module version conflicts + +DeviceManagement requires the matching Authentication SDK release. For +example, installing DeviceManagement 2.40.0 into a shell that has already +loaded Authentication 2.37.0 can fail before Graph sign-in. This is a local +PowerShell dependency problem, not a tenant role or consent failure. + +1. Preserve the console output and any saved reports, then close the + PowerShell session that reported the assembly conflict. +2. Open a fresh PowerShell 7 session (`pwsh -NoProfile`). Do not import other + Graph versions first. +3. With the current toolkit, rerun the intended preview with `-WhatIf`. The + prerequisite check selects a matching DeviceManagement version. Approve + its CurrentUser installation if permitted, or use `-AutoInstallModules` + only when that installation is already approved. +4. If installation is managed separately, use the + [matching-version installation commands](Operator-Guide.md#before-you-run), + then start a fresh shell before the preview. + +The toolkit never removes other installed versions or tries to unload a +conflicting assembly. `Remove-Module` alone is not a restart. An import +failure stops before sign-in and is recorded as a failed prerequisite. +`-NonInteractive` without installation approval prints the exact +`Install-Module -RequiredVersion` command instead of prompting. + +References, checked 2026-09-25: +[Install-Module version selection](https://learn.microsoft.com/powershell/module/powershellget/install-module) +and [PowerShell assembly conflicts](https://learn.microsoft.com/powershell/scripting/dev-cross-plat/resolving-dependency-conflicts). +The installed DeviceManagement 2.39.0 manifest was also checked: its +`RequiredModules` pins Authentication with `RequiredVersion = '2.39.0'`. +Offline fixtures cover the reported 2.37.0/2.40.0 mismatch. A fresh-session +preview on the affected workstation remains the live confirmation. + +## Windows sign-in needs a window handle + +The Graph SDK can require a parent window for WAM even when the toolkit reports +that the Windows version supports it. **WAM broker available** describes the +machine, not a guarantee that an embedded or background host can show a prompt. + +1. Open **Windows Terminal > PowerShell 7** or the **PowerShell 7** desktop app + yourself. Use an interactive user session, not a background job or service. +2. Change to the same `Products\Intune` folder and rerun the original + `-TenantAdminUpn ... -WhatIf` command. Do not add high-risk or apply switches. +3. Complete the Microsoft account selection and any approved consent prompt. + Verify the requested tenant and permissions. Enter credentials only in the + Microsoft sign-in window. +4. Wait for **Tenant identity verified** and run completion. Repeat the command + in that same shell; a matching context should log `reason=reused`. + +`-NonInteractive` suppresses toolkit prompts; it does not provide a sign-in +window. A successful interactive sign-in may allow later cached runs, but it +does not guarantee unattended access after expiration or a tenant switch. +If the error persists in the desktop terminal, retain the redacted report and +PowerShell/Graph module versions for support. Do not disable MFA or tenant +Conditional Access, add permissions, or reuse another tenant's token. + +This uses the same delegated Graph sign-in pattern as Purview. No device-code, +certificate, or secret-based fallback is automatically selected. Microsoft +documents the [WAM parent-window requirement](https://aka.ms/msal-net-wam#parent-window-handles). + +## Review a completed report + +Use the HTML file for the operator review and the JSON file for deterministic +checks. + +The final console output announces each successfully saved report's full +path, even when sign-in or a later module fails after logging starts. Copy the +printed `Invoke-Item -LiteralPath` command to open the HTML; the toolkit does +not launch a browser. Default paths remain under the product's `Reports` +folder and can be changed in the private configuration's `Report` section. + +A write warning means that report was not confirmed saved. An older file may +still exist at the same path, so do not treat its presence as current evidence. +Saving HTML and JSON is attempted independently, and an HTML-write failure +does not replace an earlier deployment error. Preserve any surviving report +and console output before retrying. + +If an earlier prerequisite or module fails, selected modules that never start +are recorded as `Skipped` with a `Blocked` disposition and a reason. An +explicit skip retains its skip evidence. `ConfiguredItem` and `WriteRiskGate` +entries describe intent, not a completed assessment. Read the module result +and its lifecycle evidence before treating any step as complete. + +Evidence-write warnings remain nonterminating even with `-WarningAction Stop` +so they cannot replace the original deployment error or prevent the other +report from being attempted. This does not suppress the warning. + +```powershell +$report = Get-Content ./Reports/intune-run-log.json -Raw | + ConvertFrom-Json + +$report.moduleSummary | + Sort-Object module | + Format-Table module, result, entryCount + +$report.entries | + Where-Object status -in @('Created', 'Updated', 'Adopted') +``` + +Treat an empty second result as required for `-WhatIf`. For an apply, compare +every returned entry with the approved change and confirm its readback. + +## Compare the five automated assessments + +| Assessment | Portal comparison | When to stop | +| --- | --- | --- | +| Apple certificate | **Devices > Device onboarding > Enrollment > Apple > Apple MDM Push Certificate** | Portal and report disagree on active or renewal state. | +| Default compliance | **Devices > Manage devices > Compliance > Compliance settings** or **Endpoint security > Device compliance > Compliance policy settings** | Effective treatment of devices without a policy is unclear or differs. | +| Enrollment restrictions | **Devices > Device onboarding > Enrollment > Enrollment options > Device platform restriction** | Count or assignment scope cannot be reconciled without exposing identifiers. | +| App protection | **Apps > Manage apps > Protection** | Platform policy or target-app availability differs from the report. | +| Device compliance | **Devices > Manage devices > Compliance > Policies** or **Endpoint security > Device compliance > Policies** | Platform policy or assignment inventory differs, or scheduled-action state is assumed from an unavailable read. | + +A discrepancy blocks closure and requires investigation; it is not approval to +edit the setting again. + +## Task-specific recovery + +### Apple certificate + +If the assessment returns guided `404`, verify the portal. Do not create a new +certificate until the customer Apple identity owner confirms whether an +existing certificate should be renewed. Renew with the same Apple account and +certificate identity whenever possible. + +### Default compliance + +A nullable or missing Graph projection does not prove the effective setting and +remains a guided result. The toolkit will not write from that unknown state. +Before any change, confirm the portal state, ensure platform compliance +policies reach pilot devices, and review the effect of existing Conditional +Access. An unavailable post-write readback blocks continuation. + +### Enrollment restrictions + +Opaque configuration IDs are valid service identifiers. Do not rewrite or +publish them. Reconcile policy count, platform, priority, ownership controls, +and assignment scope in the portal. + +The assessment reports `AssignedScope` and `ExclusionCount` across the combined +assignments of every platform restriction it read: + +| `AssignedScope` | Meaning | +| --- | --- | +| `None` | No assignments were returned. | +| `PilotOnly` | Every include target resolved to the pilot group you supplied. | +| `Broad` | A target reaches all users or all devices, an include target resolved to a group other than the pilot group, or no pilot group was supplied. | +| `Unknown` | A target could not be read confidently, so the toolkit will not claim narrower targeting than it can prove. | + +`Unknown` is a deliberate and safe result. It is reported when a target type is +unrecognized, when a group identifier is missing or empty, or when a target +carries two different group identifiers with no way to tell which one applies. + +`ExclusionCount` counts exclusion group targets across those same assignments. +Every assignment is inspected before the scope is reported, so the count does +not depend on the order Microsoft Graph returns assignments in. Compare it +against the portal rather than assuming the two lists arrive in the same order. + +### App protection + +Policy inventory does not prove settings are correct. Review Android and +iOS/iPadOS independently. Target-app readback that is unavailable remains +unknown and must not be treated as an empty target list. + +### Device compliance + +If scheduled-action readback is unavailable, retain the platform policy and +assignment inventory and verify actions in the portal. Do not infer zero +actions and do not enable related automation. + +## Recovery principles + +Assessments are safe to rerun. Supported writes must preserve unrelated +customer properties and assignments, perform bounded readback, and converge on +an idempotent rerun. Use the documented manual or automated recovery for a +partial failure; a delete API alone is not proof of rollback. + +## Recover a partial pilot deployment + +There is **no automatic rollback command**. The current create-only writers +also do not repair assignment or targeting on an existing managed object. +An `AlreadyCompliant` rerun must not be used to dismiss an earlier partial +failure. Recovery is an approved portal operation, not a reason to add +`-AdoptExisting` or delete policies by name. + +Before apply, record the exact object IDs, ownership markers, settings, +assignments, exclusions, and enrollment priorities that may be affected. +Record which objects did **not** exist. Store this privately; report redaction +means the toolkit log is not a complete backup or object-ID inventory. + +After a failure, stop subsequent stages and preserve that run's reports before +rerunning, because the normal filenames are overwritten. Match the failed +action to the exact object in the portal and check whether the service accepted +the preceding create, targeting, or assignment request. + +| Changed object | Approved recovery action | Evidence needed before closing | +|---|---|---| +| Microsoft 365 Apps | In **Apps > All apps**, open the exact new deployment. Remove only the pilot assignment added by this run. Delete the new app object only after confirming no other assignments or dependencies use it. Do not change a pre-existing app object. | Assignment is absent and device install status is reviewed. Removing a Required assignment or deleting the app definition does not undo an installation; uninstall is a separate approved device action. | +| Android/iOS app protection | In **Apps > Manage apps > Protection**, open the exact new policy and remove only its pilot assignment. If creation succeeded but targeting or assignment failed, either complete the approved configuration manually or remove the newly created, unassigned policy before a clean retry. | Targeted apps and assignments match the approved recovery state. Removing a policy does not restore data already wiped or reverse every app-side effect. | +| Device compliance policy | In **Devices > Manage devices > Compliance > Policies**, restore the prior known-good assignment and policy coverage before removing a new policy or assignment. Do not leave devices without coverage when the default is Not compliant. | Pilot devices check in and their compliance and sign-in results recover. Removing a policy can itself cause noncompliance; stop and involve the identity owner if access is affected. | +| Enrollment restriction | In **Devices > Device onboarding > Enrollment > Enrollment options > Device platform restriction**, remove only the new pilot assignment or new restriction after confirming the prior restriction and priority will apply. Do not delete or rewrite the built-in default. | Effective platform/ownership restrictions and priorities match the before-state; a representative enrollment behaves as expected. Existing enrollment is not undone by removing a restriction. | +| Default compliance setting | In **Devices > Manage devices > Compliance > Compliance settings**, restore the recorded treatment of devices with no policy and the recorded validity period. This is a tenant-wide change requiring the identity/change owner's approval, not a pilot-only undo. | Saved settings match the before-state and affected devices/sign-ins are reviewed after reevaluation. Do not blindly set Compliant as a universal recovery default. | +| Enrollment Conditional Access | In **Entra ID > Conditional Access > Policies**, verify the exact toolkit policy and its report-only state. With identity-owner approval, disable or delete only the newly created policy if it must be withdrawn. If someone separately enabled it, follow the access incident plan and restore the approved prior state, not a blanket disable of other policies. | Prior access protection and emergency-access paths still work; sign-in evidence matches the approved state. Do not delete the Intune Enrollment service principal as routine rollback: other policies or enrollment flows can depend on it. | + +Do not proceed when ownership, before-state, or dependencies are uncertain. +Keep the rollout stopped and involve the deployment and identity owners. +After recovery, run the same stage with `-WhatIf`, reconcile every proposed +change, and obtain approval before any new apply. A fresh run must have its own +report pair and a device/portal confirmation; a successful API response alone +does not prove recovery. + +## Escalation record + +When raising an issue, include product version, UTC timestamp, module/action, +sanitized status code, selected authentication mode, Graph module version, +license class, expected disposition, and redacted report excerpt. Never include +tokens, certificate material, raw tenant exports, customer identities, or +unredacted payloads. + +Use [Future Intune write capabilities](Future-Write-Capabilities.md) to route +API gaps to the correct Microsoft product group and validation gaps to the +product, security, permissions, or pilot owner. + +Copy-ready commands are in +[assessment and deployment examples](../Examples/Read-Only-Assessment.md). + +The deployment team running the toolkit owns tenant operations, customer +change control, and customer evidence retention. Report repository defects +through the [public issue tracker](https://github.com/microsoft/BestPractice_Deploy-Scripts/issues). +Report security vulnerabilities using [SECURITY.md](../../../SECURITY.md), not public issues. diff --git a/Products/Intune/docs/Future-Write-Capabilities.md b/Products/Intune/docs/Future-Write-Capabilities.md new file mode 100644 index 0000000..da9bea7 --- /dev/null +++ b/Products/Intune/docs/Future-Write-Capabilities.md @@ -0,0 +1,73 @@ +--- +title: Intune future write capabilities +layout: default +parent: Intune +--- + +# Intune remaining and future write capabilities + +This page separates the six write paths available in the current release from +the remaining guided, blocked, or broader future automation. It records which +gaps require help from Microsoft, tenant administrators, security reviewers, +or pilot owners. It is a roadmap, not a Microsoft commitment. + +Status terms: + +- **Work in progress:** supported interfaces exist, but implementation or review + is incomplete. +- **Available, gated:** implemented with deterministic fixture coverage and + safety gates. Refresh controlled pilot evidence after changing a write + contract. +- **Expansion blocked:** the current narrow write exists, but broader + platforms, payloads, or enforcement remain outside the approved scope. +- **GuidedOnly:** the supported production workflow requires a portal or + external interactive step, or the available Graph resource is beta-only. +- **Future release:** eligible only after every listed release gate is complete. + +## Write-capability register + +| Guide task | Current status | Current boundary | Stakeholder help needed | +|---|---|---|---| +| 1. Windows automatic MDM enrollment | GuidedOnly | Microsoft documents the Entra portal workflow. The mobility policy resource falls back to beta and exposes no approved GA write/readback pair. | **Microsoft Entra/Intune API owners:** confirm whether a supported GA MDM user-scope API is planned. **Tenant operator:** complete and verify the portal workflow. | +| 2. Apple MDM push certificate | GuidedOnly | Graph v1.0 exposes CSR and upload operations, but Apple sign-in remains interactive and certificate replacement has no safe automatic rollback. | **Intune enrollment owner:** confirm supported no-certificate response and renewal semantics. **Customer:** own the organization Apple account and renewal. | +| 3. Managed Google Play | GuidedOnly | Connection, readback, and unbind resources remain beta-only; Google consent requires an interactive customer-owned identity. Disconnect disables Android Enterprise management and unenrolls devices. | **Intune Android API owners:** provide or confirm GA connection/readback capability. **Customer:** own the Entra/Google identity and consent. | +| 4. Default compliance setting | Available, gated | The module assesses every run and sets `secureByDefault=True` only with `-IncludeHighRisk`, `-EnableComplianceEnforcement`, and customer approval. | **Deployment and security owners:** confirm policy delivery and access impact before selection. | +| 5. Enrollment restrictions | Available, gated | The approved Android and iOS/iPadOS personal-enrollment restrictions are created and assigned under the high-risk category gate. Broader restriction design remains customer-specific. | **Deployment and security owners:** approve supported platforms, ownership rules, pilot scope, and recovery. | +| 6. App protection policies | Available, limited | Android and iOS/iPadOS Level 1 policies are created, target the configured core apps, and are assigned to the selected scope. Modern Windows MAM and broader payloads remain outside scope. | **Intune Apps API owners:** provide the Windows GA roadmap. **Product owner:** approve any expansion beyond the shipped Level 1 payloads. | +| 7. Device compliance policies | Available, gated and partial | The approved per-platform payloads are created and assigned with `-IncludeHighRisk`. Android Device Owner, AOSP, scheduled-action mutation, and the imported catalog remain outside scope. | **Intune compliance API owners:** confirm GA roadmap and scheduled-action semantics. **Security owner:** approve each added platform independently. | +| 8. Microsoft 365 Apps deployment | Available, limited | The current release creates and assigns the configured `officeSuiteApp` through the documented beta path and verifies key readback fields. | **Intune Apps API owners:** provide or confirm a GA create/update/readback surface. **Product owner:** review the beta dependency before expanding the payload. | +| 9. Enterprise State Roaming / Windows Backup for Organizations | GuidedOnly, work in progress | Microsoft moved ESR management from the Entra portal to Windows Backup for Organizations policy management after June 2026. The toolkit has not yet verified the supported policy API payload, assignment, readback, and rollback contract. | **Windows Backup and Intune policy API owners:** identify the supported GA policy creation/readback surface and migration contract. **Tenant operator:** use the current Intune Windows Backup and Restore workflow and pilot verification. | +| 10. Conditional Access for enrollment | Available, gated, highest risk | The module ensures the enrollment service principal exists and creates the policy report-only after every high-risk gate and at least one emergency-access exclusion ID are supplied. Enforcement and broad assignment remain separate decisions. | **Security owner:** verify exclusion IDs and approve the report-only design. **Pilot owner:** prove sign-in results and recovery before promotion. | + +## Evidence required before expanding a write release + +Every new or expanded write must have authoritative API documentation, +least-privilege Graph +permissions, Intune/Entra RBAC and GDAP mapping, active-license checks, +customer-safe identity and adoption rules, `ShouldProcess` and `-WhatIf`, +read-modify-write preservation, bounded readback, retry and propagation +handling, deterministic fixtures, controlled pilot apply, rollback, partial +failure recovery, redacted HTML/JSON evidence, and independent human approval. + +The detailed source and operation records are maintained in the private +engineering repository. Operators should rely on this page and the product +README for the supported release boundary. + +## Imported candidate policy catalog + +The repository now retains all 19 Intune policy definitions from the existing +contributor implementation. The import includes mobile Baseline and Advanced +compliance and device-configuration payloads, plus Windows compliance, ASR +audit, Edge hardening, security baseline, and ACSC hardening payloads. + +The source orchestrator was not adopted. It deleted existing objects by a name +substring, created policies without read-modify-write preservation, and used +Graph beta commands and raw assignment calls without this toolkit's approval, +pilot, readback, evidence, or rollback controls. + +The normalized catalog is useful build input, but it does not change the write +status in the register above. The current release uses separate approved +payloads for app protection, compliance, enrollment restrictions, and +Microsoft 365 Apps. Catalog-based compliance and device-configuration +deployment still requires operation-level review and independent pilot +evidence. diff --git a/Products/Intune/docs/Operator-Guide.md b/Products/Intune/docs/Operator-Guide.md new file mode 100644 index 0000000..9e302d2 --- /dev/null +++ b/Products/Intune/docs/Operator-Guide.md @@ -0,0 +1,380 @@ +--- +title: Intune operator guide +layout: default +parent: Intune +--- + +# Intune operator guide + +## What this toolkit is + +The Microsoft Intune Best Practice Toolkit assesses the ten tasks in the Device +Management Deployment Guide for Small Business and applies the six supported +tenant-write paths. It records what is already configured, changed, blocked, +or guided. It is intended for partners, MSPs, and in-house IT teams onboarding +Microsoft 365 Business Premium or Intune Plan 1 tenants. + +The current release is **write-capable and pilot-first**. Start with +`-WhatIf`. Standard writes configure Microsoft 365 Apps and Android/iOS app +protection. High-risk writes remain behind explicit approval, category, +assignment, emergency-access, and rollback gates. + +## When to use it + +Use the toolkit during discovery, onboarding design, readiness assessment, +change review, and pilot planning. Do not use it as proof that a production +tenant is compliant without reviewing the generated evidence and completing +the guided tasks. + +## Before you run + +1. Use PowerShell 7 or later in an interactive desktop session. +2. Confirm Intune is the MDM authority, affected users are licensed, and each + intended device platform is supported. +3. Obtain customer authorization and confirm the expected tenant. +4. Use a delegated administrator with the documented Graph scopes and only the + Intune or Entra roles needed for the selected operations. +5. Select an approved private location for HTML and JSON evidence. +6. Identify supported platforms, pilot users, support ownership, Apple and + Google identity owners, and emergency-access accounts. +7. Do not use a production tenant for future write testing. + +Check the required local modules: + +```powershell +$requiredModules = @( + 'Microsoft.Graph.Authentication', + 'Microsoft.Graph.DeviceManagement' +) +$requiredModules | ForEach-Object { + Get-InstalledModule -Name $_ -ErrorAction SilentlyContinue | + Select-Object Name, Version +} +``` + +In a fresh PowerShell 7 session, install both modules from the same SDK release +for the current user: + +```powershell +Install-Module Microsoft.Graph.Authentication -Scope CurrentUser +$graphVersion = (Get-Module -ListAvailable Microsoft.Graph.Authentication | + Sort-Object Version -Descending | Select-Object -First 1).Version +Install-Module Microsoft.Graph.DeviceManagement -RequiredVersion $graphVersion -Scope CurrentUser +``` + +Do not install or update modules during a customer change window without first +testing the selected versions on the deployment workstation or in a lab. Pass +`-AutoInstallModules` to the orchestrator to install a missing module to +`CurrentUser` without a prompt; without it and without `-NonInteractive`, the +toolkit prompts once before installing. + +The toolkit uses the loaded Authentication version when one exists, otherwise +the highest installed version. DeviceManagement is imported or installed at +that exact version, even if a newer copy is installed side by side. +Installation still requires consent or `-AutoInstallModules`. If conflicting +modules or assemblies are already loaded, close the shell and use the +[fresh-session recovery steps](Evidence-Troubleshooting.md#graph-module-version-conflicts); +`Remove-Module` does not unload the .NET assemblies. + +The current delegated Graph API permission set requests six read scopes -- +`User.Read`, `LicenseAssignment.Read.All`, +`DeviceManagementConfiguration.Read.All`, `DeviceManagementApps.Read.All`, and +`DeviceManagementServiceConfig.Read.All`, and `Policy.Read.All` -- plus five +write scopes tied to specific, currently gated write paths -- +`DeviceManagementApps.ReadWrite.All`, +`DeviceManagementServiceConfig.ReadWrite.All`, +`DeviceManagementConfiguration.ReadWrite.All`, +`Policy.ReadWrite.ConditionalAccess`, and `Application.ReadWrite.All`. The +orchestrator requests every one of these eleven scopes at connect time for every +run, including `-WhatIf`; Graph consent is fixed at authentication, not +re-evaluated per module. `ShouldProcess`, the assignment scope, and the +high-risk gates still decide whether a write runs. Interactive sign-in also +uses the standard `openid`, `profile`, and `email` identity scopes. + +Earlier Intune 0.2.0 pilot builds requested only the original five read scopes, +matching the read-only assessments implemented then. The current 0.3.0 authentication rewrite requests the full eleven-scope set up +front so the toolkit connects Microsoft Graph once per run, the same as +Purview. Standard and high-risk write paths use that verified context; the +imported policy catalog remains the reserved fail-closed path. + +Authentication is delegated UPN/GDAP only, the same session-guard behavior as +Purview. The orchestrator connects exactly once before any task runs: it +reuses a cached Microsoft Graph context only when the account, tenant, and +every required scope already match, disconnects a stale context, and verifies +the live tenant identity through `/organization` before continuing. There is +no `-ClientId`, `-TenantId`, or `-CertificateThumbprint` parameter, and no +`ContextScope Process` override: the default Graph PowerShell authentication +persistence applies, matching Purview. Windows Authentication Manager (WAM) +usually reduces a repeat sign-in to a one-click prompt on a supported +interactive desktop; a first consent, missing scope, account change, or +customer-tenant switch can still require a full browser prompt. + +For **A window handle must be configured**, follow the +[interactive-terminal recovery steps](Evidence-Troubleshooting.md#windows-sign-in-needs-a-window-handle). +`-NonInteractive` does not fix a missing sign-in window. + +## Prepare a pilot group + +Skip this section for a discovery-only preview. Complete it before any assigned +pilot apply. The scripts accept a group ID but do not create the group or +approve its membership for you. + +1. In the customer tenant, open the [Microsoft Entra admin center](https://entra.microsoft.com) + and go to **Entra ID > Groups > All groups > New group**. Use an approved + group owner or administrator with permission to create and manage groups. +2. Choose **Security** and **Assigned** membership. Give the group a clear pilot + name and an accountable owner. Do not make it role-assignable. +3. Add only the agreed, Intune-licensed test users. Use direct user membership + for these examples, not nested groups, a device group, or an all-user dynamic + rule. Keep deployment administrators and emergency-access accounts out. +4. Create the group, open its **Overview**, and copy **Object ID**. This is the + value for `-PilotGroupId`, not the group name, tenant ID, or a user ID. +5. Check **Members** against the approved list and confirm the group appears + in the Intune admin center. A user-targeted policy can reach that user's + other applicable devices, so test users must not use production devices. +6. Retain the group ID and membership record privately. Confirm the group again + before each apply; the script does not prove its membership is safe. + +Use Microsoft's [group creation guide](https://learn.microsoft.com/entra/fundamentals/how-to-manage-groups) +and [Intune group guidance](https://learn.microsoft.com/intune/fundamentals/tenant-administration/add-groups) +if the portal layout differs. Group creation is a separate approved portal +action; do not broaden the toolkit's Graph permissions to create it. + +## Run a preview + +```powershell +cd Products/Intune +./Deploy-IntuneBestPractice.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com ` + -PilotGroupId '' ` + -WhatIf +``` + +Use `-DelegatedOrganization` for a GDAP-managed customer tenant. A wrong or +ambiguous tenant must fail closed. `-NonInteractive` suppresses toolkit +confirmation and module-install prompts; it fails fast instead of installing a +missing module unless `-AutoInstallModules` is also supplied, and it cannot +guarantee a prompt-free first sign-in. Pre-authenticate in an approved +interactive session first when running unattended. + +Use the [deployment framework](Deployment-Framework.md) and +[change-management playbook](Change-Management-Playbook.md) before a customer +onboarding. They separate assessment, guided portal work, device enrollment, +compliance observation, and later access enforcement. + +## Apply to a pilot + +Apply the standard baseline: + +```powershell +./Deploy-IntuneBestPractice.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com ` + -PilotGroupId '' +``` + +This creates and assigns Microsoft 365 Apps plus the configured Android and +iOS/iPadOS Level 1 app-protection policies. First run the same command with +`-WhatIf`, review the exact targets, and obtain approval before removing it. +Existing toolkit-managed objects are left unchanged, not repaired or +reassigned. A missing pilot ID can leave standard objects unassigned; do not +treat creation alone as a successful pilot. + +## Add high-risk changes one at a time + +These are separate change windows, not a script to paste and execute in full. +Complete the standard pilot and verify device behavior first. Before each +stage, retain its before-state and approved recovery plan using +[partial-deployment recovery](Evidence-Troubleshooting.md#recover-a-partial-pilot-deployment). +The examples deliberately skip unrelated writers to isolate the change, not +to hide a failure. + +Define the approved inputs in the same PowerShell session. Replace every +placeholder, including the approval reference, with the real private record: + +```powershell +$approved = @{ + TenantAdminUpn = 'admin@contoso.onmicrosoft.com' + PilotGroupId = '' + IncludeHighRisk = $true + CustomerApprovalId = '' + SkipAppProtectionPolicies = $true + SkipAppDeployment = $true +} +``` + +**Stage 1: device compliance policies (task 7).** Review existing Conditional +Access first: a new compliance policy can affect access immediately. Verify +policy delivery and device compliance before advancing. + +```powershell +.\Deploy-IntuneBestPractice.ps1 @approved ` + -SkipComplianceBaseline -SkipEnrollmentRestrictions -SkipDeviceConditionalAccess ` + -WhatIf +``` + +**Stage 2: enrollment restrictions (task 5).** Confirm the intended personal +versus corporate enrollment behavior on each supported pilot platform. + +```powershell +.\Deploy-IntuneBestPractice.ps1 @approved ` + -SkipComplianceBaseline -SkipDeviceCompliancePolicies -SkipDeviceConditionalAccess ` + -EnableEnrollmentRestrictions ` + -WhatIf +``` + +**Stage 3: default compliance setting (task 4).** This is a **tenant-wide +singleton**, not a pilot-scoped policy. The pilot ID does not limit its effect. +Proceed only after separate tenant-wide approval and review of existing +compliant-device Conditional Access and devices without a compliance policy. +Record both the previous default and compliance-status validity period. + +```powershell +.\Deploy-IntuneBestPractice.ps1 @approved ` + -SkipDeviceCompliancePolicies -SkipEnrollmentRestrictions -SkipDeviceConditionalAccess ` + -EnableComplianceEnforcement ` + -WhatIf +``` + +**Stage 4: report-only Conditional Access (task 10).** The identity owner must +verify working emergency-access accounts and obtain their object IDs from +**Entra ID > Users > All users > the account > Overview**. Supply those IDs, +not UPNs, and confirm the written policy exclusions after creation. + +```powershell +.\Deploy-IntuneBestPractice.ps1 @approved ` + -SkipComplianceBaseline -SkipDeviceCompliancePolicies -SkipEnrollmentRestrictions ` + -EnableConditionalAccessEnforcement ` + -BreakGlassExclusionsConfirmed ` + -BreakGlassUserIds '' ` + -RollbackAcknowledged ` + -WhatIf +``` + +For **each stage**, inspect its preview first. Only after approval, rerun that +one command without `-WhatIf`, verify portal/device state, and archive the +reports. Rerun the same stage to check for unintended changes before moving +on. Do not add `-AssignTenantWide` to these examples. + +The last stage creates a report-only policy, not enforcement. Review +[report-only sign-in results](https://learn.microsoft.com/entra/identity/conditional-access/concept-conditional-access-report-only) +and the [access-decision checklist](Change-Management-Playbook.md#phase-7-make-the-access-decision-separately). +Enabling the policy is a separate human-approved portal change. + +## What to expect + +The run connects to Microsoft Graph, validates tenant identity, checks license +capabilities, executes the supported assessments, applies or previews selected +writes, records guided steps, and writes: + +- `Reports/intune-run-report.html`, for operator review. +- `Reports/intune-run-log.json`, for machine processing and audit evidence. +- `Reports/intune-applicability.json`, for internal module applicability. + +The final output prints full paths for the saved HTML and JSON run reports +and a copyable command to open the HTML manually. These messages also appear +for previews and failures after logging starts, not just successful deployments. +If saving either report fails, a warning identifies the problem without +hiding the original deployment error. Keep the console output and do not +mistake a previous run's file for current evidence. The browser is never +opened automatically, and the configured filenames are unchanged. + +Current automated assessments cover Apple MDM push certificate health, the +default compliance setting, enrollment restrictions, Android/iOS app +protection, and per-platform compliance policies. Supported apply paths cover +Microsoft 365 Apps, Android/iOS app protection, default compliance, enrollment +restrictions, per-platform compliance policies, and enrollment Conditional +Access. Remaining tasks produce explicit guided evidence. + +An Apple certificate singleton response of `404` is recorded as `GuidedOnly`, +not as proof that the certificate is absent. Verify the Apple MDM Push +certificate page in the Intune admin center. This response does not stop the remaining assessments or selected writes. + +Graph can return a nullable `SecureByDefault` value even when the portal has a +configured state. The toolkit therefore records the state as unknown, +`GuidedOnly`, and does not change it. An explicit Boolean false can proceed +through the approved high-risk write path. If the post-write readback is null +or unavailable, the run is blocked until the portal state is verified. + +The v1.0 service can return `400` for the documented compliance-policy +`scheduledActionsForRule` relationship. The toolkit continues policy and +assignment inventory, records `ScheduledActionReadback=Unavailable`, and does +not report an unknown action count as zero. + +Enrollment restriction configuration IDs are service-defined opaque values, +not always GUIDs. The toolkit safely URL-encodes them for assignment reads and +excludes them from reports. + +The enrollment restriction assessment reports `AssignedScope` and +`ExclusionCount` across the combined assignments it read. It inspects every +assignment before reporting, so an assignment that Graph returns without a +`target` fails the assessment instead of being skipped. Microsoft Graph does not +guarantee assignment ordering, so the reported exclusion count does not depend +on the order the service returns assignments in. See +[evidence and troubleshooting](Evidence-Troubleshooting.md) for the meaning of +each scope value. + +Android and iOS/iPadOS app-protection policy IDs are also service-defined opaque +values. The toolkit URL-encodes them for assignment and target-app reads and +never includes them in evidence. + +## Interpret outcomes + +| Outcome | Meaning | Operator action | +|---|---|---| +| `AlreadyCompliant` | The implemented assessment proved the narrow checked state. | Review evidence; do not infer broader compliance. | +| `GuidedOnly` | A portal/external step, beta-only boundary, incomplete comparison, or unapproved write remains. | Follow the documented workflow and retain verification evidence. | +| `Skipped` | A license, operator switch, or applicability decision prevented the task. | Resolve the exact reason before rerunning. | +| `Blocked` | A safety prerequisite prevented the operation. | Do not bypass the gate; obtain approval or correct scope. | +| `Failed` | The assessment could not establish trustworthy state. | Correct authentication, permission, response, or service failure and rerun. | + +## Safety and change control + +Supported writes default to one approved pilot group. High-risk operations +require explicit customer approval and the documented category gates. Tenant +wide assignment requires rollback acknowledgment and configuration approval. +Conditional Access is created report-only by default. Certificate replacement, +the imported policy catalog, unsupported beta resources, and unmanaged-object +overwrite are not safe defaults. + +## Guided tasks + +Use [Future Intune write capabilities](Future-Write-Capabilities.md) to identify +the supported current workflow, API gap, stakeholder owner, and evidence needed +for every task that is not automated. + +## After the run + +Review every module verdict and every `Created`, `Updated`, or `Adopted` +entry. Confirm the target and assignment scope match the approved change. +Check module readback where it is emitted, and verify app-protection targeting +and assignment in the portal. Store reports privately, complete portal-guided +steps, and rerun with the same effective parameters to confirm idempotency. Do not publish tenant +reports or include customer identifiers, exports, credentials, or approval +artifacts in source control. + +For compliance and enrollment restrictions, `Verified` requires managed +settings and the exact assignment to match, including on an idempotent rerun. +Compliance verification also reads scheduled-action children. Missing reads, +additional targets or drift block continuation; the create-only writer does +not repair existing objects. Enrollment inventory and readback use the same +beta API as the single-platform writer and distinguish legacy multi-platform +objects. Review beta compatibility in the approved pilot. + +Existing managed app-protection policies remain unchanged and require portal +review. Their inventory is `GuidedOnly`, not verified compliance. + +Verify the five automated assessments in the portal without changing them: + +| Assessment | Current portal route | +| --- | --- | +| Apple certificate | **Devices > Device onboarding > Enrollment > Apple > Apple MDM Push Certificate** | +| Default compliance | **Devices > Manage devices > Compliance > Compliance settings** or **Endpoint security > Device compliance > Compliance policy settings** | +| Enrollment restrictions | **Devices > Device onboarding > Enrollment > Enrollment options > Device platform restriction** | +| App protection | **Apps > Manage apps > Protection** | +| Device compliance | **Devices > Manage devices > Compliance > Policies** or **Endpoint security > Device compliance > Policies** | + +Use the linked Microsoft Learn procedures in the +[device enrollment deployment guide](Device-Enrollment-Deployment-Guide.md) +if portal navigation changes. A portal discrepancy blocks closure; it is not a +reason to edit the tenant until the report, API behavior, and approved intent +are reconciled. diff --git a/Products/Intune/docs/Scenarios.md b/Products/Intune/docs/Scenarios.md new file mode 100644 index 0000000..0e62e28 --- /dev/null +++ b/Products/Intune/docs/Scenarios.md @@ -0,0 +1,200 @@ +--- +title: Intune scenarios and capabilities +layout: default +parent: Intune +--- + +# Intune scenarios and capabilities + +This page explains what the Intune toolkit can establish today, what still +requires an administrator in a portal, and what is deliberately blocked. It is +for deployment teams, security reviewers, support teams, and customer IT +owners. No PowerShell knowledge is required. + +The current `0.3.0` release is **write-capable and pilot-first**. It always +assesses the supported tenant state, can apply six approved baseline tasks, and +records each planned or completed action in local reports. Guided and blocked +tasks remain explicit. + +For commands and prerequisites, use the [operator guide](Operator-Guide.md). +For portal procedures, use the +[device enrollment deployment guide](Device-Enrollment-Deployment-Guide.md). + +## At a glance + +| Guide task | Current capability | What the script establishes | Operator action | +| --- | --- | --- | --- | +| 1. Windows automatic MDM enrollment | Guided only | Records why no approved GA write and readback path is used. | Verify the MDM user scope in the Intune admin center. | +| 2. Apple MDM push certificate | Automated read-only assessment | Reads certificate health and warns within the configured renewal window. | Verify, create, or renew the certificate through Intune and Apple when guided. | +| 3. Managed Google Play connection | Guided only | Records the interactive ownership and verification requirements. | Complete customer-owned Google consent and verify the required managed apps. | +| 4. Default compliance setting | Assessment plus gated write | Reads effective `secureByDefault`; with the high-risk category gate, sets it to true and verifies readback. | Apply only after pilot compliance policies and access impact are understood. | +| 5. Enrollment restrictions | Inventory plus gated write | Reads current restrictions; with the high-risk category gate, creates and assigns the configured Android and iOS/iPadOS restrictions. | Confirm supported platforms, ownership policy, and pilot scope. | +| 6. App protection policies | Assessment plus standard write | Reads Android and iOS/iPadOS policies, then creates the Level 1 baseline, targets core apps, and assigns the selected scope. | Review each platform and validate app behavior with pilot users. | +| 7. Device compliance policies | Inventory plus high-risk write | Reads supported policy types; with `-IncludeHighRisk`, creates the approved per-platform payloads and assigns the selected scope. | Confirm device receipt and expected noncompliance before access enforcement. | +| 8. Microsoft 365 Apps | Standard write | Creates the configured Microsoft 365 Apps deployment and assigns the selected scope through the validated beta resource. | Confirm installation status, architecture, channel, and licensing. | +| 9. Windows Backup for Organizations | Guided only | Records that the former Enterprise State Roaming workflow moved to current Windows backup policy management. | Follow current Microsoft administration guidance for the Windows estate. | +| 10. Conditional Access for enrollment | Gated report-only write | Ensures the enrollment service principal exists and creates the policy report-only after every high-risk gate and at least one emergency-access exclusion ID are supplied. | Verify exclusions, sign-in results, device compliance, and rollback before promotion. | + +## What a successful run means + +A successful preview means the toolkit connected to the expected tenant, +completed the supported reads, recorded planned writes and guided boundaries, +and produced its reports without changing tenant state. A successful apply +also means each selected write completed and its readback matched. Neither +result is a compliance certification. App-protection targeting and assignment +require portal verification because that module records the write operations +but does not emit a complete post-write readback projection. + +The HTML report is the operator summary. The JSON sidecar is the structured +evidence record. Review every module outcome: + +| Outcome | Meaning | +| --- | --- | +| `AlreadyCompliant` | The narrow implemented check proved the reported state. | +| `GuidedOnly` | A portal step, external identity, unsupported API, incomplete comparison, or unapproved write remains. | +| `Skipped` | The item was not applicable or an explicit run choice omitted it. | +| `Blocked` | A safety prerequisite refused the operation. Do not bypass it. | +| `Failed` | The toolkit could not establish trustworthy state. Correct the failure before relying on the report. | + +A `-WhatIf` run must not contain a tenant action with status `Created`, +`Updated`, or `Adopted`. An apply run can contain those statuses only for the +approved task and assignment scope. + +## Priority 1: establish enrollment prerequisites + +### Windows automatic enrollment + +The toolkit records this as guided because the current product has no approved +GA write and readback operation. The administrator verifies the MDM user scope +under **Devices > Device onboarding > Enrollment > Windows > Automatic +Enrollment**. + +Use an approved pilot user group before broader enrollment. Keep the deprecated +Windows Information Protection user scope set to `None`. + +### Apple MDM push certificate + +The toolkit reads the certificate singleton and reports validity and renewal +timing without retaining the Apple ID or certificate material. A `404` response +is not treated as proof that no certificate exists because Microsoft does not +document that meaning. The operator must verify the Apple MDM Push Certificate +page when the result is guided. + +Apple sign-in, certificate creation, upload, and annual ownership remain human +steps. Use a customer-owned Apple ID and record a renewal owner. + +### Managed Google Play + +Connecting Managed Google Play binds a customer-controlled Google account to +the tenant through an interactive browser flow. The toolkit does not automate +that flow or the destructive disconnect action. + +After connection, verify Microsoft Intune, Microsoft Authenticator, Intune +Company Portal, and Managed Home Screen in managed Google Play. Record at least +two appropriate Google enterprise owners for continuity. + +### Default compliance setting + +The toolkit reads whether devices without an assigned compliance policy are +treated as noncompliant. A nullable or missing Graph projection is reported as +unknown and guided, not normalized to false or guessed from the documented +service default. The toolkit does not write when the current state is unknown. + +This setting becomes access-impacting when Conditional Access requires a +compliant device. The toolkit changes it only with +`-IncludeHighRisk -EnableComplianceEnforcement` and a customer approval ID. +Do not select that path until enrolled devices receive the intended platform +policies and the deployment team has approved the impact. + +## Priority 2: establish platform policy + +### Enrollment restrictions + +The toolkit inventories existing platform restrictions, assignments, and +selected normalized block signals. It does not decide which platforms the +customer supports and does not compare an approved desired policy. + +The deployment team must decide whether personally owned enrollment is +allowed, which operating-system versions are supported, and whether +manufacturer restrictions are appropriate. With the documented high-risk +gates, the toolkit creates the configured personal-enrollment restrictions and +assigns them to the selected scope. + +### App protection policies + +Android and iOS/iPadOS are assessed separately. The toolkit reports normalized +policy and assignment counts plus target-app readback availability. It excludes +policy names, IDs, group IDs, settings, and raw responses from evidence. + +The module creates the configured Android and iOS/iPadOS Level 1 policies, +targets the core Microsoft apps, and assigns the selected scope. Modern Windows +MAM remains outside the supported path. The imported policy catalog is +candidate input only, and `-EnablePolicyCatalogWrite` refuses the run. + +### Device compliance policies + +The toolkit inventories supported policy types and assignments. If the +documented `scheduledActionsForRule` relationship returns the known fixed-route +`400`, it records the action count as unavailable and continues the remaining +inventory. Unknown is never reported as zero. + +With `-IncludeHighRisk`, the toolkit creates and assigns the approved +per-platform payloads. A broad compliance policy can affect access immediately +when a customer already enforces device compliance through Conditional Access, +so pilot scope is required by default. + +### Microsoft 365 Apps + +The toolkit creates the configured `officeSuiteApp`, assigns the selected +scope, and reads back its display name and update channel through the validated +beta path. Review installation status in Intune and on pilot devices. + +### Windows Backup for Organizations + +The source guide's Enterprise State Roaming task moved to Windows Backup for +Organizations policy management. The toolkit records the transition and keeps +the task guided while policy payload, assignment, readback, migration, and +recovery behavior remain unverified. + +## Priority 3: protect enrollment access + +Conditional Access is the highest-risk task. It can deny enrollment and lock +administrators out when it is enabled before devices enroll and become +compliant. The current module runs only after every documented gate is +supplied and creates the policy report-only by default. + +The policy targets Microsoft Intune Enrollment and requires MFA plus a +compliant device. The module writes the supplied `-BreakGlassUserIds` and +`-BreakGlassGroupIds` values into the policy exclusions. Before promotion, +verify those exclusions in the portal, review report-only sign-in evidence, +confirm device compliance, allow for propagation, and retain the tested +disable or exclusion recovery. + +## Deliberate differences from the source guide + +The source guide often assigns settings to all users. This toolkit uses a +pilot-first contract because enrollment restrictions, compliance, and +Conditional Access can deny access. Tenant-wide assignment is never the safe +default. + +The toolkit also refuses beta-only or ambiguous automation, does not infer +supportability from a successful mock, and does not adopt an unmanaged object +because its display name happens to match. + +## Finish a tenant assessment + +1. Run the toolkit with `-WhatIf` and the intended assignment and gate + parameters. +2. Review the HTML and JSON reports in the approved private evidence location. +3. Resolve failed reads or blocked gates before applying. +4. Apply the standard or approved high-risk pilot scope. +5. Verify `Created` or `Updated` entries, supported readback, portal or device + receipt, and idempotent rerun. +6. Complete guided portal checks under customer change control. +7. Retain before and after evidence outside the repository. +6. Rerun the assessment and compare the new report. +7. Record unresolved guided or blocked items with an owner and review date. + +See [evidence and troubleshooting](Evidence-Troubleshooting.md) for recovery +and [future write capabilities](Future-Write-Capabilities.md) for the evidence +required before any tenant-changing operation can be implemented. \ No newline at end of file diff --git a/Products/Intune/docs/Support-Team-Guide.md b/Products/Intune/docs/Support-Team-Guide.md new file mode 100644 index 0000000..badaaf8 --- /dev/null +++ b/Products/Intune/docs/Support-Team-Guide.md @@ -0,0 +1,175 @@ +--- +title: Intune support-team guide +layout: default +parent: Intune +--- + +# Intune support-team guide + +This guide is for service desk, endpoint operations, and identity support teams +who receive questions during an Intune onboarding. It explains what the current +toolkit does, what a later approved rollout can change for users, and how to +route common failures. + +## What changes when the current script runs + +A `-WhatIf` run connects to Microsoft Graph, reads selected tenant settings, +previews the supported writes, and writes local reports. It changes no tenant +state. + +An approved apply can: + +- create and assign Microsoft 365 Apps; +- create, target, and assign Android and iOS/iPadOS app protection; +- set the default compliance behavior; +- create and assign enrollment restrictions; +- create and assign the approved device compliance policies; and +- create the enrollment Conditional Access policy report-only. + +The toolkit does **not**: + +- enroll or retire a device; +- remove an application; +- promote Conditional Access to enforcement by default; +- upload or renew an Apple certificate; or +- connect or disconnect Managed Google Play. + +Users should see no device or sign-in change because a preview ran. After an +apply, compare the symptom with the selected modules, pilot scope, and change +record before attributing it to the toolkit. + +## What the deployment team receives + +| Artifact | Purpose | Handling | +| --- | --- | --- | +| `intune-run-report.html` | Human-readable task and module summary. | Store in the approved private customer location. | +| `intune-run-log.json` | Structured action and disposition evidence. | Restrict to the deployment and review teams. | +| `intune-applicability.json` | License and task applicability record. | Keep with the run evidence. | + +These files are tenant evidence. Do not attach raw reports to a public ticket +or paste them into chat. Use sanitized fields when escalating. + +## What users can experience after later approved changes + +| Change | Typical user experience | Support readiness | +| --- | --- | --- | +| Automatic enrollment scope | Windows may prompt or enroll when the user adds a work account or joins the organization. | Confirm supported enrollment method and ownership. | +| Apple certificate setup or renewal | Apple devices can enroll and continue receiving management commands. | Track annual renewal ownership and the Apple account used. | +| Managed Google Play connection | Android Enterprise enrollment and managed Android applications become available. | Confirm Google enterprise ownership and required managed applications. | +| Enrollment restrictions | A device can be refused because of platform, OS version, manufacturer, ownership, or device limit. | Know the assigned restriction, its priority, and approved exceptions. | +| App protection policy | A managed app can require a PIN or restrict organization-data movement and saving. | Confirm the user's platform, app, account, and policy assignment. | +| Device compliance policy | Company Portal can report settings the user must remediate. | Provide approved remediation steps and allow for device check-in. | +| Microsoft 365 Apps deployment | Applications install after assignment and device synchronization. | Confirm assignment, licensing, architecture, and installation status. | +| Windows backup policy | Supported Windows settings and backup behavior can change. | Route to the Windows platform owner. | +| Conditional Access | Sign-in or enrollment can require MFA and a compliant device. | Confirm report-only review, the written emergency-access exclusions, and ownership before enforcement. | + +## Triage by symptom + +### A device cannot begin enrollment + +Check, in order: + +1. The user has the expected Intune license. +2. Intune is the tenant's MDM authority. +3. The device platform and operating-system version are supported. +4. The user is in the intended automatic-enrollment or pilot scope. +5. Enrollment restrictions allow the platform and ownership type. +6. The user has not reached the device enrollment limit. +7. Platform prerequisites are complete: Apple certificate, Managed Google Play, + or the selected Windows enrollment method. + +Do not broaden a restriction or assign all users as a troubleshooting shortcut. + +### Apple enrollment fails + +Check the Apple MDM push certificate under **Devices > Device onboarding > +Enrollment > Apple > Apple MDM Push Certificate**. Confirm it is active and +that renewal used the same Apple account and certificate identity. + +An assessment `404` is not proof that no certificate exists. Verify the portal +before starting certificate setup or renewal. + +### Android Enterprise enrollment fails + +Confirm the Managed Google Play connection is healthy and customer-owned, the +required managed applications are present, and the selected Android Enterprise +enrollment method matches the device ownership model. + +Do not disconnect Managed Google Play for troubleshooting. Microsoft documents +disconnect as destructive to Android Enterprise management. + +### Company Portal reports no compliance policy + +Confirm whether the user and device should receive a platform compliance +policy. Review assignment, exclusions, filters, device ownership, and last +check-in. The tenant-wide setting for devices without a policy can report the +device as compliant or noncompliant, but it does not substitute for assigning +the intended platform policy. + +### A device is noncompliant + +Identify the exact failed setting and policy. Confirm the device checked in +after the latest change and that the user has an approved remediation path. +Do not tell the user to remove management, factory reset, or bypass security +controls unless the platform owner approved that recovery. + +### A managed application is missing + +Confirm the application assignment, user or device target, licensing, +architecture, platform, network access, and device synchronization. An app +protection policy protects organization data inside an application; it does +not install that application. + +### A user is blocked during sign-in or enrollment + +Capture the sign-in timestamp and correlation details through the approved +support process. Check Conditional Access results, target resource, user and +group scope, exclusions, authentication requirement, device registration, and +current compliance state. + +Treat administrator lockout or an unavailable emergency-access account as an +incident. Use the named identity escalation owner and approved policy disable +or exclusion procedure. + +## Safe escalation record + +Include: + +- UTC timestamp and support case number; +- product version and module or guide task; +- device platform, operating-system version, and ownership class; +- enrollment method or affected managed application; +- sanitized error code, correlation ID, and policy category; +- last successful device check-in when relevant; +- whether the impact affects one user, a pilot group, or a wider scope; +- expected result and last approved change; and +- current recovery owner. + +Do not include credentials, tokens, certificate material, raw tenant exports, +full report files, device serial numbers, personal data, or unnecessary user +identifiers. + +## Ownership map + +| Issue | First owner | Escalate to | +| --- | --- | --- | +| Script, report, or deterministic assessment failure | Deployment team | Toolkit maintainer with sanitized evidence. | +| License, Intune RBAC, Entra role, or GDAP boundary | Tenant access owner | Security and permissions reviewer. | +| Apple certificate or Apple enrollment | Apple platform owner | Customer Apple identity owner. | +| Android Enterprise or Managed Google Play | Android platform owner | Customer Google enterprise owner. | +| Windows enrollment, Microsoft 365 Apps, or Windows backup | Windows platform owner | Endpoint engineering. | +| Compliance policy or enrollment restriction | Endpoint security owner | Change approver and security reviewer. | +| Conditional Access or sign-in block | Identity owner | Security incident and emergency-access owner. | + +For the deployment sequence, use the +[change-management playbook](Change-Management-Playbook.md). For detailed +assessment errors, use +[evidence and troubleshooting](Evidence-Troubleshooting.md). + +## Microsoft references + +- [Microsoft Intune enrollment guide](https://learn.microsoft.com/intune/device-enrollment/guide) +- [Troubleshoot device enrollment in Intune](https://learn.microsoft.com/troubleshoot/mem/intune/troubleshoot-device-enrollment-in-intune) +- [Device compliance overview](https://learn.microsoft.com/intune/device-security/compliance/overview) +- [Monitor app information and assignments](https://learn.microsoft.com/intune/app-management/monitor-assignments) +- [Conditional Access insights and reporting](https://learn.microsoft.com/entra/identity/conditional-access/howto-conditional-access-insights-reporting) \ No newline at end of file diff --git a/Products/Purview/Config/PurviewConfig.psd1 b/Products/Purview/Config/PurviewConfig.psd1 index 59eb8ac..9ce57ac 100644 --- a/Products/Purview/Config/PurviewConfig.psd1 +++ b/Products/Purview/Config/PurviewConfig.psd1 @@ -1,570 +1,569 @@ -@{ - # ========================================================================= - # Purview Best Practice Toolkit — central configuration - # - # All tunables live here so partners can fork, edit, and re-run. - # Reference: "Data Security Best Practice Deployment" — Microsoft 365 - # Business Premium Security Deployment Guide. - # ========================================================================= - - # Marker stamped in object descriptions for safe re-runs and rollback. - ManagedByTag = '[Managed by SMBTool Purview Toolkit]' - - # ----- DLP simulation ----- - # When $true, new DLP policies are created in simulation mode - # (Mode = TestWithoutNotifications) with Purview's native - # "Turn the policy on if it's not edited within fifteen days of - # simulation" flag set (StartSimulation = $true). Purview itself - # enforces the 15-day countdown — the script does NOT manage it. - # Set to $false to create policies directly in Enable mode. - DlpStartInSimulation = $true - - # ----- Content marking (header / footer / watermark) ----- - # Master switch. When $false, the toolkit will SKIP applying any - # ApplyContentMarkingHeader/Footer or ApplyWaterMarking settings - # to labels, regardless of the per-label `ContentMark = $true` - # entries below. The label's other behaviour (display name, tooltip, - # priority, encryption, scope) is unaffected. Flip to $true once - # you are ready to roll out visual markings to clients. - EnableContentMarking = $false - - # ----- Sensitivity labels ----- - # - # SMB-tuned subset of Microsoft's documented default sensitivity labels. - # See: https://learn.microsoft.com/en-us/purview/default-sensitivity-labels-policies - # - # Order: lowest priority (least sensitive) FIRST. The script applies the - # priority order in the sequence shown. - # - # Label set (8 labels, 3 parents + 5 sub-labels): - # * Public — no protection - # * General — no protection (default for EMAIL) - # * Confidential — no protection (parent) - # * Confidential \ All Employees — Footer only (default for DOCUMENTS) - # * Confidential \ Specific People — Footer + UserDefined encryption (Outlook: Do Not Forward) - # * Confidential \ Internal Exception — Footer only - # * Highly Confidential — Watermark "HIGHLY CONFIDENTIAL" - # * Highly Confidential \ All Employees — Footer + Template encryption (tenant-scoped) - # * Highly Confidential \ Specific People — Footer + UserDefined encryption (Outlook: Do Not Forward) - # * Highly Confidential \ Internal Exception — Footer + Template encryption (tenant-scoped) - # - # Encryption / protection - # ----------------------- - # Template-protected labels (HC \ All Employees, HC \ Internal Exception) - # grant the configured rights bundle to ALL USERS IN YOUR TENANT ONLY — - # not external authenticated identities from other Microsoft 365 tenants. - # The toolkit auto-resolves your tenant's verified domain at runtime and - # substitutes it into `EncryptionRightsDefinitions` (see comment at that - # field below for the token syntax and fallback behaviour). - # - # UserDefined-protected labels (both Specific People sub-labels) let the - # user pick the recipients and permissions at apply time. Outlook - # behaviour is "Do Not Forward"; Office apps (Word, Excel, PowerPoint) - # prompt the user to assign permissions. - # - # The default Template rights bundle is Microsoft's "Reviewer" set: - # users get View, View Rights, Edit Content, Save, Reply, Reply All, - # Forward. Co-authoring (auto-save + simultaneous editing in Office) - # and macros / programmatic access via the Office object model are NOT - # included by default, because OBJMODEL access is the main vector by - # which third-party apps reading doc metadata break under encryption. - # - # If you need a wider rights bundle (e.g. Copy, Print, Allow Macros for - # third-party tooling), edit `EncryptionRightsDefinitions` below - # directly — typically by appending `,EXTRACT,PRINT,OBJMODEL` to the - # `{TenantDomain}:` rights string. Validate in a pilot tenant before - # rolling out, since OBJMODEL access affects every third-party app that - # reads Office document metadata. - # - # Auto-labeling (client-side and service-side) is intentionally NOT - # configured here. Add it deliberately via the Purview portal once your - # team has reviewed false-positive risk and user-impact trade-offs. - # - # Sub-label Name uniqueness - # ------------------------- - # IPPS requires every label `Name` (the internal identifier) to be unique - # across the tenant. Because Microsoft's defaults reuse display names like - # "All Employees" under multiple parents, we prefix each sub-label Name - # with its parent (e.g. `HCAllEmps`). The `AllEmployees` Name on - # Confidential is preserved unchanged so existing DLP rules with - # `LabelPath = 'Confidential/AllEmployees'` keep resolving. - # - # Label color - # ----------- - # Each label has an optional `Color = '#RRGGBB'` hex string. The toolkit - # applies it via `New-Label`/`Set-Label -AdvancedSettings @{color=...}` - # so the colour shows up next to the label in Office and Purview. Defaults - # below mirror Microsoft's recommended palette (green=Public, blue=General, - # amber=Confidential family, red=Highly Confidential family). Sub-labels - # inherit their parent's family colour. To disable, remove the `Color` - # entry — the toolkit will leave the label's current colour unchanged. - Labels = @( - @{ - Name = 'Public' - DisplayName = 'Public' - BuiltInName = 'defa4170-0d19-0005-0001-bc88714345d2' # MS built-in default (0001) - region/scheme-proof adoption key - Tooltip = 'Business data that is specifically prepared and approved for public consumption.' - Color = '#13A10E' - Encrypt = $false - ContentMark = $false - } - @{ - Name = 'General' - DisplayName = 'General' - BuiltInName = 'defa4170-0d19-0005-0002-bc88714345d2' # MS built-in default (0002) - label GROUP in modern scheme - Tooltip = 'Business data that is not intended for public consumption. However, this can be shared with external partners, as required. Examples include a company internal telephone directory, organizational charts, internal standards, and most internal communication.' - Color = '#3A96DD' - Encrypt = $false - ContentMark = $false - # General is a label GROUP (matches the Microsoft built-in default): a - # non-applicable container. Its assignable children are below; the - # published / email-default child carries the container scope - # ('Site' + 'UnifiedGroup') so new Teams / M365 Groups / SharePoint sites - # can be classified. -SkipContainerLabels strips those bits at deploy time. - SubLabels = @( - @{ - Name = 'GeneralAnyone' - DisplayName = 'Anyone (unrestricted)' - BuiltInName = 'defa4170-0d19-0005-0003-bc88714345d2' # MS built-in default (0003) - Tooltip = 'Organization data that is not intended for public consumption but can be shared with external partners if appropriate. Examples include customer conversations that do not include sensitive info, or released marketing materials.' - Color = '#3A96DD' - Encrypt = $false - ContentMark = $false - # Published + Outlook email default. Carries container scope. - ContentType = 'File, Email, Site, UnifiedGroup' - } - @{ - Name = 'GeneralAllEmployees' - DisplayName = 'All Employees (unrestricted)' - BuiltInName = 'defa4170-0d19-0005-0004-bc88714345d2' # MS built-in default (0004) - Tooltip = 'Organization data that is not intended for public consumption. If you need to share this content with external partners, confirm with other data owners that it is OK to share and then change the label to General \ Anyone (unrestricted).' - Color = '#3A96DD' - Encrypt = $false - ContentMark = $false - } - ) - } - @{ - Name = 'Confidential' - DisplayName = 'Confidential' - BuiltInName = 'defa4170-0d19-0005-0005-bc88714345d2' # MS built-in default (0005) - Tooltip = 'Sensitive business data that could cause damage to the business if shared with unauthorized people. Examples include contracts, security reports, forecast summaries, and sales account data.' - Color = '#EAA300' - Encrypt = $false - ContentMark = $false - SubLabels = @( - @{ - # Internal Name preserved for backward compat with the - # existing DLP rule LabelPath = 'Confidential/AllEmployees'. - Name = 'AllEmployees' - DisplayName = 'All Employees' - BuiltInName = 'defa4170-0d19-0005-0007-bc88714345d2' # MS built-in default (0007) - Tooltip = 'Confidential data shared internally with all employees. No encryption is applied; the label is informational and adds a footer marking.' - Color = '#EAA300' - Encrypt = $false - ContentMark = $true - FooterText = 'Classified as Confidential' - # Published label — see ContentType notes on the General - # label above. Adds container scope so this label appears - # in the Purview Edit sensitivity label -> Scope page for - # M365 Groups, Teams, and SharePoint sites. Required for - # the SMB recommended pattern where every new Team is - # classified at creation time. - ContentType = 'File, Email, Site, UnifiedGroup' - } - @{ - # Consolidated Confidential sub-label: replaces the former - # 'Specific People' + 'Internal Exception' sub-labels, renamed to - # the Microsoft built-in 'Trusted People' default (ordinal 0008) so - # the tool ADOPTS the existing default instead of creating duplicates. - # Carries the encrypted (UserDefined / Outlook Do Not Forward) protection. - Name = 'TrustedPeople' - DisplayName = 'Trusted People' - BuiltInName = 'defa4170-0d19-0005-0008-bc88714345d2' # MS built-in default (0008) - Tooltip = 'Confidential data shared with specific trusted people inside or outside the organization. Encryption is applied; trusted recipients can reshare as needed (Outlook: Encrypt-Only).' - Color = '#EAA300' - Encrypt = $true - ProtectionType = 'UserDefined' - UserDefinedOutlookBehavior = 'EncryptOnly' # Outlook: Encrypt-Only (matches the MS default Trusted People; trusted people can reshare) - ContentMark = $true - FooterText = 'Classified as Confidential' - } - ) - } - @{ - Name = 'HighlyConfidential' - DisplayName = 'Highly Confidential' - BuiltInName = 'defa4170-0d19-0005-0009-bc88714345d2' # MS built-in default (0009) - Tooltip = 'Very sensitive business data that would cause damage to the business if it was shared with unauthorized people. Examples include employee and customer information, passwords, source code, and pre-announced financial reports.' - Color = '#A4262C' - Encrypt = $false - ContentMark = $true - WatermarkText = 'HIGHLY CONFIDENTIAL' - SubLabels = @( - @{ - Name = 'HCAllEmps' - DisplayName = 'All Employees' - BuiltInName = 'defa4170-0d19-0005-000a-bc88714345d2' # MS built-in default (000a) - Tooltip = 'Highly confidential data with Co-Author rights for all users in this tenant only (view, edit, save, copy, print, allow macros, reply, forward). Data owners can track and revoke content.' - Color = '#A4262C' - Encrypt = $true - ProtectionType = 'Template' - # Per-label override for the rights bundle. Uses the - # Microsoft "Co-Author" set instead of the toolkit's - # global Reviewer default — Co-Author adds EXTRACT - # (copy), PRINT, and OBJMODEL (Office object-model - # access, needed for macros + simultaneous editing). - # `{TenantDomain}` resolves to all users in this - # tenant only (matches the global scope; excludes - # external / B2B / MSA). See header comment above - # and Skills/Purview/_CONVENTIONS.md for the full - # semantics. - # - # NOTE: granting OBJMODEL is NECESSARY BUT NOT - # SUFFICIENT for Office multi-user co-authoring on - # encrypted files. The tenant-wide switch - # `-EnableLabelCoAuthoring` (Set-PolicyConfig - # -EnableLabelCoauth:$true, opt-in) must also be on. - # Without both, users get individual edit rights but - # not concurrent editing. - EncryptionRightsDefinitions = '{TenantDomain}:VIEW,VIEWRIGHTSDATA,EDIT,DOCEDIT,EXTRACT,PRINT,OBJMODEL,REPLY,REPLYALL,FORWARD' - ContentMark = $true - FooterText = 'Classified as Highly Confidential' - # Published label — see ContentType notes on the General - # label above. Adds container scope so this label appears - # in the Purview Edit sensitivity label -> Scope page for - # M365 Groups, Teams, and SharePoint sites. - # - # NOTE: Microsoft Purview will not apply the encryption - # protection (Template / Co-Author rights) to the CONTAINER - # itself — encryption stays on files and emails. The - # container-scope bits drive the privacy / sharing / - # unmanaged-device behaviour of the Team / site, not its - # contents' RMS encryption. - ContentType = 'File, Email, Site, UnifiedGroup' - } - @{ - Name = 'HCSpecificPeople' - DisplayName = 'Specific People' - BuiltInName = 'defa4170-0d19-0005-000b-bc88714345d2' # MS built-in default (000b) - Tooltip = 'Highly confidential data that requires protection and can be viewed only by people you specify and with the permission level you choose.' - Color = '#A4262C' - Encrypt = $true - ProtectionType = 'UserDefined' - UserDefinedOutlookBehavior = 'DoNotForward' # Outlook: Do Not Forward; Office apps prompt user - ContentMark = $true - FooterText = 'Classified as Highly Confidential' - } - ) - } - ) - - # ----- Encryption rights bundle ----- - # Microsoft's "Reviewer" bundle: View, View Rights, Edit Content, Save, - # Reply, Reply All, Forward. - # - # IDENTITY token (one of): - # * `{TenantDomain}` — RECOMMENDED. Resolved at runtime to the - # tenant's primary verified domain (e.g. `contoso.com`). Per Microsoft - # Entra rights-management semantics, specifying ANY verified domain - # in the tenant automatically expands to ALL verified domains in that - # tenant — so the label is accessible to every user in your tenant - # but NOT to external authenticated users from other M365 tenants - # (which `AuthenticatedUsers` would include). If the toolkit cannot - # resolve the tenant domain at runtime, it FAILS CLOSED (throws an - # error and writes a Failed run-log entry) rather than silently - # widening scope to `AuthenticatedUsers`. Re-run after fixing - # connectivity, or opt in to broad scope explicitly with - # `{AuthenticatedUsers}` / literal `AuthenticatedUsers`. - # - # * `AuthenticatedUsers` — Microsoft's broad "any signed-in M365 - # identity" group. Includes B2B guests, partners, and any external - # Entra user. Use this only if you intentionally need to share - # encrypted content across tenants. - # - # * `user@domain.com` — A specific mail-enabled user / group / - # mailbox in your tenant. Useful for narrow-scope custom labels. - # - # Co-authoring (auto-save + simultaneous editing) and macro / object- - # model access are NOT granted, which is the safe default when third- - # party apps consume Office documents in your tenant. - # - # If you need a wider bundle (the Microsoft "Co-Author" set adds Copy - # (EXTRACT), Print, Allow Macros (OBJMODEL)), edit the string below - # directly. The "Co-Author" equivalent is: - # '{TenantDomain}:VIEW,VIEWRIGHTSDATA,EDIT,DOCEDIT,EXTRACT,PRINT,REPLY,REPLYALL,FORWARD,OBJMODEL' - # OBJMODEL is the right that most often breaks third-party apps reading - # doc metadata, so validate in a pilot tenant before promoting. - # - # PER-LABEL OVERRIDE: - # Any label with `Encrypt=$true / ProtectionType='Template'` may carry - # its own `EncryptionRightsDefinitions = '...'` field, which overrides - # this global default JUST for that label. Same token semantics apply - # ({TenantDomain}, {AuthenticatedUsers}, literal identity). Used by - # `HighlyConfidential\All Employees` (HCAllEmps) to grant the wider - # Co-Author bundle (allows copy, print, macros, Office co-authoring) - # while sibling sub-labels stay on the narrower Reviewer default. - EncryptionRightsDefinitions = '{TenantDomain}:VIEW,VIEWRIGHTSDATA,EDIT,DOCEDIT,REPLY,REPLYALL,FORWARD' - - EncryptionContentExpiredOnDateInDaysOrNever = 'Never' - EncryptionOfflineAccessDays = 30 - - # ----- Label publishing policy ----- - LabelPolicy = @{ - Name = 'SMBTool - Default Label Policy' - Comment = 'Publishes the SMBTool baseline sensitivity labels to all users.' - # Default applied label for DOCUMENTS (Word, Excel, PowerPoint, and - # service-side defaults). Per the SMB profile, this is - # 'Confidential\All Employees' (Name = 'AllEmployees') so every new - # document gets footer marking by default. Resolved by Name at - # runtime; sub-label Names are tenant-unique so this resolves cleanly. - DefaultLabel = 'AllEmployees' - # Default applied label for EMAIL (Outlook). General is now a label GROUP - # (non-applicable), so this points at its assignable child - # 'General \ Anyone (unrestricted)' (Name = 'GeneralAnyone'). Mapped to the - # IPPS advanced setting `OutlookDefaultLabel`. Set to $null to omit an - # Outlook-specific default and fall back to DefaultLabel. - DefaultLabelForEmail = 'GeneralAnyone' - # Subset of label Names to PUBLISH to end users via this policy. - # All labels in the Labels array are still CREATED in the tenant, - # but only these are visible/applicable in clients. Sub-label Names - # may be listed; the script auto-includes their parents so the - # Purview hierarchy stays valid. - # Set to $null or an empty array to publish every label that's - # created. - PublishedLabels = @( - 'Public' - 'GeneralAnyone' # General \ Anyone (unrestricted) - assignable email-default child - 'AllEmployees' # Confidential \ All Employees - 'HCAllEmps' # Highly Confidential \ All Employees - ) - # Mandatory labelling is ON by default: every Office app (Word / - # Excel / PowerPoint / Outlook) will prompt the user to pick a - # sensitivity label before they can save a new document or send a - # new email. This closes the "untagged content" hole that breaks - # DLP rules, auto-classification, and Copilot exclusions, all of - # which key off the label. Set to $false in your fork only if the - # customer explicitly cannot tolerate the prompt during rollout - # (a documented end-user training plan is the better answer). - # See docs/End-User-Adoption-Guide.md for the user-facing impact. - MandatoryLabelling = $true - # Justification required when downgrading sensitivity. - DowngradeJustification = $true - # "Inherit label from attachments": when a user sends an email that has - # labelled attachments, apply the highest-priority attachment label to the - # email. 'Automatic' applies it silently (the Purview "on" behaviour); - # 'Recommended' prompts the user; set to $null to leave the feature off. - # Requires the published labels to be scoped to both Files and Emails - # (the SMBTool published labels are). Maps to the IPPS label-policy advanced - # setting `AttachmentAction`. - AttachmentAction = 'Automatic' - } - - # ----- DLP policies ----- - # Two policies (Microsoft's recommendation): one for Exchange, one for SPO + OneDrive. - # - # Workloads supported under Microsoft 365 Business Premium: - # * 'Exchange' - mailboxes - # * 'SharePointOneDrive' - SPO sites + OneDrive for Business - # - # E5 / Purview Suite ONLY (rejected when Deploy-PurviewBestPractice.ps1 is - # run with -BPOnly): - # * 'Endpoint' / 'Devices' - Endpoint DLP - # * 'OnPremisesScanner' - on-prem file shares & SP servers - # * 'DefenderForCloudApps' - 3rd party apps via MCAS - # * 'PowerBI' - Power BI tenants - DlpPolicies = @( - @{ - Name = 'SMBTool - DLP - Confidential and HC external (EXO)' - Comment = 'Blocks Exchange messages labelled with any Confidential or Highly Confidential sub-label from being sent outside the organisation.' - Workload = 'Exchange' - RuleName = 'SMBTool - DLP Rule - Confidential and HC - Exchange' - # All Confidential + Highly Confidential sub-labels are OR-matched. - # Resolved to GUIDs at runtime. - LabelPaths = @( - 'Confidential/AllEmployees' - 'Confidential/TrustedPeople' - 'HighlyConfidential/HCAllEmps' - 'HighlyConfidential/HCSpecificPeople' - ) - BlockAccess = $true - # IMPORTANT — DO NOT DELETE 'BlockAccessScope' on the Exchange rule. - # On Exchange, enforcement is driven entirely by AccessScope='NotInOrganization' - # (set in Setup-DLP.ps1 line ~496). The IPPS engine IGNORES BlockAccessScope - # for Exchange rules at evaluation time — it is read only by Purview's web - # UI rule-editor to highlight the matching radio button ("Block only people - # outside your organization"). If you remove this line: - # * Enforcement is unchanged (Exchange still blocks external recipients). - # * The Purview UI shows the radio group EMPTY when an admin opens the - # rule, making it look misconfigured, and -AdoptExisting may flag it - # as drift the next time the toolkit runs. - # Keep the value at 'PerUser' for UI parity with the radio "Block only - # people outside your organization". The SPO/ODFB rule below uses the same - # field but there the engine DOES read it — see the comment on that rule. - BlockAccessScope = 'PerUser' - NotifyUser = @('SiteAdmin','LastModifier','Owner') - GenerateIncidentReport = @('SiteAdmin') - } - @{ - Name = 'SMBTool - DLP - Confidential and HC external (SPO+ODB)' - Comment = 'Blocks SharePoint and OneDrive files labelled with any Confidential or Highly Confidential sub-label from being shared externally.' - Workload = 'SharePointOneDrive' - RuleName = 'SMBTool - DLP Rule - Confidential and HC - SPO ODFB' - LabelPaths = @( - 'Confidential/AllEmployees' - 'Confidential/TrustedPeople' - 'HighlyConfidential/HCAllEmps' - 'HighlyConfidential/HCSpecificPeople' - ) - BlockAccess = $true - # SPO/ODFB: 'PerUser' = "Block only people outside your organization". - # 'All' would mean "Block everyone" (incl. internal users). - # 'PerAnonymousUser' would only block anonymous link recipients. - BlockAccessScope = 'PerUser' - NotifyUser = @('SiteAdmin','LastModifier','Owner') - GenerateIncidentReport = @('SiteAdmin') - } - # ----------------------------------------------------------------- - # Endpoint DLP — audit copy/print/USB/network-share actions on - # devices when content carries any Confidential or Highly - # Confidential sub-label. - # - # LICENSING: requires Microsoft 365 E5 / Purview Suite. The toolkit - # rejects this policy when run with -BPOnly (Business Premium only). - # - # MODE: created in simulation (TestWithoutNotifications) by default - # via DlpStartInSimulation = $true. After validating in simulation, - # toggle the Purview portal "Turn the policy on if it's not edited - # within fifteen days of simulation" checkbox or set - # DlpStartInSimulation = $false to enforce. - # - # ACTIONS (EndpointDlpRestrictions): default 'Audit' so simulation - # produces telemetry without interrupting users. Flip individual - # entries to 'Block' / 'BlockOverride' / 'Warn' once the desired - # behaviour is confirmed in DLP Activity Explorer. - # ----------------------------------------------------------------- - @{ - Name = 'SMBTool - DLP - Endpoint Confidential and HC' - Comment = 'Endpoint DLP - audits copy / print / USB / network-share / cloud-sync actions on managed devices when content is labelled Confidential or Highly Confidential.' - Workload = 'Endpoint' - RuleName = 'SMBTool - DLP Rule - Endpoint Confidential and HC' - LabelPaths = @( - 'Confidential/AllEmployees' - 'Confidential/TrustedPeople' - 'HighlyConfidential/HCAllEmps' - 'HighlyConfidential/HCSpecificPeople' - ) - # Endpoint device-action restrictions. Hashtable keys are - # case-sensitive: { Setting = ''; Value = '' }. - # Valid Setting values (per the API error response — these are the - # short forms the cmdlet actually accepts, not the longer names - # shown in some MS Learn pages): - # Print, CopyPaste, ScreenCapture, RemovableMedia, NetworkShare, - # UnallowedApps, CloudEgress, UnallowedBluetoothTransferApps, - # RemoteDesktopServices, WebPagePrint, WebPageCopyPaste, - # WebPageSaveToLocal, PasteToBrowser, AccessByAnyAppDefault, - # UnallowedFtpTransferApps. - # Valid Value values: Audit, Block, Warn, BlockOverride. - EndpointDlpRestrictions = @( - @{ Setting = 'CopyPaste'; Value = 'Audit' } - @{ Setting = 'Print'; Value = 'Audit' } - @{ Setting = 'RemovableMedia'; Value = 'Audit' } - @{ Setting = 'NetworkShare'; Value = 'Audit' } - ) - EnforcePortalAccess = $true - ReportSeverityLevel = 'Medium' - GenerateAlert = $true - NotifyUser = @() - GenerateIncidentReport = @() - } - ) - - # ----- Retention ----- - Retention = @{ - Name = 'SMBTool - Exchange 7-year retention' - Comment = 'Retains Exchange mailbox content for 7 years from creation, then deletes. Aligned with common SMB regulatory record-keeping requirements (ATO/IRS/SEC/ASIC) but partners must confirm against the customer''s vertical.' - RuleName = 'SMBTool - Exchange 7yr Rule' - DurationDays = 2555 # 7 years - DurationDisplayHint = 'Years' - Action = 'KeepAndDelete' - ExpirationDateOption = 'CreationAgeInDays' - # Locations: Exchange by default. Add 'SharePoint','OneDrive' to widen. - Locations = @('Exchange') - } - - # ----- AI governance & data security (default ON for E5 / Purview Suite) ----- - # - # Applied by default. AI governance is auto-skipped on Business Premium - # tenants ($BPOnly) because the Microsoft 365 Copilot DLP policy plane - # is included in E5 / Purview Suite, not in Business Premium. Opt out - # with -SkipAIControls. - # - # Per Microsoft Learn (https://learn.microsoft.com/purview/dlp-microsoft365-copilot-location-learn-about): - # the Microsoft 365 Copilot DLP location protects BOTH paid Microsoft - # 365 Copilot AND the free Microsoft 365 Copilot Chat experience, so - # policy creation succeeds on E5 / Purview Suite tenants regardless of - # whether paid Copilot per-user licenses are present. - # - # Per Microsoft Learn (New-DlpCompliancePolicy / New-DlpComplianceRule): - # - Locations JSON pins the Copilot location GUID - # (470f2276-e011-4e9d-a6ec-20768be3a4b0). - # - EnforcementPlanes 'CopilotExperiences' selects the Copilot enforcement - # surface. Add 'Agent' here when extending coverage to Agent 365. - # - The rule action 'ExcludeContentProcessing'='Block' prevents Copilot - # from processing the labelled content (it is excluded from grounding, - # summarisation, and search results). - AIGovernance = @{ - DlpPolicies = @( - # AI_054 — Block Microsoft 365 Copilot from processing - # Highly Confidential content. - # https://microsoft.github.io/zerotrustassessment/docs/workshop-guidance/AI/AI_054 - @{ - Name = 'SMBTool - AI - Block Copilot for Highly Confidential' - RuleName = 'SMBTool - AI Rule - Block Copilot Highly Confidential' - Comment = 'AI_054 - Prevents Microsoft 365 Copilot from processing content labelled Highly Confidential.' - Mode = 'Enable' # Enable | TestWithNotifications | TestWithoutNotifications | Disable - EnforcementPlanes = @('CopilotExperiences') # Add 'Agent' to also cover Agent 365 (preview) - # Locations: target the Microsoft 365 Copilot location. - # Inclusions/Exclusions follow the Locations JSON schema. - # Default = tenant-wide. Use the commented form below to - # pilot on a security group first. - Locations = @( - @{ - Workload = 'Applications' - Location = '470f2276-e011-4e9d-a6ec-20768be3a4b0' - Inclusions = @(@{ Type = 'Tenant'; Identity = 'All' }) - # Inclusions = @(@{ Type = 'Group'; Identity = 'CopilotPilotUsers@contoso.com' }) - # Exclusions = @(@{ Type = 'Group'; Identity = 'CopilotExempt@contoso.com' }) - } - ) - # Sensitivity label paths to block. Resolved to GUIDs at runtime. - LabelPaths = @('HighlyConfidential') - # Block Copilot from processing matched content. Other valid - # 'setting' values include 'EncryptionEnabled' and - # 'BlockAccess'; ExcludeContentProcessing is the recommended - # action for Copilot per Microsoft documentation. - RestrictAccess = @(@{ setting = 'ExcludeContentProcessing'; value = 'Block' }) - } - ) - } - - # ----- Tenant settings (foundational) ----- - TenantSettings = @{ - EnableUnifiedAuditLog = $true - EnableSensitivityLabelForPDF = $true - EnableAIPIntegrationInSPO = $true - # Tenant-wide label co-authoring metadata-format switch - # (Set-PolicyConfig -EnableLabelCoauth). DEFAULT-OFF and operator - # opt-in via -EnableLabelCoAuthoring on - # Deploy-PurviewBestPractice.ps1. This switch is ONE-WAY: once - # enabled, label metadata moves out of custom properties; disabling - # it later loses labels on unencrypted Office files. Any app, - # service, scanner or script reading doc metadata from the old - # location (AIP scanner < v3.0, OneDrive sync < 19.002, MIP SDK - # < 1.7, custom DLP scanners, custom Exchange mail-flow rules, - # etc.) will break. The toolkit refuses to flip this on partner - # tenants by default to avoid taking responsibility for that risk. - # Ref: https://learn.microsoft.com/purview/sensitivity-labels-coauthoring - EnableLabelCoAuth = $false - } -} +@{ + # ========================================================================= + # Purview Best Practice Toolkit — central configuration + # + # All tunables live here so partners can fork, edit, and re-run. + # Reference: "Data Security Best Practice Deployment" — Microsoft 365 + # Business Premium Security Deployment Guide. + # ========================================================================= + + # Marker stamped in object descriptions for safe re-runs and rollback. + ManagedByTag = '[Managed by SMBTool Purview Toolkit]' + + # ----- DLP simulation ----- + # When $true, new DLP policies are created in simulation mode + # (Mode = TestWithoutNotifications) with Purview's native + # "Turn the policy on if it's not edited within fifteen days of + # simulation" flag set (StartSimulation = $true). Purview itself + # enforces the 15-day countdown — the script does NOT manage it. + # Set to $false to create policies directly in Enable mode. + DlpStartInSimulation = $true + + # ----- Content marking (header / footer / watermark) ----- + # Master switch. When $false, the toolkit will SKIP applying any + # ApplyContentMarkingHeader/Footer or ApplyWaterMarking settings + # to labels, regardless of the per-label `ContentMark = $true` + # entries below. The label's other behaviour (display name, tooltip, + # priority, encryption, scope) is unaffected. Flip to $true once + # you are ready to roll out visual markings to clients. + EnableContentMarking = $false + + # ----- Sensitivity labels ----- + # + # SMB-tuned subset of Microsoft's documented default sensitivity labels. + # See: https://learn.microsoft.com/en-us/purview/default-sensitivity-labels-policies + # + # Order: lowest priority (least sensitive) FIRST. The script applies the + # priority order in the sequence shown. + # + # Label set (8 labels, 3 parents + 5 sub-labels): + # * Public — no protection + # * General — no protection (default for EMAIL) + # * Confidential — no protection (parent) + # * Confidential \ All Employees — Footer only (default for DOCUMENTS) + # * Confidential \ Specific People — Footer + UserDefined encryption (Outlook: Do Not Forward) + # * Confidential \ Internal Exception — Footer only + # * Highly Confidential — Watermark "HIGHLY CONFIDENTIAL" + # * Highly Confidential \ All Employees — Footer + Template encryption (tenant-scoped) + # * Highly Confidential \ Specific People — Footer + UserDefined encryption (Outlook: Do Not Forward) + # * Highly Confidential \ Internal Exception — Footer + Template encryption (tenant-scoped) + # + # Encryption / protection + # ----------------------- + # Template-protected labels (HC \ All Employees, HC \ Internal Exception) + # grant the configured rights bundle to ALL USERS IN YOUR TENANT ONLY — + # not external authenticated identities from other Microsoft 365 tenants. + # The toolkit auto-resolves your tenant's verified domain at runtime and + # substitutes it into `EncryptionRightsDefinitions` (see comment at that + # field below for the token syntax and fallback behaviour). + # + # UserDefined-protected labels (both Specific People sub-labels) let the + # user pick the recipients and permissions at apply time. Outlook + # behaviour is "Do Not Forward"; Office apps (Word, Excel, PowerPoint) + # prompt the user to assign permissions. + # + # The default Template rights bundle is Microsoft's "Reviewer" set: + # users get View, View Rights, Edit Content, Save, Reply, Reply All, + # Forward. Co-authoring (auto-save + simultaneous editing in Office) + # and macros / programmatic access via the Office object model are NOT + # included by default, because OBJMODEL access is the main vector by + # which third-party apps reading doc metadata break under encryption. + # + # If you need a wider rights bundle (e.g. Copy, Print, Allow Macros for + # third-party tooling), edit `EncryptionRightsDefinitions` below + # directly — typically by appending `,EXTRACT,PRINT,OBJMODEL` to the + # `{TenantDomain}:` rights string. Validate in a pilot tenant before + # rolling out, since OBJMODEL access affects every third-party app that + # reads Office document metadata. + # + # Auto-labeling (client-side and service-side) is intentionally NOT + # configured here. Add it deliberately via the Purview portal once your + # team has reviewed false-positive risk and user-impact trade-offs. + # + # Sub-label Name uniqueness + # ------------------------- + # IPPS requires every label `Name` (the internal identifier) to be unique + # across the tenant. Because Microsoft's defaults reuse display names like + # "All Employees" under multiple parents, we prefix each sub-label Name + # with its parent (e.g. `HCAllEmps`). The `AllEmployees` Name on + # Confidential is preserved unchanged so existing DLP rules with + # `LabelPath = 'Confidential/AllEmployees'` keep resolving. + # + # Label color + # ----------- + # Each label has an optional `Color = '#RRGGBB'` hex string. The toolkit + # applies it via `New-Label`/`Set-Label -AdvancedSettings @{color=...}` + # so the colour shows up next to the label in Office and Purview. Defaults + # below mirror Microsoft's recommended palette (green=Public, blue=General, + # amber=Confidential family, red=Highly Confidential family). Sub-labels + # inherit their parent's family colour. To disable, remove the `Color` + # entry — the toolkit will leave the label's current colour unchanged. + Labels = @( + @{ + Name = 'Public' + DisplayName = 'Public' + BuiltInName = 'defa4170-0d19-0005-0001-bc88714345d2' # MS built-in default (0001) - region/scheme-proof adoption key + Tooltip = 'Business data that is specifically prepared and approved for public consumption.' + Color = '#13A10E' + Encrypt = $false + ContentMark = $false + } + @{ + Name = 'General' + DisplayName = 'General' + BuiltInName = 'defa4170-0d19-0005-0002-bc88714345d2' # MS built-in default (0002) - label GROUP in modern scheme + Tooltip = 'Business data that is not intended for public consumption. However, this can be shared with external partners, as required. Examples include a company internal telephone directory, organizational charts, internal standards, and most internal communication.' + Color = '#3A96DD' + Encrypt = $false + ContentMark = $false + # General is a label GROUP (matches the Microsoft built-in default): a + # non-applicable container. Its assignable children are below; the + # published / email-default child carries the container scope + # ('Site' + 'UnifiedGroup') so new Teams / M365 Groups / SharePoint sites + # can be classified. -SkipContainerLabels strips those bits at deploy time. + SubLabels = @( + @{ + Name = 'GeneralAnyone' + DisplayName = 'Anyone (unrestricted)' + BuiltInName = 'defa4170-0d19-0005-0003-bc88714345d2' # MS built-in default (0003) + Tooltip = 'Organization data that is not intended for public consumption but can be shared with external partners if appropriate. Examples include customer conversations that do not include sensitive info, or released marketing materials.' + Color = '#3A96DD' + Encrypt = $false + ContentMark = $false + # Published + Outlook email default. Carries container scope. + ContentType = 'File, Email, Site, UnifiedGroup' + } + @{ + Name = 'GeneralAllEmployees' + DisplayName = 'All Employees (unrestricted)' + BuiltInName = 'defa4170-0d19-0005-0004-bc88714345d2' # MS built-in default (0004) + Tooltip = 'Organization data that is not intended for public consumption. If you need to share this content with external partners, confirm with other data owners that it is OK to share and then change the label to General \ Anyone (unrestricted).' + Color = '#3A96DD' + Encrypt = $false + ContentMark = $false + } + ) + } + @{ + Name = 'Confidential' + DisplayName = 'Confidential' + BuiltInName = 'defa4170-0d19-0005-0005-bc88714345d2' # MS built-in default (0005) + Tooltip = 'Sensitive business data that could cause damage to the business if shared with unauthorized people. Examples include contracts, security reports, forecast summaries, and sales account data.' + Color = '#EAA300' + Encrypt = $false + ContentMark = $false + SubLabels = @( + @{ + # Internal Name preserved for backward compat with the + # existing DLP rule LabelPath = 'Confidential/AllEmployees'. + Name = 'AllEmployees' + DisplayName = 'All Employees' + BuiltInName = 'defa4170-0d19-0005-0007-bc88714345d2' # MS built-in default (0007) + Tooltip = 'Confidential data shared internally with all employees. No encryption is applied; the label is informational and adds a footer marking.' + Color = '#EAA300' + Encrypt = $false + ContentMark = $true + FooterText = 'Classified as Confidential' + # Published label — see ContentType notes on the General + # label above. Adds container scope so this label appears + # in the Purview Edit sensitivity label -> Scope page for + # M365 Groups, Teams, and SharePoint sites. Required for + # the SMB recommended pattern where every new Team is + # classified at creation time. + ContentType = 'File, Email, Site, UnifiedGroup' + } + @{ + # Consolidated Confidential sub-label: replaces the former + # 'Specific People' + 'Internal Exception' sub-labels, renamed to + # the Microsoft built-in 'Trusted People' default (ordinal 0008) so + # the tool ADOPTS the existing default instead of creating duplicates. + # Carries the encrypted (UserDefined / Outlook Do Not Forward) protection. + Name = 'TrustedPeople' + DisplayName = 'Trusted People' + BuiltInName = 'defa4170-0d19-0005-0008-bc88714345d2' # MS built-in default (0008) + Tooltip = 'Confidential data shared with specific trusted people inside or outside the organization. Encryption is applied; trusted recipients can reshare as needed (Outlook: Encrypt-Only).' + Color = '#EAA300' + Encrypt = $true + ProtectionType = 'UserDefined' + UserDefinedOutlookBehavior = 'EncryptOnly' # Outlook: Encrypt-Only (matches the MS default Trusted People; trusted people can reshare) + ContentMark = $true + FooterText = 'Classified as Confidential' + } + ) + } + @{ + Name = 'HighlyConfidential' + DisplayName = 'Highly Confidential' + BuiltInName = 'defa4170-0d19-0005-0009-bc88714345d2' # MS built-in default (0009) + Tooltip = 'Very sensitive business data that would cause damage to the business if it was shared with unauthorized people. Examples include employee and customer information, passwords, source code, and pre-announced financial reports.' + Color = '#A4262C' + Encrypt = $false + ContentMark = $true + WatermarkText = 'HIGHLY CONFIDENTIAL' + SubLabels = @( + @{ + Name = 'HCAllEmps' + DisplayName = 'All Employees' + BuiltInName = 'defa4170-0d19-0005-000a-bc88714345d2' # MS built-in default (000a) + Tooltip = 'Highly confidential data with Co-Author rights for all users in this tenant only (view, edit, save, copy, print, allow macros, reply, forward). Data owners can track and revoke content.' + Color = '#A4262C' + Encrypt = $true + ProtectionType = 'Template' + # Per-label override for the rights bundle. Uses the + # Microsoft "Co-Author" set instead of the toolkit's + # global Reviewer default — Co-Author adds EXTRACT + # (copy), PRINT, and OBJMODEL (Office object-model + # access, needed for macros + simultaneous editing). + # `{TenantDomain}` resolves to all users in this + # tenant only (matches the global scope; excludes + # external / B2B / MSA). See the header comment above + # for the tenant-scoping semantics. + # + # NOTE: granting OBJMODEL is NECESSARY BUT NOT + # SUFFICIENT for Office multi-user co-authoring on + # encrypted files. The tenant-wide switch + # `-EnableLabelCoAuthoring` (Set-PolicyConfig + # -EnableLabelCoauth:$true, opt-in) must also be on. + # Without both, users get individual edit rights but + # not concurrent editing. + EncryptionRightsDefinitions = '{TenantDomain}:VIEW,VIEWRIGHTSDATA,EDIT,DOCEDIT,EXTRACT,PRINT,OBJMODEL,REPLY,REPLYALL,FORWARD' + ContentMark = $true + FooterText = 'Classified as Highly Confidential' + # Published label — see ContentType notes on the General + # label above. Adds container scope so this label appears + # in the Purview Edit sensitivity label -> Scope page for + # M365 Groups, Teams, and SharePoint sites. + # + # NOTE: Microsoft Purview will not apply the encryption + # protection (Template / Co-Author rights) to the CONTAINER + # itself — encryption stays on files and emails. The + # container-scope bits drive the privacy / sharing / + # unmanaged-device behaviour of the Team / site, not its + # contents' RMS encryption. + ContentType = 'File, Email, Site, UnifiedGroup' + } + @{ + Name = 'HCSpecificPeople' + DisplayName = 'Specific People' + BuiltInName = 'defa4170-0d19-0005-000b-bc88714345d2' # MS built-in default (000b) + Tooltip = 'Highly confidential data that requires protection and can be viewed only by people you specify and with the permission level you choose.' + Color = '#A4262C' + Encrypt = $true + ProtectionType = 'UserDefined' + UserDefinedOutlookBehavior = 'DoNotForward' # Outlook: Do Not Forward; Office apps prompt user + ContentMark = $true + FooterText = 'Classified as Highly Confidential' + } + ) + } + ) + + # ----- Encryption rights bundle ----- + # Microsoft's "Reviewer" bundle: View, View Rights, Edit Content, Save, + # Reply, Reply All, Forward. + # + # IDENTITY token (one of): + # * `{TenantDomain}` — RECOMMENDED. Resolved at runtime to the + # tenant's primary verified domain (e.g. `contoso.com`). Per Microsoft + # Entra rights-management semantics, specifying ANY verified domain + # in the tenant automatically expands to ALL verified domains in that + # tenant — so the label is accessible to every user in your tenant + # but NOT to external authenticated users from other M365 tenants + # (which `AuthenticatedUsers` would include). If the toolkit cannot + # resolve the tenant domain at runtime, it FAILS CLOSED (throws an + # error and writes a Failed run-log entry) rather than silently + # widening scope to `AuthenticatedUsers`. Re-run after fixing + # connectivity, or opt in to broad scope explicitly with + # `{AuthenticatedUsers}` / literal `AuthenticatedUsers`. + # + # * `AuthenticatedUsers` — Microsoft's broad "any signed-in M365 + # identity" group. Includes B2B guests, partners, and any external + # Entra user. Use this only if you intentionally need to share + # encrypted content across tenants. + # + # * `user@domain.com` — A specific mail-enabled user / group / + # mailbox in your tenant. Useful for narrow-scope custom labels. + # + # Co-authoring (auto-save + simultaneous editing) and macro / object- + # model access are NOT granted, which is the safe default when third- + # party apps consume Office documents in your tenant. + # + # If you need a wider bundle (the Microsoft "Co-Author" set adds Copy + # (EXTRACT), Print, Allow Macros (OBJMODEL)), edit the string below + # directly. The "Co-Author" equivalent is: + # '{TenantDomain}:VIEW,VIEWRIGHTSDATA,EDIT,DOCEDIT,EXTRACT,PRINT,REPLY,REPLYALL,FORWARD,OBJMODEL' + # OBJMODEL is the right that most often breaks third-party apps reading + # doc metadata, so validate in a pilot tenant before promoting. + # + # PER-LABEL OVERRIDE: + # Any label with `Encrypt=$true / ProtectionType='Template'` may carry + # its own `EncryptionRightsDefinitions = '...'` field, which overrides + # this global default JUST for that label. Same token semantics apply + # ({TenantDomain}, {AuthenticatedUsers}, literal identity). Used by + # `HighlyConfidential\All Employees` (HCAllEmps) to grant the wider + # Co-Author bundle (allows copy, print, macros, Office co-authoring) + # while sibling sub-labels stay on the narrower Reviewer default. + EncryptionRightsDefinitions = '{TenantDomain}:VIEW,VIEWRIGHTSDATA,EDIT,DOCEDIT,REPLY,REPLYALL,FORWARD' + + EncryptionContentExpiredOnDateInDaysOrNever = 'Never' + EncryptionOfflineAccessDays = 30 + + # ----- Label publishing policy ----- + LabelPolicy = @{ + Name = 'SMBTool - Default Label Policy' + Comment = 'Publishes the SMBTool baseline sensitivity labels to all users.' + # Default applied label for DOCUMENTS (Word, Excel, PowerPoint, and + # service-side defaults). Per the SMB profile, this is + # 'Confidential\All Employees' (Name = 'AllEmployees') so every new + # document gets footer marking by default. Resolved by Name at + # runtime; sub-label Names are tenant-unique so this resolves cleanly. + DefaultLabel = 'AllEmployees' + # Default applied label for EMAIL (Outlook). General is now a label GROUP + # (non-applicable), so this points at its assignable child + # 'General \ Anyone (unrestricted)' (Name = 'GeneralAnyone'). Mapped to the + # IPPS advanced setting `OutlookDefaultLabel`. Set to $null to omit an + # Outlook-specific default and fall back to DefaultLabel. + DefaultLabelForEmail = 'GeneralAnyone' + # Subset of label Names to PUBLISH to end users via this policy. + # All labels in the Labels array are still CREATED in the tenant, + # but only these are visible/applicable in clients. Sub-label Names + # may be listed; the script auto-includes their parents so the + # Purview hierarchy stays valid. + # Set to $null or an empty array to publish every label that's + # created. + PublishedLabels = @( + 'Public' + 'GeneralAnyone' # General \ Anyone (unrestricted) - assignable email-default child + 'AllEmployees' # Confidential \ All Employees + 'HCAllEmps' # Highly Confidential \ All Employees + ) + # Mandatory labelling is ON by default: every Office app (Word / + # Excel / PowerPoint / Outlook) will prompt the user to pick a + # sensitivity label before they can save a new document or send a + # new email. This closes the "untagged content" hole that breaks + # DLP rules, auto-classification, and Copilot exclusions, all of + # which key off the label. Set to $false in your fork only if the + # customer explicitly cannot tolerate the prompt during rollout + # (a documented end-user training plan is the better answer). + # See docs/End-User-Adoption-Guide.md for the user-facing impact. + MandatoryLabelling = $true + # Justification required when downgrading sensitivity. + DowngradeJustification = $true + # "Inherit label from attachments": when a user sends an email that has + # labelled attachments, apply the highest-priority attachment label to the + # email. 'Automatic' applies it silently (the Purview "on" behaviour); + # 'Recommended' prompts the user; set to $null to leave the feature off. + # Requires the published labels to be scoped to both Files and Emails + # (the SMBTool published labels are). Maps to the IPPS label-policy advanced + # setting `AttachmentAction`. + AttachmentAction = 'Automatic' + } + + # ----- DLP policies ----- + # Two policies (Microsoft's recommendation): one for Exchange, one for SPO + OneDrive. + # + # Workloads supported under Microsoft 365 Business Premium: + # * 'Exchange' - mailboxes + # * 'SharePointOneDrive' - SPO sites + OneDrive for Business + # + # E5 / Purview Suite: + # * 'Endpoint' - Endpoint DLP + # + # Setup-DLP does not currently implement Devices aliases, on-premises, + # Defender for Cloud Apps, or Power BI rule shapes. Entries with unsupported + # workload values are skipped with structured evidence. + DlpPolicies = @( + @{ + Name = 'SMBTool - DLP - Confidential and HC external (EXO)' + Comment = 'Blocks Exchange messages labelled with any Confidential or Highly Confidential sub-label from being sent outside the organisation.' + Workload = 'Exchange' + RuleName = 'SMBTool - DLP Rule - Confidential and HC - Exchange' + # All Confidential + Highly Confidential sub-labels are OR-matched. + # Resolved to GUIDs at runtime. + LabelPaths = @( + 'Confidential/AllEmployees' + 'Confidential/TrustedPeople' + 'HighlyConfidential/HCAllEmps' + 'HighlyConfidential/HCSpecificPeople' + ) + BlockAccess = $true + # IMPORTANT — DO NOT DELETE 'BlockAccessScope' on the Exchange rule. + # On Exchange, enforcement is driven entirely by AccessScope='NotInOrganization' + # (set in Setup-DLP.ps1 line ~496). The IPPS engine IGNORES BlockAccessScope + # for Exchange rules at evaluation time — it is read only by Purview's web + # UI rule-editor to highlight the matching radio button ("Block only people + # outside your organization"). If you remove this line: + # * Enforcement is unchanged (Exchange still blocks external recipients). + # * The Purview UI shows the radio group EMPTY when an admin opens the + # rule, making it look misconfigured, and -AdoptExisting may flag it + # as drift the next time the toolkit runs. + # Keep the value at 'PerUser' for UI parity with the radio "Block only + # people outside your organization". The SPO/ODFB rule below uses the same + # field but there the engine DOES read it — see the comment on that rule. + BlockAccessScope = 'PerUser' + NotifyUser = @('SiteAdmin','LastModifier','Owner') + GenerateIncidentReport = @('SiteAdmin') + } + @{ + Name = 'SMBTool - DLP - Confidential and HC external (SPO+ODB)' + Comment = 'Blocks SharePoint and OneDrive files labelled with any Confidential or Highly Confidential sub-label from being shared externally.' + Workload = 'SharePointOneDrive' + RuleName = 'SMBTool - DLP Rule - Confidential and HC - SPO ODFB' + LabelPaths = @( + 'Confidential/AllEmployees' + 'Confidential/TrustedPeople' + 'HighlyConfidential/HCAllEmps' + 'HighlyConfidential/HCSpecificPeople' + ) + BlockAccess = $true + # SPO/ODFB: 'PerUser' = "Block only people outside your organization". + # 'All' would mean "Block everyone" (incl. internal users). + # 'PerAnonymousUser' would only block anonymous link recipients. + BlockAccessScope = 'PerUser' + NotifyUser = @('SiteAdmin','LastModifier','Owner') + GenerateIncidentReport = @('SiteAdmin') + } + # ----------------------------------------------------------------- + # Endpoint DLP — audit copy/print/USB/network-share actions on + # devices when content carries any Confidential or Highly + # Confidential sub-label. + # + # LICENSING: requires Microsoft 365 E5 / Purview Suite. The toolkit + # rejects this policy when run with -BPOnly (Business Premium only). + # + # MODE: created in simulation (TestWithoutNotifications) by default + # via DlpStartInSimulation = $true. After validating in simulation, + # toggle the Purview portal "Turn the policy on if it's not edited + # within fifteen days of simulation" checkbox or set + # DlpStartInSimulation = $false to enforce. + # + # ACTIONS (EndpointDlpRestrictions): default 'Audit' so simulation + # produces telemetry without interrupting users. Flip individual + # entries to 'Block' / 'BlockOverride' / 'Warn' once the desired + # behaviour is confirmed in DLP Activity Explorer. + # ----------------------------------------------------------------- + @{ + Name = 'SMBTool - DLP - Endpoint Confidential and HC' + Comment = 'Endpoint DLP - audits copy / print / USB / network-share / cloud-sync actions on managed devices when content is labelled Confidential or Highly Confidential.' + Workload = 'Endpoint' + RuleName = 'SMBTool - DLP Rule - Endpoint Confidential and HC' + LabelPaths = @( + 'Confidential/AllEmployees' + 'Confidential/TrustedPeople' + 'HighlyConfidential/HCAllEmps' + 'HighlyConfidential/HCSpecificPeople' + ) + # Endpoint device-action restrictions. Hashtable keys are + # case-sensitive: { Setting = ''; Value = '' }. + # Valid Setting values (per the API error response — these are the + # short forms the cmdlet actually accepts, not the longer names + # shown in some MS Learn pages): + # Print, CopyPaste, ScreenCapture, RemovableMedia, NetworkShare, + # UnallowedApps, CloudEgress, UnallowedBluetoothTransferApps, + # RemoteDesktopServices, WebPagePrint, WebPageCopyPaste, + # WebPageSaveToLocal, PasteToBrowser, AccessByAnyAppDefault, + # UnallowedFtpTransferApps. + # Valid Value values: Audit, Block, Warn, BlockOverride. + EndpointDlpRestrictions = @( + @{ Setting = 'CopyPaste'; Value = 'Audit' } + @{ Setting = 'Print'; Value = 'Audit' } + @{ Setting = 'RemovableMedia'; Value = 'Audit' } + @{ Setting = 'NetworkShare'; Value = 'Audit' } + ) + EnforcePortalAccess = $true + ReportSeverityLevel = 'Medium' + GenerateAlert = $true + NotifyUser = @() + GenerateIncidentReport = @() + } + ) + + # ----- Retention ----- + Retention = @{ + Name = 'SMBTool - Exchange 7-year retention' + Comment = 'Retains Exchange mailbox content for 7 years from creation, then deletes. Aligned with common SMB regulatory record-keeping requirements (ATO/IRS/SEC/ASIC) but partners must confirm against the customer''s vertical.' + RuleName = 'SMBTool - Exchange 7yr Rule' + DurationDays = 2555 # 7 years + DurationDisplayHint = 'Years' + Action = 'KeepAndDelete' + ExpirationDateOption = 'CreationAgeInDays' + # Locations: Exchange by default. Add 'SharePoint','OneDrive' to widen. + Locations = @('Exchange') + } + + # ----- AI governance & data security (default ON for E5 / Purview Suite) ----- + # + # Applied by default. AI governance is auto-skipped on Business Premium + # tenants ($BPOnly) because the Microsoft 365 Copilot DLP policy plane + # is included in E5 / Purview Suite, not in Business Premium. Opt out + # with -SkipAIControls. + # + # Per Microsoft Learn (https://learn.microsoft.com/purview/dlp-microsoft365-copilot-location-learn-about): + # the Microsoft 365 Copilot DLP location protects BOTH paid Microsoft + # 365 Copilot AND the free Microsoft 365 Copilot Chat experience, so + # policy creation succeeds on E5 / Purview Suite tenants regardless of + # whether paid Copilot per-user licenses are present. + # + # Per Microsoft Learn (New-DlpCompliancePolicy / New-DlpComplianceRule): + # - Locations JSON pins the Copilot location GUID + # (470f2276-e011-4e9d-a6ec-20768be3a4b0). + # - EnforcementPlanes 'CopilotExperiences' selects the Copilot enforcement + # surface. Add 'Agent' here when extending coverage to Agent 365. + # - The rule action 'ExcludeContentProcessing'='Block' prevents Copilot + # from processing the labelled content (it is excluded from grounding, + # summarisation, and search results). + AIGovernance = @{ + DlpPolicies = @( + # AI_054 — Block Microsoft 365 Copilot from processing + # Highly Confidential content. + # https://microsoft.github.io/zerotrustassessment/docs/workshop-guidance/AI/AI_054 + @{ + Name = 'SMBTool - AI - Block Copilot for Highly Confidential' + RuleName = 'SMBTool - AI Rule - Block Copilot Highly Confidential' + Comment = 'AI_054 - Prevents Microsoft 365 Copilot from processing content labelled Highly Confidential.' + Mode = 'Enable' # Enable | TestWithNotifications | TestWithoutNotifications | Disable + EnforcementPlanes = @('CopilotExperiences') # Add 'Agent' to also cover Agent 365 (preview) + # Locations: target the Microsoft 365 Copilot location. + # Inclusions/Exclusions follow the Locations JSON schema. + # Default = tenant-wide. Use the commented form below to + # pilot on a security group first. + Locations = @( + @{ + Workload = 'Applications' + Location = '470f2276-e011-4e9d-a6ec-20768be3a4b0' + Inclusions = @(@{ Type = 'Tenant'; Identity = 'All' }) + # Inclusions = @(@{ Type = 'Group'; Identity = 'CopilotPilotUsers@contoso.com' }) + # Exclusions = @(@{ Type = 'Group'; Identity = 'CopilotExempt@contoso.com' }) + } + ) + # Sensitivity label paths to block. Resolved to GUIDs at runtime. + LabelPaths = @('HighlyConfidential') + # Block Copilot from processing matched content. Other valid + # 'setting' values include 'EncryptionEnabled' and + # 'BlockAccess'; ExcludeContentProcessing is the recommended + # action for Copilot per Microsoft documentation. + RestrictAccess = @(@{ setting = 'ExcludeContentProcessing'; value = 'Block' }) + } + ) + } + + # ----- Tenant settings (foundational) ----- + TenantSettings = @{ + EnableUnifiedAuditLog = $true + EnableSensitivityLabelForPDF = $true + EnableAIPIntegrationInSPO = $true + # Tenant-wide label co-authoring metadata-format switch + # (Set-PolicyConfig -EnableLabelCoauth). DEFAULT-OFF and operator + # opt-in via -EnableLabelCoAuthoring on + # Deploy-PurviewBestPractice.ps1. This switch is ONE-WAY: once + # enabled, label metadata moves out of custom properties; disabling + # it later loses labels on unencrypted Office files. Any app, + # service, scanner or script reading doc metadata from the old + # location (AIP scanner < v3.0, OneDrive sync < 19.002, MIP SDK + # < 1.7, custom DLP scanners, custom Exchange mail-flow rules, + # etc.) will break. The toolkit refuses to flip this on partner + # tenants by default to avoid taking responsibility for that risk. + # Ref: https://learn.microsoft.com/purview/sensitivity-labels-coauthoring + EnableLabelCoAuth = $false + } +} diff --git a/Products/Purview/Deploy-PurviewBestPractice.ps1 b/Products/Purview/Deploy-PurviewBestPractice.ps1 index 88c90de..85d08ad 100644 --- a/Products/Purview/Deploy-PurviewBestPractice.ps1 +++ b/Products/Purview/Deploy-PurviewBestPractice.ps1 @@ -1,1113 +1,1120 @@ -# ============================================================================= -# DISCLAIMER -# ============================================================================= -# This sample script is not supported under any Microsoft standard support -# program or service. The sample script is provided AS IS without warranty of -# any kind. Microsoft further disclaims all implied warranties including, -# without limitation, any implied warranties of merchantability or of fitness -# for a particular purpose. The entire risk arising out of the use or -# performance of the sample scripts and documentation remains with you. In no -# event shall Microsoft, its authors, or anyone else involved in the creation, -# production, or delivery of the scripts be liable for any damages whatsoever -# (including, without limitation, damages for loss of business profits, -# business interruption, loss of business information, or other pecuniary -# loss) arising out of the use of or inability to use the sample scripts or -# documentation, even if Microsoft has been advised of the possibility of -# such damages. -# -# Please do not contact Microsoft support with any issues or concerns -# regarding this script. -# ============================================================================= - -<# -.SYNOPSIS - Deploys the Microsoft Purview Best Practice baseline for Microsoft 365 - Business Premium tenants. - -.DESCRIPTION - Single entry point that orchestrates the full deployment based on the - Microsoft "Data Security Best Practice Deployment" guide for Business - Premium. The toolkit is modular — every task is an independent script - under .\Modules and can be run standalone. - - Tasks (in order): - 1. Tenant settings — audit log, AIP/SPO integration, PDF labels, - co-authoring (and optional container labels - + premium audit) - 2. Sensitivity labels — Personal, Public, General, Confidential - (with AllEmployees sub-label), - Highly Confidential — encryption applied, - ordered, and published with General as the - default - 3. DLP — separate Exchange and SPO+OneDrive policies - blocking external sharing of the - Confidential\AllEmployees label - 4. Retention — Exchange mailbox 7-year retain-then-delete - 5. AI governance — Microsoft 365 Copilot DLP policies - (e.g. AI_054 - Block Copilot for Highly - Confidential). Default ON for E5 / Purview - Suite tenants; auto-skipped on Business - Premium ($BPOnly). Opt out with - -SkipAIControls. - - Default mode = APPLY changes. Pass -WhatIf for preview, or -Confirm for - per-action confirmation prompts. - -.PARAMETER TenantAdminUpn - UPN of the tenant administrator (or partner GDAP admin) used for sign-in. - -.PARAMETER SharePointAdminUrl - Optional override for the SharePoint admin centre URL - (e.g. https://contoso-admin.sharepoint.com). When omitted, the URL is - auto-derived from the tenant's initial onmicrosoft.com domain after - Exchange Online is connected. Use this parameter only when auto-derivation - fails (e.g. multi-geo or unusual domain configurations). - -.PARAMETER DelegatedOrganization - Customer tenant primary domain when running as a partner via GDAP. - -.PARAMETER ConfigPath - Path to a custom PurviewConfig.psd1. Defaults to .\Config\PurviewConfig.psd1. - -.PARAMETER SkipTenantSettings - Skip foundational tenant settings (audit, SPO integration, co-authoring). - -.PARAMETER SkipLabels - Skip sensitivity-label creation and publishing. - -.PARAMETER SkipDLP - Skip DLP policy creation. - -.PARAMETER ApplyRetention - Provision the Exchange mailbox retention policy from - PurviewConfig.psd1. **Opt-in** — retention does NOT run by default - because the shipped 7-year retain-then-delete default is destructive - (deletes mail older than 7 years tenant-wide) and is wrong for some - regulated verticals (law / accounting / healthcare / financial - advisors / construction / real estate). The partner must consciously - choose a duration for the customer's vertical before enabling. - See docs/Retention-Default-Risk.md. - -.PARAMETER SkipAIControls - Skip the AI governance / Microsoft 365 Copilot DLP policy step. By - default, AI governance runs on every E5 / Purview Suite deployment - because the policy plane is included in those SKUs and the protection - (blocking Copilot from grounding on Highly Confidential content) is the - same risk class as Endpoint DLP. AI governance is auto-skipped on - Business Premium tenants ($BPOnly) regardless of this switch. - - Per Microsoft Learn, the policy enforces against both paid Microsoft - 365 Copilot and the free Microsoft 365 Copilot Chat experience, so - creation succeeds on E5 / Purview Suite tenants even when no paid - Copilot per-user licenses are present. - - See: https://learn.microsoft.com/purview/dlp-microsoft365-copilot-location-learn-about - -.PARAMETER ApplyAIControls - DEPRECATED. AI governance is now provisioned by default (see - -SkipAIControls). Passing this switch emits a deprecation warning and - is otherwise ignored. Cannot be combined with -SkipAIControls. Kept - for backward compatibility with existing partner runbooks; will be - removed in a future major version. - -.PARAMETER EnableContainerLabels - DEPRECATED. Container labels (Group.Unified EnableMIPLabels) are now - provisioned by default (the toolkit's licensing floor is Microsoft 365 - Business Premium, which includes Entra ID P1 — the AAD-side - requirement). See -SkipContainerLabels to opt out. Passing this switch - emits a deprecation warning and is otherwise ignored. Cannot be - combined with -SkipContainerLabels. Kept for backward compatibility - with existing partner runbooks; will be removed in a future major - version. - -.PARAMETER SkipContainerLabels - Opt out of container labels (Group.Unified EnableMIPLabels) for - Microsoft 365 groups, Teams, and SharePoint sites. Use this only when - container labels are managed by another process (e.g. an existing - Conditional Access / sensitivity-label rollout owned by the customer - or another partner), or on a sub-Business-Premium tenant that lacks - Entra ID P1. License auto-detect also flips this on automatically - when the tenant has no recognised Microsoft 365 BP / E5 / Purview - Suite SKU. - -.PARAMETER EnablePremiumAudit - Also enable per-mailbox SearchQueryInitiated audit. Requires Audit - (Premium) licensing. - -.PARAMETER PremiumAuditMailbox - Mailbox UPN(s) on which to enable SearchQueryInitiated audit. - -.PARAMETER AdoptExisting - Update labels, policies, and rules that already exist but were not created - by this toolkit. Use only after auditing existing configuration. - -.PARAMETER EnableLabelCoAuthoring - OPT-IN tenant-wide switch. Calls `Set-PolicyConfig -EnableLabelCoauth:$true` - in Setup-TenantSettings step [4/5]. THIS IS A ONE-WAY CHANGE: once - enabled, sensitivity-label metadata moves out of the old custom-properties - location to the new embedded location. Disabling it later (PowerShell - only — the Purview portal does NOT support disabling) REMOVES the new- - location metadata; unencrypted Word/Excel/PowerPoint files lose their - labels entirely. Any third-party app, scanner or script that reads - labels from the old location will break: AIP scanner < v3.0, OneDrive - sync < 19.002, MIP SDK < 1.7, custom DLP scanners, custom Exchange - mail-flow rules, etc. Off by default because partners cannot - enumerate every third-party integration on a customer tenant. Confirm - no old-location consumers exist before passing this switch. - Ref: https://learn.microsoft.com/purview/sensitivity-labels-coauthoring - -.PARAMETER NonInteractive - Skip the preflight confirmation prompt (e.g. for CI/automation runs). - Also skips the post-connect tenant-identity confirmation prompt. If the - connected tenant's verified domains do NOT include the expected domain - (from -DelegatedOrganization or the -TenantAdminUpn suffix), the script - aborts with a hard error BEFORE any destructive change. - -.PARAMETER AutoInstallModules - Auto-install any missing PowerShell modules (ExchangeOnlineManagement, - Microsoft.Online.SharePoint.PowerShell, Microsoft.Graph.*) to the current - user scope without prompting. Without this switch you are prompted before - each install. - -.PARAMETER NoLicenseAutoDetect - Disable automatic license-tier detection. By default the script connects to - Microsoft Graph, reads /subscribedSkus, and (a) skips AI governance when no - Microsoft 365 E5 / Purview Suite SKU is detected, and (b) skips container - labels when no Microsoft 365 BP / E5 / Purview Suite SKU is detected. - Pass this switch to skip detection (e.g. when running unattended in a - tenant where the operator does not have Organization.Read.All consent). - -.PARAMETER BPOnly - Hard-restrict the toolkit to Microsoft 365 Business Premium-eligible - features only. Refuses to enable add-ons that require Microsoft 365 E5 / - Microsoft Purview Suite licensing: - * Premium Audit / 1-year retention (-EnablePremiumAudit) - * Endpoint DLP (Devices), DLP for Defender for Cloud Apps, - on-premises DLP scanner, Power BI DLP - Container labels (Group.Unified EnableMIPLabels) are NOT in this list — - they work on Business Premium because BP includes Entra ID P1 (the - AAD-side requirement). Pass -SkipContainerLabels if you need to opt out. - Also propagates to the DLP module so any custom workload added to - PurviewConfig.psd1 that requires E5 is rejected up-front. - -.EXAMPLE - # Standard partner-managed customer onboarding — SharePoint admin URL is - # auto-derived from the tenant's initial domain. - .\Deploy-PurviewBestPractice.ps1 -TenantAdminUpn admin@contoso.onmicrosoft.com - -.EXAMPLE - # Preview every change without applying - .\Deploy-PurviewBestPractice.ps1 ` - -TenantAdminUpn admin@contoso.onmicrosoft.com -WhatIf - -.EXAMPLE - # GDAP partner-delegated scenario, with retention opt-in - .\Deploy-PurviewBestPractice.ps1 ` - -TenantAdminUpn partneradmin@fabrikam.onmicrosoft.com ` - -DelegatedOrganization contoso.onmicrosoft.com ` - -ApplyRetention - -.EXAMPLE - # Override the auto-derived SharePoint admin URL (rare — multi-geo/vanity) - .\Deploy-PurviewBestPractice.ps1 ` - -TenantAdminUpn admin@contoso.onmicrosoft.com ` - -SharePointAdminUrl https://contoso-admin.sharepoint.com - -.NOTES - * Required modules: ExchangeOnlineManagement, - Microsoft.Online.SharePoint.PowerShell, and - Microsoft.Graph.Beta.Identity.DirectoryManagement (skipped only when - -SkipContainerLabels or -SkipTenantSettings is used). - * Label and policy changes can take up to 24 hours to propagate. - * Always pilot in a test tenant before production rollout. -#> -[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] -param( - [Parameter(Mandatory)] - [string] $TenantAdminUpn, - - [Parameter()] - [string] $SharePointAdminUrl, - - [Parameter()] - [string] $DelegatedOrganization, - - [Parameter()] - [string] $ConfigPath, - - [Parameter()] - [switch] $SkipTenantSettings, - - [Parameter()] - [switch] $SkipLabels, - - [Parameter()] - [switch] $SkipDLP, - - [Parameter()] - [switch] $ApplyRetention, - - [Parameter()] - [switch] $SkipAIControls, - - # DEPRECATED — see -SkipAIControls. Retained as a no-op switch (with a - # runtime deprecation warning) so existing partner runbooks that pass - # -ApplyAIControls continue to work without a hard parameter-binding - # error. Mutually exclusive with -SkipAIControls (validated below). - [Parameter()] - [switch] $ApplyAIControls, - - # DEPRECATED — see -SkipContainerLabels. Retained as a no-op switch (with - # a runtime deprecation warning) so existing partner runbooks that pass - # -EnableContainerLabels continue to work without a hard parameter-binding - # error. Mutually exclusive with -SkipContainerLabels (validated below). - [Parameter()] - [switch] $EnableContainerLabels, - - [Parameter()] - [switch] $SkipContainerLabels, - - [Parameter()] - [switch] $EnablePremiumAudit, - - [Parameter()] - [string[]] $PremiumAuditMailbox, - - [Parameter()] - [switch] $AdoptExisting, - - [Parameter()] - [switch] $EnableLabelCoAuthoring, - - [Parameter()] - [switch] $NonInteractive, - - [Parameter()] - [switch] $AutoInstallModules, - - [Parameter()] - [switch] $BPOnly, - - [Parameter()] - [switch] $NoLicenseAutoDetect, - - # PR-Report: end-of-run HTML report (Tier 1 - task status + run metadata). - # Default location: same folder as the script, named with a timestamp. - # Pass -NoReport to suppress, or -ReportPath to control location. - [Parameter()] - [string] $ReportPath, - - [Parameter()] - [switch] $NoReport -) - -$ErrorActionPreference = 'Stop' -# Auto-confirm: this toolkit is designed for unattended/scripted runs. Use -WhatIf for dry-run. -$ConfirmPreference = 'None' - -# --------------------------------------------------------------------------- -# Toolkit version -# --------------------------------------------------------------------------- -# Surfaced in the end-of-run HTML report and (eventually) in support logs. -# Bump on each release. The runtime build suffix is the short Git SHA when -# the script lives in a working tree -- fall back to '' in tarball deploys. -$script:DeployVersion = '1.2.0' -try { - $gitSha = & git -C $PSScriptRoot rev-parse --short HEAD 2>$null - if ($LASTEXITCODE -eq 0 -and $gitSha) { - $script:DeployVersion = "$script:DeployVersion+$($gitSha.Trim())" - } -} catch { } - -# Capture start time and run identifier as early as possible so the -# end-of-run report has accurate timing even if connect/license auto-detect -# throws before any task runs. -$script:StartTime = Get-Date -$script:RunId = [guid]::NewGuid() - -# Tier-2 run log: initialise the singleton collection that Setup-* modules -# and the retry helper will append decision-point entries to. Dot-source -# here so the functions are defined before any module is invoked. -. (Join-Path $PSScriptRoot 'Modules\PurviewRunLog.ps1') -Initialize-PurviewRunLog - -# --------------------------------------------------------------------------- -# PowerShell version gate -# --------------------------------------------------------------------------- -# This toolkit requires PowerShell 7+ (PowerShell Core / pwsh.exe). -# Windows PowerShell 5.1 (powershell.exe) is NOT supported because: -# * ExchangeOnlineManagement v3+ Connect-IPPSSession's REST/EXOv3 path -# and the Microsoft.Graph SDK both rely on .NET Core APIs that PS 5.1 -# does not expose, leading to silent auth and cmdlet-discovery failures. -# * Newer label / DLP cmdlets are surfaced only over the v3 REST channel. -# We hard-fail upfront so partners aren't left debugging cryptic mid-run errors. -if ($PSVersionTable.PSVersion.Major -lt 7) { - $edition = if ($PSVersionTable.PSEdition) { $PSVersionTable.PSEdition } else { 'Desktop' } - $msg = @" -This toolkit requires PowerShell 7 or later (PowerShell Core / pwsh.exe). -You are running: PowerShell $($PSVersionTable.PSVersion) (Edition: $edition). - -Windows PowerShell 5.1 is not supported — the Exchange Online v3 REST channel -and Microsoft.Graph SDK depend on .NET Core APIs unavailable in PS 5.1, which -causes silent connection failures and missing cmdlets later in the deploy. - -Install PowerShell 7: winget install --id Microsoft.PowerShell --source winget - or: https://aka.ms/PowerShell-Release -Then re-run this script from a `pwsh` prompt (not `powershell`). -"@ - throw $msg -} - -# --------------------------------------------------------------------------- -# Locate config & modules relative to this script -# --------------------------------------------------------------------------- -$scriptRoot = Split-Path -Parent $PSCommandPath -if (-not $ConfigPath) { - $ConfigPath = Join-Path $scriptRoot 'Config\PurviewConfig.psd1' -} -if (-not (Test-Path $ConfigPath)) { - throw "Config file not found: $ConfigPath" -} -$config = Import-PowerShellDataFile -Path $ConfigPath - -# Operator opt-in for the tenant-wide label co-authoring metadata-format -# switch (Set-PolicyConfig -EnableLabelCoauth). The config default is -# $false because this is a ONE-WAY change that can break third-party apps -# reading labels from the old custom-properties location (AIP scanner -# < v3.0, OneDrive sync < 19.002, MIP SDK < 1.7, etc.). The operator -# must pass -EnableLabelCoAuthoring explicitly after confirming no old- -# location consumers exist on the target tenant. See PurviewConfig.psd1 -# comment, the .PARAMETER block above, and -# https://learn.microsoft.com/purview/sensitivity-labels-coauthoring. -if ($EnableLabelCoAuthoring) { - if (-not $config.TenantSettings) { $config.TenantSettings = @{} } - $config.TenantSettings.EnableLabelCoAuth = $true -} - -$moduleRoot = Join-Path $scriptRoot 'Modules' -$connectScript = Join-Path $moduleRoot 'Connect-PurviewServices.ps1' -$tenantScript = Join-Path $moduleRoot 'Setup-TenantSettings.ps1' -$labelsScript = Join-Path $moduleRoot 'Setup-SensitivityLabels.ps1' -$dlpScript = Join-Path $moduleRoot 'Setup-DLP.ps1' -$retentionScript = Join-Path $moduleRoot 'Setup-Retention.ps1' -$aiScript = Join-Path $moduleRoot 'Setup-AIGovernance.ps1' - -foreach ($s in @($connectScript, $tenantScript, $labelsScript, $dlpScript, $retentionScript, $aiScript)) { - if (-not (Test-Path $s)) { throw "Required module script not found: $s" } -} - -# --------------------------------------------------------------------------- -# Validate parameter combinations -# --------------------------------------------------------------------------- -$needsSpo = -not $SkipTenantSettings -or -not $SkipLabels -if ($EnablePremiumAudit -and (-not $PremiumAuditMailbox -or $PremiumAuditMailbox.Count -eq 0)) { - throw "-EnablePremiumAudit requires -PremiumAuditMailbox ." -} - -if ($BPOnly) { - $bpViolations = @() - if ($EnablePremiumAudit) { - $bpViolations += " * -EnablePremiumAudit (Audit Premium / SearchQueryInitiated) requires Microsoft 365 E5." - } - if ($bpViolations) { - throw "-BPOnly conflicts with E5-only options:`n$($bpViolations -join "`n")`nRemove the conflicting switches, or omit -BPOnly if the customer holds E5/Purview Suite." - } -} - -# AI controls: validate the deprecated/new switch combo and warn callers -# who still pass -ApplyAIControls. The switch is a no-op (AI governance is -# now default-on for E5 / Purview Suite tenants) but we keep it bindable -# so existing partner runbooks don't crash with "parameter not found". -if ($ApplyAIControls -and $SkipAIControls) { - throw "-ApplyAIControls and -SkipAIControls cannot be combined. -ApplyAIControls is deprecated (AI governance is now default-on); use -SkipAIControls alone to opt out, or remove both switches to accept the default." -} -if ($ApplyAIControls) { - Write-Warning "-ApplyAIControls is deprecated and ignored: AI governance is now default-on for E5 / Purview Suite tenants. Pass -SkipAIControls to opt out, or -BPOnly to force-skip. This switch will be removed in a future release." -} - -# Container labels: validate the deprecated/new switch combo and warn callers -# who still pass -EnableContainerLabels. The switch is a no-op (container -# labels are now default-on — Business Premium is the toolkit's licensing -# floor and BP includes Entra ID P1, which is the AAD-side requirement) but -# we keep it bindable so existing partner runbooks don't crash with -# "parameter not found". Mirrors the -ApplyAIControls / -SkipAIControls -# deprecation pattern above. -if ($EnableContainerLabels -and $SkipContainerLabels) { - throw "-EnableContainerLabels and -SkipContainerLabels cannot be combined. -EnableContainerLabels is deprecated (container labels are now default-on); use -SkipContainerLabels alone to opt out, or remove both switches to accept the default." -} -if ($EnableContainerLabels) { - Write-Warning "-EnableContainerLabels is deprecated and ignored: container labels are now default-on (Business Premium is the licensing floor and BP includes Entra ID P1, the AAD-side requirement). Pass -SkipContainerLabels to opt out. This switch will be removed in a future release." -} - -# --------------------------------------------------------------------------- -# Preflight summary -# --------------------------------------------------------------------------- -$bannerSpoUrl = if ($SharePointAdminUrl) { $SharePointAdminUrl } elseif ($needsSpo) { '(auto-derive from tenant)' } else { '(not needed)' } -$bannerDelegate = if ($DelegatedOrganization) { $DelegatedOrganization } else { '(none)' } -$tickTenant = if (-not $SkipTenantSettings) { 'X' } else { ' ' } -$tickLabels = if (-not $SkipLabels) { 'X' } else { ' ' } -$tickDlp = if (-not $SkipDLP) { 'X' } else { ' ' } -$tickRetention = if ($ApplyRetention) { 'X' } else { ' ' } -$tickAi = if ($SkipAIControls) { ' ' } - elseif ($BPOnly -or $NoLicenseAutoDetect) { - if ($BPOnly) { ' ' } else { 'X' } - } else { '?' } -$tickContainer = if ($SkipContainerLabels -or $SkipTenantSettings) { ' ' } - elseif ($BPOnly -or $NoLicenseAutoDetect) { 'X' } - else { '?' } -$tickPremium = if ($EnablePremiumAudit) { 'X' } else { ' ' } -$tickAdopt = if ($AdoptExisting) { 'X' } else { ' ' } -$tickLabelCoAuth = if ($EnableLabelCoAuthoring) { 'X' } else { ' ' } -$bannerMode = if ($WhatIfPreference) { 'WHAT-IF (preview only — no changes)' } else { 'APPLY (changes will be made)' } -$bannerTier = if ($BPOnly) { 'Business Premium ONLY (E5 features blocked)' } else { 'No license tier restriction' } - -$banner = @" - -============================================================================== - Microsoft Purview Best Practice Deployment - Reference: M365 Business Premium "Data Security Best Practice Deployment" -============================================================================== - Tenant admin UPN : $TenantAdminUpn - SharePoint admin URL : $bannerSpoUrl - Delegated org (GDAP) : $bannerDelegate - Config file : $ConfigPath - - Tasks to run: - [$tickTenant] Tenant settings (audit, SPO/AIP, PDF) - [$tickLabels] Sensitivity labels (3 parents + 5 sub-labels, publish) - [$tickDlp] DLP policies (Exchange + SPO/OneDrive) - [$tickRetention] Retention (Exchange 7 years — opt-in via -ApplyRetention) - [$tickAi] AI governance (Block Copilot grounding on Highly Confidential — default on; opt out: -SkipAIControls; auto-skipped on Business Premium) - - Optional features: - [$tickContainer] Container labels (Group.Unified EnableMIPLabels) ['?' = default on; auto-skips if license detect finds no recognised M365 BP/E5/Purview Suite SKU] - [$tickPremium] Premium audit (SearchQueryInitiated) - [$tickAdopt] Adopt existing (overwrite non-toolkit objects) - [$tickLabelCoAuth] Label co-auth tenant switch (ONE-WAY — see -EnableLabelCoAuthoring help) - - Legend: 'X' = task is in scope and will run. ' ' = task is skipped (by user opt-out or BP auto-skip). - '?' = task pending license auto-detect — runs if tenant has E5 / Purview Suite, otherwise auto-skips. - - Mode: $bannerMode - License tier: $bannerTier -============================================================================== - -"@ - -Write-Host $banner -ForegroundColor Cyan - -if (-not $WhatIfPreference -and -not $NonInteractive) { - $confirmation = Read-Host "Proceed with deployment? [y/N]" - if ($confirmation -notmatch '^[yY]') { - Write-Host "Deployment cancelled." -ForegroundColor Yellow - return - } -} - -# --------------------------------------------------------------------------- -# Connect -# --------------------------------------------------------------------------- -Write-Host "`n--- Connecting to services ---" -ForegroundColor White -$connectArgs = @{ TenantAdminUpn = $TenantAdminUpn } -if ($needsSpo) { $connectArgs['NeedsSharePoint'] = $true } -if ($SharePointAdminUrl) { $connectArgs['SharePointAdminUrl'] = $SharePointAdminUrl } -if ($DelegatedOrganization){ $connectArgs['DelegatedOrganization'] = $DelegatedOrganization } -# License auto-detect must run when ANY tier-dependent feature is in scope. -# Today there are two: -# * AI governance — default-on for E5 / Purview Suite, auto-skipped on BP. -# * Container labels — default-on for everyone (BP is the licensing floor -# and BP includes Entra ID P1, the AAD-side requirement). License -# auto-detect's only job here is to flip $SkipContainerLabels = $true -# when the tenant has no recognised M365 BP/E5/Purview Suite SKU -# ('Other' tier), since the Graph call would otherwise fail with -# "tenant lacks required license" on a sub-BP tenant. -# If neither is in scope, skip the Graph call to keep BP-only / partial -# re-runs lean. -$aiInScope = -not $SkipAIControls -$containerLabelsInScope = (-not $SkipTenantSettings -and -not $SkipContainerLabels) -$wantGraphForAutoDetect = (-not $BPOnly -and -not $NoLicenseAutoDetect -and - ($aiInScope -or $containerLabelsInScope)) -if ($containerLabelsInScope -or $wantGraphForAutoDetect) { $connectArgs['ConnectGraph'] = $true } -# Least-privilege Graph scopes (Jim's PR1 feedback): -# * Organization.Read.All — covers /subscribedSkus + /organization (license auto-detect, tenant-identity confirm) -# * Directory.ReadWrite.All — added upfront whenever container labels are in scope. -# Required for Get-MgBetaDirectorySettingTemplate + New-/Update-MgBetaDirectorySetting -# against the Group.Unified directory setting. Note: we did experiment with the -# narrower GroupSettings.ReadWrite.All scope (the documented permission for -# /directorySettingTemplates), but it triggered a 403 Authorization_RequestDenied -# on tenants whose admins had only ever consented to Directory.ReadWrite.All — the -# token cache wasn't refreshed and admin-consent for the narrower scope wasn't -# reliably available. Directory.ReadWrite.All is the historically-consented, -# known-working scope and the canonical fallback per the Graph docs. -$graphScopes = @() -if ($connectArgs.ContainsKey('ConnectGraph')) { - $graphScopes = @('Organization.Read.All') - if ($containerLabelsInScope) { $graphScopes += 'Directory.ReadWrite.All' } - $connectArgs['GraphScopes'] = $graphScopes -} -if ($AutoInstallModules) { $connectArgs['AutoInstallModules'] = $true } -if ($NonInteractive) { $connectArgs['NonInteractive'] = $true } -# Surface the Copilot DLP module-readiness pre-req only when the AI step is -# in scope. License auto-detect may still flip $BPOnly later, but if the -# operator already passed -BPOnly we can skip the warning at connect time. -if ($aiInScope -and -not $BPOnly) { $connectArgs['AIControlsInScope'] = $true } -$connectionInfo = & $connectScript @connectArgs -if ($connectionInfo -and $connectionInfo.SharePointAdminUrl) { - $SharePointAdminUrl = $connectionInfo.SharePointAdminUrl -} - -# --------------------------------------------------------------------------- -# License auto-detection (drives AI governance + container-labels gating) -# --------------------------------------------------------------------------- -function Get-TenantPurviewLicenseTier { - [CmdletBinding()] - param() - - $result = [pscustomobject]@{ - Tier = 'Unknown' - PartNumbers = @() - Reason = $null - } - - if (-not (Get-Command Invoke-MgGraphRequest -ErrorAction SilentlyContinue)) { - $result.Reason = 'Microsoft.Graph.Authentication module not loaded' - return $result - } - - try { - $resp = Invoke-MgGraphRequest -Method GET -Uri 'https://graph.microsoft.com/v1.0/subscribedSkus' -ErrorAction Stop - } catch { - $result.Reason = "Subscribed-SKUs query failed: $($_.Exception.Message)" - return $result - } - - if (-not $resp -or -not $resp.value) { - $result.Reason = 'Subscribed-SKUs response empty' - return $result - } - - $skus = @($resp.value | Where-Object { $_.capabilityStatus -ne 'Suspended' -and $_.capabilityStatus -ne 'Deleted' }) - $partNumbers = @($skus | ForEach-Object { $_.skuPartNumber } | Where-Object { $_ }) - $result.PartNumbers = $partNumbers - - # Headline SKUs that grant container-label rights (Group.Unified EnableMIPLabels) - # IMPORTANT: SKU part-numbers are exact-match strings, NOT wildcards. When - # Microsoft introduces a new SKU variant (e.g. the EU no-Teams unbundling) - # it gets a new part-number and we must add it here explicitly or the - # tenant gets misclassified as 'Other' and auto-detect skips container - # labels / -BPOnly gets force-enabled. To inventory a tenant's real SKUs: - # Invoke-MgGraphRequest -Method GET -Uri 'https://graph.microsoft.com/v1.0/subscribedSkus' | - # Select-Object -ExpandProperty value | - # Where-Object capabilityStatus -ne 'Suspended' | - # Select-Object skuPartNumber, skuId - $e5Sku = @( - # M365 E5 (full bundle, includes Teams) - 'SPE_E5','SPE_E5_NOPSTNCONF','SPE_E5_CALLINGMINUTES', - 'SPE_E5_USGOV_GCCHIGH', - # M365 E5 no-Teams variants (EU unbundling, May 2024+). - # Teams must be licensed separately via 'Microsoft_Teams_Enterprise_New'. - 'Microsoft_365_E5_(no_Teams)','Microsoft_365_E5_no_Teams','SPE_E5_NOPSTNCONF_no_Teams', - 'Microsoft_365_E5_EEA_(no_Teams)_with_Calling_Minutes', - 'Microsoft_365_E5_EEA_(no_Teams)_without_Audio_Conferencing', - # Office 365 E5 - 'ENTERPRISEPREMIUM','ENTERPRISEPREMIUM_NOPSTNCONF', - # Compliance / Security add-ons (each includes the IPPS container-label rights) - 'INFORMATION_PROTECTION_COMPLIANCE', - 'IDENTITY_THREAT_PROTECTION', - 'M365_E5_SUITE_COMPONENTS', - 'Microsoft_Purview_Suite', - 'INFORMATION_PROTECTION_AND_GOVERNANCE', - 'PURVIEW_SUITE_FOR_BUSINESS_PREMIUM', - 'PURVIEW_SUITE_FOR_BUSINESS_PREMIUM_NEW', - 'DEFENDER_AND_PURVIEW_SUITES_FOR_BUSINESS_PREMIUM', - 'DEFENDER_AND_PURVIEW_SUITES_FOR_BUSINESS_PREMIUM_NEW', - # Education A5 (compliance feature parity with E5) - 'M365EDU_A5_FACULTY','M365EDU_A5_STUDENT','M365EDU_A5_STUUSEBNFT' - ) - $matched = @($partNumbers | Where-Object { $_ -in $e5Sku }) - if ($matched.Count -gt 0) { - $result.Tier = 'E5OrPurviewSuite' - $result.PartNumbers = $matched - return $result - } - - $bpSku = @( - 'SPB','BUSINESS_PREMIUM', - 'Microsoft_365_ Business_ Premium_(no Teams)', - 'Office_365_w/o_Teams_Bundle_Business_Premium', - 'Microsoft_365_Business_Premium_Donation_(Non_Profit_Pricing)' - ) - $bpMatched = @($partNumbers | Where-Object { $_ -in $bpSku }) - if ($bpMatched.Count -gt 0) { - $result.Tier = 'BusinessPremium' - $result.PartNumbers = $bpMatched - return $result - } - - $result.Tier = 'Other' - return $result -} - -if ($wantGraphForAutoDetect) { - Write-Host "`n--- License auto-detect ---" -ForegroundColor White - $tier = Get-TenantPurviewLicenseTier - switch ($tier.Tier) { - 'E5OrPurviewSuite' { - Write-Host (" Detected: Microsoft 365 E5 / Purview Suite (SKU: {0})." -f ($tier.PartNumbers -join ', ')) -ForegroundColor Green - if ($containerLabelsInScope) { - Write-Host " Container labels (Group.Unified EnableMIPLabels): enabled (default)." -ForegroundColor Green - Write-Host " (To opt out, re-run with -SkipContainerLabels.)" -ForegroundColor DarkGray - } - } - 'BusinessPremium' { - Write-Host (" Detected: Microsoft 365 Business Premium (SKU: {0})." -f ($tier.PartNumbers -join ', ')) -ForegroundColor Green - # BP includes Entra ID P1+, which is the AAD-side requirement for - # Group.Unified.EnableMIPLabels (container labels on Teams / M365 - # Groups / SharePoint sites). Microsoft markets container labels - # as an E5 / Purview Suite feature but the tenant switch itself - # works on BP — that's why container labels are default-on for - # the toolkit (BP is the documented licensing floor). - if ($containerLabelsInScope) { - Write-Host " Container labels (Group.Unified EnableMIPLabels): enabled (default)." -ForegroundColor Green - Write-Host " Rationale: BP includes Entra ID P1+, which is the AAD-side requirement for container labels." -ForegroundColor DarkGray - Write-Host " (To opt out, re-run with -SkipContainerLabels.)" -ForegroundColor DarkGray - } - if (-not $BPOnly) { - $BPOnly = $true - Write-Host " Auto-enabling -BPOnly: E5/Purview-Suite-only DLP workloads (Endpoint, MCAS, OnPrem, PowerBI) will be SKIPPED with a warning, not attempted." -ForegroundColor Yellow - Write-Host " (To override, re-run with -NoLicenseAutoDetect.)" -ForegroundColor DarkGray - } - } - 'Other' { - $skuList = if ($tier.PartNumbers) { $tier.PartNumbers -join ', ' } else { '(none)' } - Write-Host (" Tenant SKUs: {0}" -f $skuList) -ForegroundColor DarkGray - # No recognised BP/E5/Purview Suite SKU → we can't trust Entra ID P1 - # is present, so flip container labels OFF to avoid a Graph 403 / - # "tenant lacks required license" failure in step [5/5]. - if ($containerLabelsInScope) { - $SkipContainerLabels = $true - Write-Host " Auto-skipping: Container labels (Group.Unified EnableMIPLabels)." -ForegroundColor Yellow - Write-Host " Rationale: no M365 BP/E5/Purview Suite SKU detected; cannot verify Entra ID P1 (the AAD-side requirement)." -ForegroundColor DarkGray - Write-Host " (To override, re-run with -NoLicenseAutoDetect — assumes the tenant has standalone Entra ID P1 / EMS.)" -ForegroundColor DarkGray - } - if (-not $BPOnly) { - $BPOnly = $true - Write-Host " Auto-enabling -BPOnly (no E5/Purview-Suite SKU detected): E5-only DLP workloads will be SKIPPED with a warning, not attempted." -ForegroundColor Yellow - Write-Host " (To override, re-run with -NoLicenseAutoDetect.)" -ForegroundColor DarkGray - } - } - default { - Write-Host " Could not classify tenant license tier." -ForegroundColor DarkYellow - if ($tier.Reason) { Write-Host " Reason: $($tier.Reason)" -ForegroundColor DarkYellow } - Write-Host " Container labels will run anyway (default); pass -SkipContainerLabels to opt out." -ForegroundColor DarkYellow - } - } -} - -# --------------------------------------------------------------------------- -# Graph scope extension after auto-detect promotion (least-privilege) -# --------------------------------------------------------------------------- -# Graph scope is now requested UPFRONT (Directory.ReadWrite.All is included -# in the first Connect-MgGraph whenever container labels are in scope). -# Previously this block extended consent AFTER license auto-detect flipped -# $EnableContainerLabels — that path no longer exists (container labels are -# default-on for everyone, license auto-detect only flips them OFF for the -# 'Other' tier). Block removed; nothing to do here. - -# --------------------------------------------------------------------------- -# Tenant identity confirmation (PR1 — Jim's feedback) -# --------------------------------------------------------------------------- -# After connect succeeds, resolve the ACTUAL tenant we landed in and confirm -# it matches what the user implied via -TenantAdminUpn / -DelegatedOrganization. -# Catches the classic "I thought I was on tenant A but my last interactive -# sign-in was on tenant B" disaster before any destructive change runs. -function Get-ActualTenantIdentity { - [CmdletBinding()] - param() - - $identity = [pscustomobject]@{ - DisplayName = $null - TenantId = $null - DefaultDomain = $null - InitialDomain = $null - AllDomains = @() - Source = $null - } - - # Prefer Graph (cleaner structured response) when /organization is reachable. - if (Get-Command Invoke-MgGraphRequest -ErrorAction SilentlyContinue) { - try { - $org = Invoke-MgGraphRequest -Method GET ` - -Uri 'https://graph.microsoft.com/v1.0/organization?$select=id,displayName,verifiedDomains' ` - -ErrorAction Stop - if ($org -and $org.value -and $org.value.Count -gt 0) { - $o = $org.value[0] - $identity.DisplayName = $o.displayName - $identity.TenantId = $o.id - $identity.AllDomains = @($o.verifiedDomains | ForEach-Object { $_.name }) - $defaultDom = @($o.verifiedDomains | Where-Object { $_.isDefault }) - $initialDom = @($o.verifiedDomains | Where-Object { $_.isInitial }) - if ($defaultDom.Count -gt 0) { $identity.DefaultDomain = $defaultDom[0].name } - if ($initialDom.Count -gt 0) { $identity.InitialDomain = $initialDom[0].name } - $identity.Source = 'Microsoft Graph (/organization)' - return $identity - } - } catch { - Write-Verbose "Graph identity lookup failed, falling back to EXO: $($_.Exception.Message)" - } - } - - # Fall back to Exchange Online — always available because EXO is the first - # service we connect to. - try { - $orgCfg = Get-OrganizationConfig -ErrorAction Stop - if ($orgCfg) { - $identity.DisplayName = if ($orgCfg.DisplayName) { $orgCfg.DisplayName } else { $orgCfg.Name } - if ($orgCfg.PSObject.Properties['Guid'] -and $orgCfg.Guid) { - $identity.TenantId = [string]$orgCfg.Guid - } - } - $domains = @(Get-AcceptedDomain -ErrorAction Stop) - $identity.AllDomains = @($domains | ForEach-Object { $_.DomainName }) - $defaultDom = @($domains | Where-Object { $_.Default }) - $initialDom = @($domains | Where-Object { $_.InitialDomain }) - if ($defaultDom.Count -gt 0) { $identity.DefaultDomain = $defaultDom[0].DomainName } - if ($initialDom.Count -gt 0) { $identity.InitialDomain = $initialDom[0].DomainName } - $identity.Source = 'Exchange Online (Get-OrganizationConfig + Get-AcceptedDomain)' - return $identity - } catch { - throw "Could not resolve tenant identity from Graph or Exchange Online. Connection may have failed silently. Error: $($_.Exception.Message)" - } -} - -function Test-ExpectedTenantMatch { - [CmdletBinding()] - param( - [Parameter(Mandatory)] $Identity, - [string] $TenantAdminUpn, - [string] $DelegatedOrganization - ) - - # GDAP: expected = -DelegatedOrganization (a domain on the customer tenant). - # Non-GDAP: expected = the admin UPN's domain suffix. - $expected = if ($DelegatedOrganization) { - $DelegatedOrganization.ToLowerInvariant() - } elseif ($TenantAdminUpn -match '@(.+)$') { - $Matches[1].ToLowerInvariant() - } else { - $null - } - - if (-not $expected) { - return [pscustomobject]@{ - Match = $false - Source = '(could not derive expected tenant from inputs)' - Expected = $null - Reason = 'Cannot validate tenant identity: no -DelegatedOrganization and -TenantAdminUpn has no domain suffix.' - } - } - - $source = if ($DelegatedOrganization) { '-DelegatedOrganization' } else { 'admin UPN suffix' } - $allDomains = @($Identity.AllDomains | ForEach-Object { $_.ToLowerInvariant() }) - $match = $allDomains -contains $expected - - return [pscustomobject]@{ - Match = $match - Source = $source - Expected = $expected - Reason = if ($match) { - "Expected domain '$expected' (from $source) is a verified domain on the connected tenant." - } else { - "Expected domain '$expected' (from $source) is NOT a verified domain on the connected tenant. The signed-in session appears to be authed against a DIFFERENT tenant than intended." - } - } -} - -Write-Host "`n--- Tenant identity confirmation ---" -ForegroundColor White -$tenantIdentity = Get-ActualTenantIdentity -$expectedMatch = Test-ExpectedTenantMatch -Identity $tenantIdentity ` - -TenantAdminUpn $TenantAdminUpn ` - -DelegatedOrganization $DelegatedOrganization - -$idTenantId = if ($tenantIdentity.TenantId) { $tenantIdentity.TenantId } else { '(not resolved)' } -$idDisplayName = if ($tenantIdentity.DisplayName) { $tenantIdentity.DisplayName } else { '(not resolved)' } -$idDefaultDomain = if ($tenantIdentity.DefaultDomain) { $tenantIdentity.DefaultDomain } else { '(not resolved)' } -$idInitialDomain = if ($tenantIdentity.InitialDomain) { $tenantIdentity.InitialDomain } else { '(not resolved)' } -$idUpnSuffix = if ($TenantAdminUpn -match '@(.+)$') { $Matches[1] } else { '(unknown)' } -$idGdap = if ($DelegatedOrganization) { $DelegatedOrganization } else { '(none)' } - -$idBanner = @" - Connected tenant (live from $($tenantIdentity.Source)): - Display name : $idDisplayName - Default domain : $idDefaultDomain - Initial domain : $idInitialDomain - Tenant ID : $idTenantId - - Expected (from arguments): - UPN suffix : $idUpnSuffix - GDAP target : $idGdap -"@ -Write-Host $idBanner -ForegroundColor Cyan - -if ($expectedMatch.Match) { - Write-Host " [OK] Identity matches expected: $($expectedMatch.Reason)" -ForegroundColor Green -} else { - Write-Host " [!!] IDENTITY MISMATCH: $($expectedMatch.Reason)" -ForegroundColor Red - if ($NonInteractive) { - throw "Tenant identity mismatch (running with -NonInteractive). Aborting before any destructive change.`n Expected (from $($expectedMatch.Source)): $($expectedMatch.Expected)`n Connected tenant verified domains: $($tenantIdentity.AllDomains -join ', ')" - } -} - -if (-not $WhatIfPreference -and -not $NonInteractive) { - $tenantLabel = if ($tenantIdentity.DisplayName -and $tenantIdentity.DefaultDomain) { - "$($tenantIdentity.DisplayName) ($($tenantIdentity.DefaultDomain))" - } elseif ($tenantIdentity.DefaultDomain) { - $tenantIdentity.DefaultDomain - } else { - '(unidentified tenant)' - } - $confirm = Read-Host "`nConfirm: deploy to '$tenantLabel'? [y/N]" - if ($confirm -notmatch '^[yY]') { - Write-Host "Deployment cancelled at tenant identity check." -ForegroundColor Yellow - return - } -} - -# --------------------------------------------------------------------------- -# Run tasks in order -# --------------------------------------------------------------------------- -# Jim's PR5 feedback: wrap the entire run-tasks block in try/finally so the -# deployment summary ALWAYS prints, even when one of the modules throws a -# terminating error mid-run. Operators need to know what got done before the -# crash; losing the summary is worse than the crash itself. -$summary = [ordered]@{} -try { - -if (-not $SkipTenantSettings) { - Write-Host "`n--- [1/5] Tenant settings ---" -ForegroundColor White - $_taskSw = [System.Diagnostics.Stopwatch]::StartNew() - Add-RunLogEntry -Module 'Setup-TenantSettings' -Action 'Module start' -Status 'Started' - try { - $taskArgs = @{ Config = $config } - if ($SkipContainerLabels) { $taskArgs['SkipContainerLabels'] = $true } - if ($EnablePremiumAudit) { $taskArgs['EnablePremiumAudit'] = $true; $taskArgs['PremiumAuditMailbox'] = $PremiumAuditMailbox } - if ($NonInteractive) { $taskArgs['NonInteractive'] = $true } - & $tenantScript @taskArgs - $summary['Tenant settings'] = 'OK' - $_taskSw.Stop() - Add-RunLogEntry -Module 'Setup-TenantSettings' -Action 'Module complete' -Status 'Succeeded' -ElapsedMs ([int]$_taskSw.ElapsedMilliseconds) - } catch { - $summary['Tenant settings'] = "FAILED: $($_.Exception.Message)" - $_taskSw.Stop() - Add-RunLogEntry -Module 'Setup-TenantSettings' -Action 'Module complete' -Status 'Failed' -ElapsedMs ([int]$_taskSw.ElapsedMilliseconds) -Detail $_.Exception.Message - Write-Error $_ - } -} else { - $summary['Tenant settings'] = 'Skipped' - Add-RunLogEntry -Module 'Setup-TenantSettings' -Action 'Module' -Status 'Skipped' -Detail '-SkipTenantSettings was set' -} - -if (-not $SkipLabels) { - Write-Host "`n--- [2/5] Sensitivity labels ---" -ForegroundColor White - $_taskSw = [System.Diagnostics.Stopwatch]::StartNew() - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Module start' -Status 'Started' - try { - $taskArgs = @{ Config = $config } - if ($AdoptExisting) { $taskArgs['AdoptExisting'] = $true } - if ($tenantIdentity) { $taskArgs['TenantIdentity'] = $tenantIdentity } - if ($SkipContainerLabels) { $taskArgs['SkipContainerLabels'] = $true } - & $labelsScript @taskArgs - $summary['Sensitivity labels'] = 'OK' - $_taskSw.Stop() - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Module complete' -Status 'Succeeded' -ElapsedMs ([int]$_taskSw.ElapsedMilliseconds) - } catch { - $summary['Sensitivity labels'] = "FAILED: $($_.Exception.Message)" - $_taskSw.Stop() - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Module complete' -Status 'Failed' -ElapsedMs ([int]$_taskSw.ElapsedMilliseconds) -Detail $_.Exception.Message - Write-Error $_ - } -} else { - $summary['Sensitivity labels'] = 'Skipped' - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Module' -Status 'Skipped' -Detail '-SkipLabels was set' -} - -if (-not $SkipDLP) { - Write-Host "`n--- [3/5] DLP policies ---" -ForegroundColor White - $_taskSw = [System.Diagnostics.Stopwatch]::StartNew() - Add-RunLogEntry -Module 'Setup-DLP' -Action 'Module start' -Status 'Started' - try { - $taskArgs = @{ Config = $config } - if ($AdoptExisting) { $taskArgs['AdoptExisting'] = $true } - if ($BPOnly) { $taskArgs['BPOnly'] = $true } - & $dlpScript @taskArgs - $summary['DLP policies'] = 'OK' - $_taskSw.Stop() - Add-RunLogEntry -Module 'Setup-DLP' -Action 'Module complete' -Status 'Succeeded' -ElapsedMs ([int]$_taskSw.ElapsedMilliseconds) - } catch { - $summary['DLP policies'] = "FAILED: $($_.Exception.Message)" - $_taskSw.Stop() - Add-RunLogEntry -Module 'Setup-DLP' -Action 'Module complete' -Status 'Failed' -ElapsedMs ([int]$_taskSw.ElapsedMilliseconds) -Detail $_.Exception.Message - Write-Error $_ - } -} else { - $summary['DLP policies'] = 'Skipped' - Add-RunLogEntry -Module 'Setup-DLP' -Action 'Module' -Status 'Skipped' -Detail '-SkipDLP was set' -} - -if ($ApplyRetention) { - Write-Host "`n--- [4/5] Retention ---" -ForegroundColor White - $_taskSw = [System.Diagnostics.Stopwatch]::StartNew() - Add-RunLogEntry -Module 'Setup-Retention' -Action 'Module start' -Status 'Started' - try { - $taskArgs = @{ Config = $config } - if ($AdoptExisting) { $taskArgs['AdoptExisting'] = $true } - & $retentionScript @taskArgs - $summary['Retention'] = 'OK' - $_taskSw.Stop() - Add-RunLogEntry -Module 'Setup-Retention' -Action 'Module complete' -Status 'Succeeded' -ElapsedMs ([int]$_taskSw.ElapsedMilliseconds) - } catch { - $summary['Retention'] = "FAILED: $($_.Exception.Message)" - $_taskSw.Stop() - Add-RunLogEntry -Module 'Setup-Retention' -Action 'Module complete' -Status 'Failed' -ElapsedMs ([int]$_taskSw.ElapsedMilliseconds) -Detail $_.Exception.Message - Write-Error $_ - } -} else { - $summary['Retention'] = 'Skipped (opt-in — pass -ApplyRetention to enable; see docs/Retention-Default-Risk.md)' - Add-RunLogEntry -Module 'Setup-Retention' -Action 'Module' -Status 'Skipped' -Detail '-ApplyRetention not set (opt-in)' -} - -# AI governance is now default-on for E5 / Purview Suite tenants. -# Skip paths: -# * -SkipAIControls — explicit operator opt-out -# * $BPOnly — auto-detected Business Premium (Copilot DLP policy plane -# is an E5 / Purview Suite feature); we never call the module on BP -# When neither applies we run it. The module itself ALSO honours -BPOnly -# as a defense-in-depth guard for direct callers (the orchestrator never -# passes -BPOnly on this path because the gate above already excluded BP). -Write-Host "`n--- [5/5] AI governance (Copilot DLP) ---" -ForegroundColor White -if (-not $SkipAIControls -and -not $BPOnly) { - $_taskSw = [System.Diagnostics.Stopwatch]::StartNew() - Add-RunLogEntry -Module 'Setup-AIGovernance' -Action 'Module start' -Status 'Started' - try { - $taskArgs = @{ Config = $config } - if ($AdoptExisting) { $taskArgs['AdoptExisting'] = $true } - & $aiScript @taskArgs - $summary['AI governance'] = 'OK' - $_taskSw.Stop() - Add-RunLogEntry -Module 'Setup-AIGovernance' -Action 'Module complete' -Status 'Succeeded' -ElapsedMs ([int]$_taskSw.ElapsedMilliseconds) - } catch { - $summary['AI governance'] = "FAILED: $($_.Exception.Message)" - $_taskSw.Stop() - Add-RunLogEntry -Module 'Setup-AIGovernance' -Action 'Module complete' -Status 'Failed' -ElapsedMs ([int]$_taskSw.ElapsedMilliseconds) -Detail $_.Exception.Message - Write-Error $_ - } -} elseif ($SkipAIControls) { - Write-Host " Skipped (-SkipAIControls)." -ForegroundColor DarkGray - Write-Host " Microsoft 365 Copilot DLP policies in PurviewConfig.psd1 -> AIGovernance" -ForegroundColor DarkGray - Write-Host " will NOT be created or updated this run." -ForegroundColor DarkGray - $summary['AI governance'] = 'Skipped (-SkipAIControls)' - Add-RunLogEntry -Module 'Setup-AIGovernance' -Action 'Module' -Status 'Skipped' -Detail '-SkipAIControls was set' -} else { - # $BPOnly path (either explicit -BPOnly or auto-detected Business Premium). - Write-Host " Skipped (E5 / Purview Suite required; tenant is Business Premium)." -ForegroundColor DarkGray - Write-Host " Copilot DLP policies are part of the E5 / Purview Suite plane and" -ForegroundColor DarkGray - Write-Host " cannot be created on Business Premium. Upgrade the SKU or omit" -ForegroundColor DarkGray - Write-Host " -BPOnly to re-evaluate." -ForegroundColor DarkGray - $summary['AI governance'] = 'Skipped (E5 / Purview Suite required; Business Premium tenant)' - Add-RunLogEntry -Module 'Setup-AIGovernance' -Action 'Module' -Status 'Skipped' -Detail '-BPOnly was set (Copilot DLP requires E5 / Purview Suite)' -} - -} finally { - # --------------------------------------------------------------------------- - # Summary (always runs — even if a task threw a terminating error above). - # --------------------------------------------------------------------------- - Write-Host "`n==============================================================================" -ForegroundColor Cyan - Write-Host " Deployment summary" -ForegroundColor Cyan - Write-Host "==============================================================================" -ForegroundColor Cyan - if ($summary.Count -eq 0) { - Write-Host " (No tasks recorded — run aborted before any step started.)" -ForegroundColor DarkYellow - } else { - $summary.GetEnumerator() | ForEach-Object { - $color = switch -Wildcard ($_.Value) { - 'OK' { 'Green' } - 'Skipped*' { 'DarkGray' } - 'FAILED:*' { 'Red' } - default { 'White' } - } - Write-Host (" {0,-22} {1}" -f $_.Key, $_.Value) -ForegroundColor $color - } - } - Write-Host "==============================================================================" -ForegroundColor Cyan - - Write-Host "`nReminder: sensitivity-label and DLP changes can take up to 24 hours to fully propagate." -ForegroundColor DarkYellow - - # --------------------------------------------------------------------------- - # HTML report (Tier 1 + Tier 2) — emitted AFTER the CLI summary so a - # renderer failure can never hide the console output. Suppress with - # -NoReport. - # --------------------------------------------------------------------------- - if (-not $NoReport) { - try { - . (Join-Path $PSScriptRoot 'Modules\Write-PurviewHtmlReport.ps1') - - $resolvedReportPath = if ($ReportPath) { - $ReportPath - } else { - # Default: drop the report in the caller's working directory - # (where they ran the script from), NOT $PSScriptRoot. Operators - # expect output next to where they invoked the tool, and the - # install folder may be read-only on managed devices. - Join-Path (Get-Location).Path ("Deploy-PurviewBestPractice-Report-{0}.html" -f $script:StartTime.ToString('yyyyMMdd-HHmmss')) - } - - $endTime = Get-Date - $reportRunLog = Get-PurviewRunLog - - $reportParams = @{ - Summary = $summary - OutputPath = $resolvedReportPath - StartTime = $script:StartTime - EndTime = $endTime - RunId = $script:RunId - Parameters = $PSBoundParameters - ScriptVersion = $script:DeployVersion - } - if ($tenantIdentity) { $reportParams['TenantIdentity'] = $tenantIdentity } - if ($TenantAdminUpn) { $reportParams['TenantAdminUpn'] = $TenantAdminUpn } - if ($reportRunLog -and $reportRunLog.Count -gt 0) { - $reportParams['RunLog'] = $reportRunLog - } - - $written = Write-PurviewHtmlReport @reportParams - Write-Host ("`nHTML report written: {0}" -f $written) -ForegroundColor Cyan - - # JSON sidecar -- same path, .json extension. Machine-readable - # mirror of the run log + summary, handy for auditing or for - # piping into ticketing systems. - try { - $jsonPath = [System.IO.Path]::ChangeExtension($written, '.json') - $tid = if ($tenantIdentity) { [string]$tenantIdentity.TenantId } else { '' } - Save-PurviewRunLogJson ` - -Path $jsonPath ` - -RunId $script:RunId ` - -StartTime $script:StartTime ` - -EndTime $endTime ` - -ScriptVersion $script:DeployVersion ` - -TenantId $tid ` - -TenantAdminUpn $TenantAdminUpn ` - -Summary $summary | Out-Null - Write-Host ("JSON sidecar: {0}" -f $jsonPath) -ForegroundColor Cyan - } catch { - Write-Warning ("JSON sidecar could not be written: {0}" -f $_.Exception.Message) - } - } catch { - # Never throw out of the finally block — the CLI summary already - # printed; a report failure is a usability bug, not a deploy bug. - Write-Warning ("HTML report could not be written: {0}" -f $_.Exception.Message) - } finally { - # Clear $global:PurviewRunLog so it doesn't leak between runs - # in interactive PowerShell sessions. - try { Clear-PurviewRunLog } catch { } - } - } -} +# ============================================================================= +# DISCLAIMER +# ============================================================================= +# This sample script is not supported under any Microsoft standard support +# program or service. The sample script is provided AS IS without warranty of +# any kind. Microsoft further disclaims all implied warranties including, +# without limitation, any implied warranties of merchantability or of fitness +# for a particular purpose. The entire risk arising out of the use or +# performance of the sample scripts and documentation remains with you. In no +# event shall Microsoft, its authors, or anyone else involved in the creation, +# production, or delivery of the scripts be liable for any damages whatsoever +# (including, without limitation, damages for loss of business profits, +# business interruption, loss of business information, or other pecuniary +# loss) arising out of the use of or inability to use the sample scripts or +# documentation, even if Microsoft has been advised of the possibility of +# such damages. +# +# Please do not contact Microsoft support with any issues or concerns +# regarding this script. +# ============================================================================= + +<# +.SYNOPSIS + Deploys the Microsoft Purview Best Practice baseline for Microsoft 365 + Business Premium tenants. + +.DESCRIPTION + Single entry point that orchestrates the full deployment based on the + Microsoft "Data Security Best Practice Deployment" guide for Business + Premium. The toolkit is modular — every task is an independent script + under .\Modules and can be run standalone. + + Tasks (in order): + 1. Tenant settings — audit log, AIP/SPO integration, PDF labels, + co-authoring (and optional container labels + + premium audit) + 2. Sensitivity labels — Personal, Public, General, Confidential + (with AllEmployees sub-label), + Highly Confidential — encryption applied, + ordered, and published with General as the + default + 3. DLP — separate Exchange and SPO+OneDrive policies + blocking external sharing of the + Confidential\AllEmployees label + 4. Retention — Exchange mailbox 7-year retain-then-delete + 5. AI governance — Microsoft 365 Copilot DLP policies + (e.g. AI_054 - Block Copilot for Highly + Confidential). Default ON for E5 / Purview + Suite tenants; auto-skipped on Business + Premium ($BPOnly). Opt out with + -SkipAIControls. + + Default mode = APPLY changes. Pass -WhatIf for preview, or -Confirm for + per-action confirmation prompts. + +.PARAMETER TenantAdminUpn + UPN of the tenant administrator (or partner GDAP admin) used for sign-in. + +.PARAMETER SharePointAdminUrl + Optional override for the SharePoint admin centre URL + (e.g. https://contoso-admin.sharepoint.com). When omitted, the URL is + auto-derived from the tenant's initial onmicrosoft.com domain after + Exchange Online is connected. Use this parameter only when auto-derivation + fails (e.g. multi-geo or unusual domain configurations). + +.PARAMETER DelegatedOrganization + Customer tenant primary domain when running as a partner via GDAP. + +.PARAMETER ConfigPath + Path to a custom PurviewConfig.psd1. Defaults to .\Config\PurviewConfig.psd1. + +.PARAMETER SkipTenantSettings + Skip foundational tenant settings (audit, SPO integration, co-authoring). + +.PARAMETER SkipLabels + Skip sensitivity-label creation and publishing. + +.PARAMETER SkipDLP + Skip DLP policy creation. + +.PARAMETER ApplyRetention + Provision the Exchange mailbox retention policy from + PurviewConfig.psd1. **Opt-in** — retention does NOT run by default + because the shipped 7-year retain-then-delete default is destructive + (deletes mail older than 7 years tenant-wide) and is wrong for some + regulated verticals (law / accounting / healthcare / financial + advisors / construction / real estate). The partner must consciously + choose a duration for the customer's vertical before enabling. + See docs/Retention-Default-Risk.md. + +.PARAMETER SkipAIControls + Skip the AI governance / Microsoft 365 Copilot DLP policy step. By + default, AI governance runs on every E5 / Purview Suite deployment + because the policy plane is included in those SKUs and the protection + (blocking Copilot from grounding on Highly Confidential content) is the + same risk class as Endpoint DLP. AI governance is auto-skipped on + Business Premium tenants ($BPOnly) regardless of this switch. + + Per Microsoft Learn, the policy enforces against both paid Microsoft + 365 Copilot and the free Microsoft 365 Copilot Chat experience, so + creation succeeds on E5 / Purview Suite tenants even when no paid + Copilot per-user licenses are present. + + See: https://learn.microsoft.com/purview/dlp-microsoft365-copilot-location-learn-about + +.PARAMETER ApplyAIControls + DEPRECATED. AI governance is now provisioned by default (see + -SkipAIControls). Passing this switch emits a deprecation warning and + is otherwise ignored. Cannot be combined with -SkipAIControls. Kept + for backward compatibility with existing partner runbooks; will be + removed in a future major version. + +.PARAMETER EnableContainerLabels + DEPRECATED. Container labels (Group.Unified EnableMIPLabels) are now + provisioned by default (the toolkit's licensing floor is Microsoft 365 + Business Premium, which includes Entra ID P1 — the AAD-side + requirement). See -SkipContainerLabels to opt out. Passing this switch + emits a deprecation warning and is otherwise ignored. Cannot be + combined with -SkipContainerLabels. Kept for backward compatibility + with existing partner runbooks; will be removed in a future major + version. + +.PARAMETER SkipContainerLabels + Opt out of container labels (Group.Unified EnableMIPLabels) for + Microsoft 365 groups, Teams, and SharePoint sites. Use this only when + container labels are managed by another process (e.g. an existing + Conditional Access / sensitivity-label rollout owned by the customer + or another partner), or on a sub-Business-Premium tenant that lacks + Entra ID P1. License auto-detect also flips this on automatically + when the tenant has no recognised Microsoft 365 BP / E5 / Purview + Suite SKU. + +.PARAMETER EnablePremiumAudit + Also enable per-mailbox SearchQueryInitiated audit. Requires Audit + (Premium) licensing. + +.PARAMETER PremiumAuditMailbox + Mailbox UPN(s) on which to enable SearchQueryInitiated audit. + +.PARAMETER AdoptExisting + Update labels, policies, and rules that already exist but were not created + by this toolkit. Use only after auditing existing configuration. + +.PARAMETER EnableLabelCoAuthoring + OPT-IN tenant-wide switch. Calls `Set-PolicyConfig -EnableLabelCoauth:$true` + in Setup-TenantSettings step [4/5]. THIS IS A ONE-WAY CHANGE: once + enabled, sensitivity-label metadata moves out of the old custom-properties + location to the new embedded location. Disabling it later (PowerShell + only — the Purview portal does NOT support disabling) REMOVES the new- + location metadata; unencrypted Word/Excel/PowerPoint files lose their + labels entirely. Any third-party app, scanner or script that reads + labels from the old location will break: AIP scanner < v3.0, OneDrive + sync < 19.002, MIP SDK < 1.7, custom DLP scanners, custom Exchange + mail-flow rules, etc. Off by default because partners cannot + enumerate every third-party integration on a customer tenant. Confirm + no old-location consumers exist before passing this switch. + Ref: https://learn.microsoft.com/purview/sensitivity-labels-coauthoring + +.PARAMETER NonInteractive + Skip the preflight confirmation prompt (e.g. for CI/automation runs). + Also skips the post-connect tenant-identity confirmation prompt. If the + connected tenant's verified domains do NOT include the expected domain + (from -DelegatedOrganization or the -TenantAdminUpn suffix), the script + aborts with a hard error BEFORE any destructive change. + +.PARAMETER AutoInstallModules + Auto-install any missing PowerShell modules (ExchangeOnlineManagement, + Microsoft.Online.SharePoint.PowerShell, Microsoft.Graph.*) to the current + user scope without prompting. Without this switch you are prompted before + each install. + +.PARAMETER NoLicenseAutoDetect + Disable automatic license-tier detection. By default the script connects to + Microsoft Graph, reads /subscribedSkus, and (a) skips AI governance when no + Microsoft 365 E5 / Purview Suite SKU is detected, and (b) skips container + labels when no Microsoft 365 BP / E5 / Purview Suite SKU is detected. + Pass this switch to skip detection (e.g. when running unattended in a + tenant where the operator does not have Organization.Read.All consent). + +.PARAMETER BPOnly + Hard-restrict the toolkit to Microsoft 365 Business Premium-eligible + features only. Refuses to enable add-ons that require Microsoft 365 E5 / + Microsoft Purview Suite licensing: + * Premium Audit / 1-year retention (-EnablePremiumAudit) + * Endpoint DLP (Devices), DLP for Defender for Cloud Apps, + on-premises DLP scanner, Power BI DLP + Container labels (Group.Unified EnableMIPLabels) are NOT in this list — + they work on Business Premium because BP includes Entra ID P1 (the + AAD-side requirement). Pass -SkipContainerLabels if you need to opt out. + Also propagates to the DLP module so any custom workload added to + PurviewConfig.psd1 that requires E5 is rejected up-front. + +.PARAMETER DeploymentPlanPath + Optional path for the pre-connection Deployment Plan HTML file. The JSON + sidecar uses the same basename. By default both files are written to the + caller's working directory. + +.PARAMETER NoDeploymentPlan + Suppress the offline Deployment Plan. This does not suppress the end-of-run + deployment report. Plan generation is best effort and never blocks service + connection when a local rendering or file-write error occurs. + +.EXAMPLE + # Standard partner-managed customer onboarding — SharePoint admin URL is + # auto-derived from the tenant's initial domain. + .\Deploy-PurviewBestPractice.ps1 -TenantAdminUpn admin@contoso.onmicrosoft.com + +.EXAMPLE + # Preview every change without applying + .\Deploy-PurviewBestPractice.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com -WhatIf + +.EXAMPLE + # GDAP partner-delegated scenario, with retention opt-in + .\Deploy-PurviewBestPractice.ps1 ` + -TenantAdminUpn partneradmin@fabrikam.onmicrosoft.com ` + -DelegatedOrganization contoso.onmicrosoft.com ` + -ApplyRetention + +.EXAMPLE + # Override the auto-derived SharePoint admin URL (rare — multi-geo/vanity) + .\Deploy-PurviewBestPractice.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com ` + -SharePointAdminUrl https://contoso-admin.sharepoint.com + +.NOTES + * Required modules: ExchangeOnlineManagement, + Microsoft.Online.SharePoint.PowerShell, and + Microsoft.Graph.Beta.Identity.DirectoryManagement (skipped only when + -SkipContainerLabels or -SkipTenantSettings is used). + * Label and policy changes can take up to 24 hours to propagate. + * Always pilot in a test tenant before production rollout. +#> +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +param( + [Parameter(Mandatory)] + [string] $TenantAdminUpn, + + [Parameter()] + [string] $SharePointAdminUrl, + + [Parameter()] + [string] $DelegatedOrganization, + + [Parameter()] + [string] $ConfigPath, + + [Parameter()] + [switch] $SkipTenantSettings, + + [Parameter()] + [switch] $SkipLabels, + + [Parameter()] + [switch] $SkipDLP, + + [Parameter()] + [switch] $ApplyRetention, + + [Parameter()] + [switch] $SkipAIControls, + + # DEPRECATED — see -SkipAIControls. Retained as a no-op switch (with a + # runtime deprecation warning) so existing partner runbooks that pass + # -ApplyAIControls continue to work without a hard parameter-binding + # error. Mutually exclusive with -SkipAIControls (validated below). + [Parameter()] + [switch] $ApplyAIControls, + + # DEPRECATED — see -SkipContainerLabels. Retained as a no-op switch (with + # a runtime deprecation warning) so existing partner runbooks that pass + # -EnableContainerLabels continue to work without a hard parameter-binding + # error. Mutually exclusive with -SkipContainerLabels (validated below). + [Parameter()] + [switch] $EnableContainerLabels, + + [Parameter()] + [switch] $SkipContainerLabels, + + [Parameter()] + [switch] $EnablePremiumAudit, + + [Parameter()] + [string[]] $PremiumAuditMailbox, + + [Parameter()] + [switch] $AdoptExisting, + + [Parameter()] + [switch] $EnableLabelCoAuthoring, + + [Parameter()] + [switch] $NonInteractive, + + [Parameter()] + [switch] $AutoInstallModules, + + [Parameter()] + [switch] $BPOnly, + + [Parameter()] + [switch] $NoLicenseAutoDetect, + + # PR-Report: end-of-run HTML report (Tier 1 - task status + run metadata). + # Default location: same folder as the script, named with a timestamp. + # Pass -NoReport to suppress, or -ReportPath to control location. + [Parameter()] + [string] $ReportPath, + + [Parameter()] + [switch] $NoReport, + + [Parameter()] + [string] $DeploymentPlanPath, + + [Parameter()] + [switch] $NoDeploymentPlan +) + +$ErrorActionPreference = 'Stop' +# Auto-confirm: this toolkit is designed for unattended/scripted runs. Use -WhatIf for dry-run. +$ConfirmPreference = 'None' + +# --------------------------------------------------------------------------- +# Toolkit version +# --------------------------------------------------------------------------- +# Surfaced in the end-of-run HTML report and (eventually) in support logs. +# Bump on each release. The runtime build suffix is the short Git SHA when +# the script lives in a working tree -- fall back to '' in tarball deploys. +$script:DeployVersion = '1.4.0' +try { + $gitSha = & git -C $PSScriptRoot rev-parse --short HEAD 2>$null + if ($LASTEXITCODE -eq 0 -and $gitSha) { + $script:DeployVersion = "$script:DeployVersion+$($gitSha.Trim())" + } +} catch { } + +# Capture start time and run identifier as early as possible so the +# end-of-run report has accurate timing even if connect/license auto-detect +# throws before any task runs. +$script:StartTime = Get-Date +$script:RunId = [guid]::NewGuid() + +# Tier-2 run log: initialise the singleton collection that Setup-* modules +# and the retry helper will append decision-point entries to. Dot-source +# here so the functions are defined before any module is invoked. +. (Join-Path $PSScriptRoot 'Modules\PurviewRunLog.ps1') +. (Join-Path $PSScriptRoot 'Modules\PurviewTenantIdentity.ps1') +Initialize-PurviewRunLog + +# --------------------------------------------------------------------------- +# PowerShell version gate +# --------------------------------------------------------------------------- +# This toolkit requires PowerShell 7+ (PowerShell Core / pwsh.exe). +# Windows PowerShell 5.1 (powershell.exe) is NOT supported because: +# * ExchangeOnlineManagement v3+ Connect-IPPSSession's REST/EXOv3 path +# and the Microsoft.Graph SDK both rely on .NET Core APIs that PS 5.1 +# does not expose, leading to silent auth and cmdlet-discovery failures. +# * Newer label / DLP cmdlets are surfaced only over the v3 REST channel. +# We hard-fail upfront so partners aren't left debugging cryptic mid-run errors. +if ($PSVersionTable.PSVersion.Major -lt 7) { + $edition = if ($PSVersionTable.PSEdition) { $PSVersionTable.PSEdition } else { 'Desktop' } + $msg = @" +This toolkit requires PowerShell 7 or later (PowerShell Core / pwsh.exe). +You are running: PowerShell $($PSVersionTable.PSVersion) (Edition: $edition). + +Windows PowerShell 5.1 is not supported — the Exchange Online v3 REST channel +and Microsoft.Graph SDK depend on .NET Core APIs unavailable in PS 5.1, which +causes silent connection failures and missing cmdlets later in the deploy. + +Install PowerShell 7: winget install --id Microsoft.PowerShell --source winget + or: https://aka.ms/PowerShell-Release +Then re-run this script from a `pwsh` prompt (not `powershell`). +"@ + throw $msg +} + +# --------------------------------------------------------------------------- +# Locate config & modules relative to this script +# --------------------------------------------------------------------------- +$scriptRoot = Split-Path -Parent $PSCommandPath +if (-not $ConfigPath) { + $ConfigPath = Join-Path $scriptRoot 'Config\PurviewConfig.psd1' +} +if (-not (Test-Path $ConfigPath)) { + throw "Config file not found: $ConfigPath" +} +$config = Import-PowerShellDataFile -Path $ConfigPath + +# Operator opt-in for the tenant-wide label co-authoring metadata-format +# switch (Set-PolicyConfig -EnableLabelCoauth). The config default is +# $false because this is a ONE-WAY change that can break third-party apps +# reading labels from the old custom-properties location (AIP scanner +# < v3.0, OneDrive sync < 19.002, MIP SDK < 1.7, etc.). The operator +# must pass -EnableLabelCoAuthoring explicitly after confirming no old- +# location consumers exist on the target tenant. See PurviewConfig.psd1 +# comment, the .PARAMETER block above, and +# https://learn.microsoft.com/purview/sensitivity-labels-coauthoring. +if ($EnableLabelCoAuthoring) { + if (-not $config.TenantSettings) { $config.TenantSettings = @{} } + $config.TenantSettings.EnableLabelCoAuth = $true +} + +$moduleRoot = Join-Path $scriptRoot 'Modules' +$connectScript = Join-Path $moduleRoot 'Connect-PurviewServices.ps1' +$tenantScript = Join-Path $moduleRoot 'Setup-TenantSettings.ps1' +$labelsScript = Join-Path $moduleRoot 'Setup-SensitivityLabels.ps1' +$dlpScript = Join-Path $moduleRoot 'Setup-DLP.ps1' +$retentionScript = Join-Path $moduleRoot 'Setup-Retention.ps1' +$aiScript = Join-Path $moduleRoot 'Setup-AIGovernance.ps1' +$deploymentPlanScript = Join-Path $moduleRoot 'Write-PurviewDeploymentPlan.ps1' +$configurationContractScript = Join-Path $moduleRoot 'PurviewConfigurationContract.ps1' +$guideMappingPath = Join-Path $scriptRoot 'References\DataSecuritySmbGuideMapping.psd1' +$supportingGuideMappingPath = Join-Path $scriptRoot 'References\MicrosoftLearnLightweightDlpMapping.psd1' + +foreach ($s in @( + $connectScript, + $tenantScript, + $labelsScript, + $dlpScript, + $retentionScript, + $aiScript, + $configurationContractScript +)) { + if (-not (Test-Path $s)) { throw "Required module script not found: $s" } +} +. $configurationContractScript +Assert-PurviewLabelIdentityConfiguration -Config $config + +# --------------------------------------------------------------------------- +# Validate parameter combinations +# --------------------------------------------------------------------------- +$needsSpo = -not $SkipTenantSettings -or -not $SkipLabels +if ($EnablePremiumAudit -and (-not $PremiumAuditMailbox -or $PremiumAuditMailbox.Count -eq 0)) { + throw "-EnablePremiumAudit requires -PremiumAuditMailbox ." +} + +if ($BPOnly) { + $bpViolations = @() + if ($EnablePremiumAudit) { + $bpViolations += " * -EnablePremiumAudit (Audit Premium / SearchQueryInitiated) requires Microsoft 365 E5." + } + if ($bpViolations) { + throw "-BPOnly conflicts with E5-only options:`n$($bpViolations -join "`n")`nRemove the conflicting switches, or omit -BPOnly if the customer holds E5/Purview Suite." + } +} + +# AI controls: validate the deprecated/new switch combo and warn callers +# who still pass -ApplyAIControls. The switch is a no-op (AI governance is +# now default-on for E5 / Purview Suite tenants) but we keep it bindable +# so existing partner runbooks don't crash with "parameter not found". +if ($ApplyAIControls -and $SkipAIControls) { + throw "-ApplyAIControls and -SkipAIControls cannot be combined. -ApplyAIControls is deprecated (AI governance is now default-on); use -SkipAIControls alone to opt out, or remove both switches to accept the default." +} +if ($ApplyAIControls) { + Write-Warning "-ApplyAIControls is deprecated and ignored: AI governance is now default-on for E5 / Purview Suite tenants. Pass -SkipAIControls to opt out, or -BPOnly to force-skip. This switch will be removed in a future release." +} + +# Container labels: validate the deprecated/new switch combo and warn callers +# who still pass -EnableContainerLabels. The switch is a no-op (container +# labels are now default-on — Business Premium is the toolkit's licensing +# floor and BP includes Entra ID P1, which is the AAD-side requirement) but +# we keep it bindable so existing partner runbooks don't crash with +# "parameter not found". Mirrors the -ApplyAIControls / -SkipAIControls +# deprecation pattern above. +if ($EnableContainerLabels -and $SkipContainerLabels) { + throw "-EnableContainerLabels and -SkipContainerLabels cannot be combined. -EnableContainerLabels is deprecated (container labels are now default-on); use -SkipContainerLabels alone to opt out, or remove both switches to accept the default." +} +if ($EnableContainerLabels) { + Write-Warning "-EnableContainerLabels is deprecated and ignored: container labels are now default-on (Business Premium is the licensing floor and BP includes Entra ID P1, the AAD-side requirement). Pass -SkipContainerLabels to opt out. This switch will be removed in a future release." +} + +# --------------------------------------------------------------------------- +# Offline Deployment Plan +# --------------------------------------------------------------------------- +if ($NoDeploymentPlan) { + Add-RunLogEntry -Module 'Write-PurviewDeploymentPlan' -Action 'Generate plan' ` + -Status 'Skipped' -Detail '-NoDeploymentPlan was set' +} else { + try { + if (-not (Test-Path -LiteralPath $deploymentPlanScript -PathType Leaf)) { + throw "Deployment Plan writer not found: $deploymentPlanScript" + } + if (-not (Test-Path -LiteralPath $guideMappingPath -PathType Leaf)) { + throw "Deployment Plan guide mapping not found: $guideMappingPath" + } + if (-not (Test-Path -LiteralPath $supportingGuideMappingPath -PathType Leaf)) { + throw "Deployment Plan supporting guide mapping not found: $supportingGuideMappingPath" + } + + . $deploymentPlanScript + $guideMapping = Import-PowerShellDataFile -LiteralPath $guideMappingPath + $supportingGuideMapping = Import-PowerShellDataFile -LiteralPath $supportingGuideMappingPath + $planId = [guid]::NewGuid() + $planGeneratedAt = [datetime]::UtcNow + $planReference = New-PurviewPlanReference -PlanId $planId -GeneratedAt $planGeneratedAt + $resolvedDeploymentPlanPath = if ($DeploymentPlanPath) { + $DeploymentPlanPath + } else { + Join-Path (Get-Location).Path ( + 'Deploy-PurviewBestPractice-Plan-{0}.html' -f $planReference + ) + } + $deploymentPlanModel = Get-PurviewDeploymentPlanModel ` + -Config $config ` + -ConfigPath $ConfigPath ` + -Parameters $PSBoundParameters ` + -GuideMapping $guideMapping ` + -SupportingGuideMappings @($supportingGuideMapping) ` + -ScriptVersion $script:DeployVersion ` + -PlanId $planId ` + -PlanReference $planReference ` + -GeneratedAt $planGeneratedAt + $deploymentPlanResult = Write-PurviewDeploymentPlan ` + -Model $deploymentPlanModel ` + -OutputPath $resolvedDeploymentPlanPath + + Write-Host ("`nDeployment Plan reference: {0}" -f $deploymentPlanResult.PlanReference) -ForegroundColor Cyan + Write-Host ("Deployment Plan HTML: {0}" -f ([IO.Path]::GetFileName($deploymentPlanResult.HtmlPath))) -ForegroundColor Cyan + Write-Host ("Plan JSON sidecar: {0}" -f ([IO.Path]::GetFileName($deploymentPlanResult.JsonPath))) -ForegroundColor Cyan + Add-RunLogEntry -Module 'Write-PurviewDeploymentPlan' -Action 'Generate plan' ` + -Target ([IO.Path]::GetFileName($deploymentPlanResult.HtmlPath)) ` + -Status 'Succeeded' ` + -Detail ( + 'planReference={0}; configurationSha256={1}; planInputSha256={2}' -f + $deploymentPlanResult.PlanReference, + $deploymentPlanResult.ConfigurationSha256, + $deploymentPlanResult.PlanInputSha256 + ) + } catch { + $planErrorType = $_.Exception.GetType().Name + $planGuidance = if ($planErrorType -in @( + 'IOException', + 'UnauthorizedAccessException', + 'DirectoryNotFoundException', + 'DriveNotFoundException', + 'ItemNotFoundException', + 'NotSupportedException' + )) { + 'Review the output directory and permissions.' + } else { + 'Review the local guide mappings and Deployment Plan renderer.' + } + $planWarning = "Offline Deployment Plan could not be written ($planErrorType). $planGuidance" + Write-Warning $planWarning + Add-RunLogEntry -Module 'Write-PurviewDeploymentPlan' -Action 'Generate plan' ` + -Status 'Info' -Detail "WARNING: $planWarning" + } +} + +# --------------------------------------------------------------------------- +# Preflight summary +# --------------------------------------------------------------------------- +$bannerSpoUrl = if ($SharePointAdminUrl) { $SharePointAdminUrl } elseif ($needsSpo) { '(auto-derive from tenant)' } else { '(not needed)' } +$bannerDelegate = if ($DelegatedOrganization) { $DelegatedOrganization } else { '(none)' } +$tickTenant = if (-not $SkipTenantSettings) { 'X' } else { ' ' } +$tickLabels = if (-not $SkipLabels) { 'X' } else { ' ' } +$tickDlp = if (-not $SkipDLP) { 'X' } else { ' ' } +$tickRetention = if ($ApplyRetention) { 'X' } else { ' ' } +$tickAi = if ($SkipAIControls) { ' ' } + elseif ($BPOnly -or $NoLicenseAutoDetect) { + if ($BPOnly) { ' ' } else { 'X' } + } else { '?' } +$tickContainer = if ($SkipContainerLabels -or $SkipTenantSettings) { ' ' } + elseif ($BPOnly -or $NoLicenseAutoDetect) { 'X' } + else { '?' } +$tickPremium = if ($EnablePremiumAudit) { 'X' } else { ' ' } +$tickAdopt = if ($AdoptExisting) { 'X' } else { ' ' } +$tickLabelCoAuth = if ($EnableLabelCoAuthoring) { 'X' } else { ' ' } +$bannerMode = if ($WhatIfPreference) { 'WHAT-IF (preview only — no changes)' } else { 'APPLY (changes will be made)' } +$bannerTier = if ($BPOnly) { 'Business Premium ONLY (E5 features blocked)' } else { 'No license tier restriction' } + +$banner = @" + +============================================================================== + Microsoft Purview Best Practice Deployment + Reference: M365 Business Premium "Data Security Best Practice Deployment" +============================================================================== + Tenant admin UPN : $TenantAdminUpn + SharePoint admin URL : $bannerSpoUrl + Delegated org (GDAP) : $bannerDelegate + Config file : $ConfigPath + + Tasks to run: + [$tickTenant] Tenant settings (audit, SPO/AIP, PDF) + [$tickLabels] Sensitivity labels (3 parents + 5 sub-labels, publish) + [$tickDlp] DLP policies (Exchange + SPO/OneDrive) + [$tickRetention] Retention (Exchange 7 years — opt-in via -ApplyRetention) + [$tickAi] AI governance (Block Copilot grounding on Highly Confidential — default on; opt out: -SkipAIControls; auto-skipped on Business Premium) + + Optional features: + [$tickContainer] Container labels (Group.Unified EnableMIPLabels) ['?' = default on; auto-skips if license detect finds no recognised M365 BP/E5/Purview Suite SKU] + [$tickPremium] Premium audit (SearchQueryInitiated) + [$tickAdopt] Adopt existing (overwrite non-toolkit objects) + [$tickLabelCoAuth] Label co-auth tenant switch (ONE-WAY — see -EnableLabelCoAuthoring help) + + Legend: 'X' = task is in scope and will run. ' ' = task is skipped (by user opt-out or BP auto-skip). + '?' = task pending license auto-detect — runs if tenant has E5 / Purview Suite, otherwise auto-skips. + + Mode: $bannerMode + License tier: $bannerTier +============================================================================== + +"@ + +Write-Host $banner -ForegroundColor Cyan + +if (-not $WhatIfPreference -and -not $NonInteractive) { + $confirmation = Read-Host "Proceed with deployment? [y/N]" + if ($confirmation -notmatch '^[yY]') { + Write-Host "Deployment cancelled." -ForegroundColor Yellow + return + } +} + +# --------------------------------------------------------------------------- +# Connect +# --------------------------------------------------------------------------- +Write-Host "`n--- Connecting to services ---" -ForegroundColor White +$connectArgs = @{ TenantAdminUpn = $TenantAdminUpn } +if ($needsSpo) { $connectArgs['NeedsSharePoint'] = $true } +if ($SharePointAdminUrl) { $connectArgs['SharePointAdminUrl'] = $SharePointAdminUrl } +if ($DelegatedOrganization){ $connectArgs['DelegatedOrganization'] = $DelegatedOrganization } +# License auto-detect must run when ANY tier-dependent feature is in scope. +# Today there are two: +# * AI governance — default-on for E5 / Purview Suite, auto-skipped on BP. +# * Container labels — default-on for everyone (BP is the licensing floor +# and BP includes Entra ID P1, the AAD-side requirement). License +# auto-detect's only job here is to flip $SkipContainerLabels = $true +# when the tenant has no recognised M365 BP/E5/Purview Suite SKU +# ('Other' tier), since the Graph call would otherwise fail with +# "tenant lacks required license" on a sub-BP tenant. +# If neither is in scope, skip the Graph call to keep BP-only / partial +# re-runs lean. +$aiInScope = -not $SkipAIControls +$containerLabelsInScope = (-not $SkipTenantSettings -and -not $SkipContainerLabels) +$wantGraphForAutoDetect = (-not $BPOnly -and -not $NoLicenseAutoDetect -and + ($aiInScope -or $containerLabelsInScope)) +if ($containerLabelsInScope -or $wantGraphForAutoDetect) { $connectArgs['ConnectGraph'] = $true } +# Least-privilege Graph scopes: +# * Organization.Read.All — covers /subscribedSkus + /organization (license auto-detect, tenant-identity confirm) +# * Directory.ReadWrite.All — added upfront whenever container labels are in scope. +# Required for Get-MgBetaDirectorySettingTemplate + New-/Update-MgBetaDirectorySetting +# against the Group.Unified directory setting. Note: we did experiment with the +# narrower GroupSettings.ReadWrite.All scope (the documented permission for +# /directorySettingTemplates), but it triggered a 403 Authorization_RequestDenied +# on tenants whose admins had only ever consented to Directory.ReadWrite.All — the +# token cache wasn't refreshed and admin-consent for the narrower scope wasn't +# reliably available. Directory.ReadWrite.All is the historically-consented, +# known-working scope and the canonical fallback per the Graph docs. +$graphScopes = @() +if ($connectArgs.ContainsKey('ConnectGraph')) { + $graphScopes = @('Organization.Read.All') + if ($containerLabelsInScope) { $graphScopes += 'Directory.ReadWrite.All' } + $connectArgs['GraphScopes'] = $graphScopes +} +if ($AutoInstallModules) { $connectArgs['AutoInstallModules'] = $true } +if ($NonInteractive) { $connectArgs['NonInteractive'] = $true } +# Surface the Copilot DLP module-readiness pre-req only when the AI step is +# in scope. License auto-detect may still flip $BPOnly later, but if the +# operator already passed -BPOnly we can skip the warning at connect time. +if ($aiInScope -and -not $BPOnly) { $connectArgs['AIControlsInScope'] = $true } +$connectionInfo = & $connectScript @connectArgs +if ($connectionInfo -and $connectionInfo.SharePointAdminUrl) { + $SharePointAdminUrl = $connectionInfo.SharePointAdminUrl +} + +# --------------------------------------------------------------------------- +# License auto-detection (drives AI governance + container-labels gating) +# --------------------------------------------------------------------------- +function Get-TenantPurviewLicenseTier { + [CmdletBinding()] + param() + + $result = [pscustomobject]@{ + Tier = 'Unknown' + PartNumbers = @() + Reason = $null + } + + if (-not (Get-Command Invoke-MgGraphRequest -ErrorAction SilentlyContinue)) { + $result.Reason = 'Microsoft.Graph.Authentication module not loaded' + return $result + } + + try { + $resp = Invoke-MgGraphRequest -Method GET -Uri 'https://graph.microsoft.com/v1.0/subscribedSkus' -ErrorAction Stop + } catch { + $result.Reason = "Subscribed-SKUs query failed: $($_.Exception.Message)" + return $result + } + + if (-not $resp -or -not $resp.value) { + $result.Reason = 'Subscribed-SKUs response empty' + return $result + } + + $skus = @($resp.value | Where-Object { $_.capabilityStatus -ne 'Suspended' -and $_.capabilityStatus -ne 'Deleted' }) + $partNumbers = @($skus | ForEach-Object { $_.skuPartNumber } | Where-Object { $_ }) + $result.PartNumbers = $partNumbers + + # Headline SKUs that grant container-label rights (Group.Unified EnableMIPLabels) + # IMPORTANT: SKU part-numbers are exact-match strings, NOT wildcards. When + # Microsoft introduces a new SKU variant (e.g. the EU no-Teams unbundling) + # it gets a new part-number and we must add it here explicitly or the + # tenant gets misclassified as 'Other' and auto-detect skips container + # labels / -BPOnly gets force-enabled. To inventory a tenant's real SKUs: + # Invoke-MgGraphRequest -Method GET -Uri 'https://graph.microsoft.com/v1.0/subscribedSkus' | + # Select-Object -ExpandProperty value | + # Where-Object capabilityStatus -ne 'Suspended' | + # Select-Object skuPartNumber, skuId + $e5Sku = @( + # M365 E5 (full bundle, includes Teams) + 'SPE_E5','SPE_E5_NOPSTNCONF','SPE_E5_CALLINGMINUTES', + 'SPE_E5_USGOV_GCCHIGH', + # M365 E5 no-Teams variants (EU unbundling, May 2024+). + # Teams must be licensed separately via 'Microsoft_Teams_Enterprise_New'. + 'Microsoft_365_E5_(no_Teams)','Microsoft_365_E5_no_Teams','SPE_E5_NOPSTNCONF_no_Teams', + 'Microsoft_365_E5_EEA_(no_Teams)_with_Calling_Minutes', + 'Microsoft_365_E5_EEA_(no_Teams)_without_Audio_Conferencing', + # Office 365 E5 + 'ENTERPRISEPREMIUM','ENTERPRISEPREMIUM_NOPSTNCONF', + # Compliance / Security add-ons (each includes the IPPS container-label rights) + 'INFORMATION_PROTECTION_COMPLIANCE', + 'IDENTITY_THREAT_PROTECTION', + 'M365_E5_SUITE_COMPONENTS', + 'Microsoft_Purview_Suite', + 'INFORMATION_PROTECTION_AND_GOVERNANCE', + 'PURVIEW_SUITE_FOR_BUSINESS_PREMIUM', + 'PURVIEW_SUITE_FOR_BUSINESS_PREMIUM_NEW', + 'DEFENDER_AND_PURVIEW_SUITES_FOR_BUSINESS_PREMIUM', + 'DEFENDER_AND_PURVIEW_SUITES_FOR_BUSINESS_PREMIUM_NEW', + # Education A5 (compliance feature parity with E5) + 'M365EDU_A5_FACULTY','M365EDU_A5_STUDENT','M365EDU_A5_STUUSEBNFT' + ) + $matched = @($partNumbers | Where-Object { $_ -in $e5Sku }) + if ($matched.Count -gt 0) { + $result.Tier = 'E5OrPurviewSuite' + $result.PartNumbers = $matched + return $result + } + + $bpSku = @( + 'SPB','BUSINESS_PREMIUM', + 'Microsoft_365_ Business_ Premium_(no Teams)', + 'Office_365_w/o_Teams_Bundle_Business_Premium', + 'Microsoft_365_Business_Premium_Donation_(Non_Profit_Pricing)' + ) + $bpMatched = @($partNumbers | Where-Object { $_ -in $bpSku }) + if ($bpMatched.Count -gt 0) { + $result.Tier = 'BusinessPremium' + $result.PartNumbers = $bpMatched + return $result + } + + $result.Tier = 'Other' + return $result +} + +if ($wantGraphForAutoDetect) { + Write-Host "`n--- License auto-detect ---" -ForegroundColor White + $tier = Get-TenantPurviewLicenseTier + switch ($tier.Tier) { + 'E5OrPurviewSuite' { + Write-Host (" Detected: Microsoft 365 E5 / Purview Suite (SKU: {0})." -f ($tier.PartNumbers -join ', ')) -ForegroundColor Green + if ($containerLabelsInScope) { + Write-Host " Container labels (Group.Unified EnableMIPLabels): enabled (default)." -ForegroundColor Green + Write-Host " (To opt out, re-run with -SkipContainerLabels.)" -ForegroundColor DarkGray + } + } + 'BusinessPremium' { + Write-Host (" Detected: Microsoft 365 Business Premium (SKU: {0})." -f ($tier.PartNumbers -join ', ')) -ForegroundColor Green + # BP includes Entra ID P1+, which is the AAD-side requirement for + # Group.Unified.EnableMIPLabels (container labels on Teams / M365 + # Groups / SharePoint sites). Microsoft markets container labels + # as an E5 / Purview Suite feature but the tenant switch itself + # works on BP — that's why container labels are default-on for + # the toolkit (BP is the documented licensing floor). + if ($containerLabelsInScope) { + Write-Host " Container labels (Group.Unified EnableMIPLabels): enabled (default)." -ForegroundColor Green + Write-Host " Rationale: BP includes Entra ID P1+, which is the AAD-side requirement for container labels." -ForegroundColor DarkGray + Write-Host " (To opt out, re-run with -SkipContainerLabels.)" -ForegroundColor DarkGray + } + if (-not $BPOnly) { + $BPOnly = $true + Write-Host " Auto-enabling -BPOnly: E5/Purview-Suite-only DLP workloads (Endpoint, MCAS, OnPrem, PowerBI) will be SKIPPED with a warning, not attempted." -ForegroundColor Yellow + Write-Host " (To override, re-run with -NoLicenseAutoDetect.)" -ForegroundColor DarkGray + } + } + 'Other' { + $skuList = if ($tier.PartNumbers) { $tier.PartNumbers -join ', ' } else { '(none)' } + Write-Host (" Tenant SKUs: {0}" -f $skuList) -ForegroundColor DarkGray + # No recognised BP/E5/Purview Suite SKU → we can't trust Entra ID P1 + # is present, so flip container labels OFF to avoid a Graph 403 / + # "tenant lacks required license" failure in step [5/5]. + if ($containerLabelsInScope) { + $SkipContainerLabels = $true + Write-Host " Auto-skipping: Container labels (Group.Unified EnableMIPLabels)." -ForegroundColor Yellow + Write-Host " Rationale: no M365 BP/E5/Purview Suite SKU detected; cannot verify Entra ID P1 (the AAD-side requirement)." -ForegroundColor DarkGray + Write-Host " (To override, re-run with -NoLicenseAutoDetect — assumes the tenant has standalone Entra ID P1 / EMS.)" -ForegroundColor DarkGray + } + if (-not $BPOnly) { + $BPOnly = $true + Write-Host " Auto-enabling -BPOnly (no E5/Purview-Suite SKU detected): E5-only DLP workloads will be SKIPPED with a warning, not attempted." -ForegroundColor Yellow + Write-Host " (To override, re-run with -NoLicenseAutoDetect.)" -ForegroundColor DarkGray + } + } + default { + Write-Host " Could not classify tenant license tier." -ForegroundColor DarkYellow + if ($tier.Reason) { Write-Host " Reason: $($tier.Reason)" -ForegroundColor DarkYellow } + Write-Host " Container labels will run anyway (default); pass -SkipContainerLabels to opt out." -ForegroundColor DarkYellow + } + } +} + +# --------------------------------------------------------------------------- +# Graph scope extension after auto-detect promotion (least-privilege) +# --------------------------------------------------------------------------- +# Graph scope is now requested UPFRONT (Directory.ReadWrite.All is included +# in the first Connect-MgGraph whenever container labels are in scope). +# Previously this block extended consent AFTER license auto-detect flipped +# $EnableContainerLabels — that path no longer exists (container labels are +# default-on for everyone, license auto-detect only flips them OFF for the +# 'Other' tier). Block removed; nothing to do here. + +# --------------------------------------------------------------------------- +# Tenant identity confirmation +# --------------------------------------------------------------------------- +# After connect succeeds, resolve the ACTUAL tenant we landed in and confirm +# it matches what the user implied via -TenantAdminUpn / -DelegatedOrganization. +# Catches the classic "I thought I was on tenant A but my last interactive +# sign-in was on tenant B" disaster before any destructive change runs. +Write-Host "`n--- Tenant identity confirmation ---" -ForegroundColor White +$tenantIdentity = Get-PurviewTenantIdentity +$expectedMatch = Test-PurviewExpectedTenantMatch -Identity $tenantIdentity ` + -TenantAdminUpn $TenantAdminUpn ` + -DelegatedOrganization $DelegatedOrganization + +$idTenantId = if ($tenantIdentity.TenantId) { $tenantIdentity.TenantId } else { '(not resolved)' } +$idDisplayName = if ($tenantIdentity.DisplayName) { $tenantIdentity.DisplayName } else { '(not resolved)' } +$idDefaultDomain = if ($tenantIdentity.DefaultDomain) { $tenantIdentity.DefaultDomain } else { '(not resolved)' } +$idInitialDomain = if ($tenantIdentity.InitialDomain) { $tenantIdentity.InitialDomain } else { '(not resolved)' } +$idUpnSuffix = if ($TenantAdminUpn -match '@(.+)$') { $Matches[1] } else { '(unknown)' } +$idGdap = if ($DelegatedOrganization) { $DelegatedOrganization } else { '(none)' } + +$idBanner = @" + Connected tenant (live from $($tenantIdentity.Source)): + Display name : $idDisplayName + Default domain : $idDefaultDomain + Initial domain : $idInitialDomain + Tenant ID : $idTenantId + + Expected (from arguments): + UPN suffix : $idUpnSuffix + GDAP target : $idGdap +"@ +Write-Host $idBanner -ForegroundColor Cyan + +if ($expectedMatch.Match) { + Write-Host " [OK] Identity matches expected: $($expectedMatch.Reason)" -ForegroundColor Green +} else { + Write-Host " [!!] IDENTITY MISMATCH: $($expectedMatch.Reason)" -ForegroundColor Red + if ($NonInteractive) { + throw "Tenant identity mismatch (running with -NonInteractive). Aborting before any destructive change.`n Expected (from $($expectedMatch.Source)): $($expectedMatch.Expected)`n Connected tenant verified domains: $($tenantIdentity.AllDomains -join ', ')" + } +} + +if (-not $WhatIfPreference -and -not $NonInteractive) { + $tenantLabel = if ($tenantIdentity.DisplayName -and $tenantIdentity.DefaultDomain) { + "$($tenantIdentity.DisplayName) ($($tenantIdentity.DefaultDomain))" + } elseif ($tenantIdentity.DefaultDomain) { + $tenantIdentity.DefaultDomain + } else { + '(unidentified tenant)' + } + $confirm = Read-Host "`nConfirm: deploy to '$tenantLabel'? [y/N]" + if ($confirm -notmatch '^[yY]') { + Write-Host "Deployment cancelled at tenant identity check." -ForegroundColor Yellow + return + } +} + +# --------------------------------------------------------------------------- +# Run tasks in order +# --------------------------------------------------------------------------- +# Wrap the entire run-tasks block in try/finally so the +# deployment summary ALWAYS prints, even when one of the modules throws a +# terminating error mid-run. Operators need to know what got done before the +# crash; losing the summary is worse than the crash itself. +$summary = [ordered]@{} +try { + +if (-not $SkipTenantSettings) { + Write-Host "`n--- [1/5] Tenant settings ---" -ForegroundColor White + $_taskSw = [System.Diagnostics.Stopwatch]::StartNew() + Add-RunLogEntry -Module 'Setup-TenantSettings' -Action 'Module start' -Status 'Started' + try { + $taskArgs = @{ Config = $config } + if ($SkipContainerLabels) { $taskArgs['SkipContainerLabels'] = $true } + if ($EnablePremiumAudit) { $taskArgs['EnablePremiumAudit'] = $true; $taskArgs['PremiumAuditMailbox'] = $PremiumAuditMailbox } + if ($NonInteractive) { $taskArgs['NonInteractive'] = $true } + & $tenantScript @taskArgs + $summary['Tenant settings'] = 'OK' + $_taskSw.Stop() + Add-RunLogEntry -Module 'Setup-TenantSettings' -Action 'Module complete' -Status 'Succeeded' -ElapsedMs ([int]$_taskSw.ElapsedMilliseconds) + } catch { + $summary['Tenant settings'] = "FAILED: $($_.Exception.Message)" + $_taskSw.Stop() + Add-RunLogEntry -Module 'Setup-TenantSettings' -Action 'Module complete' -Status 'Failed' -ElapsedMs ([int]$_taskSw.ElapsedMilliseconds) -Detail $_.Exception.Message + Write-Error $_ + } +} else { + $summary['Tenant settings'] = 'Skipped' + Add-RunLogEntry -Module 'Setup-TenantSettings' -Action 'Module' -Status 'Skipped' -Detail '-SkipTenantSettings was set' +} + +if (-not $SkipLabels) { + Write-Host "`n--- [2/5] Sensitivity labels ---" -ForegroundColor White + $_taskSw = [System.Diagnostics.Stopwatch]::StartNew() + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Module start' -Status 'Started' + try { + $taskArgs = @{ Config = $config } + if ($AdoptExisting) { $taskArgs['AdoptExisting'] = $true } + if ($tenantIdentity) { $taskArgs['TenantIdentity'] = $tenantIdentity } + if ($SkipContainerLabels) { $taskArgs['SkipContainerLabels'] = $true } + & $labelsScript @taskArgs + $summary['Sensitivity labels'] = 'OK' + $_taskSw.Stop() + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Module complete' -Status 'Succeeded' -ElapsedMs ([int]$_taskSw.ElapsedMilliseconds) + } catch { + $summary['Sensitivity labels'] = "FAILED: $($_.Exception.Message)" + $_taskSw.Stop() + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Module complete' -Status 'Failed' -ElapsedMs ([int]$_taskSw.ElapsedMilliseconds) -Detail $_.Exception.Message + Write-Error $_ + } +} else { + $summary['Sensitivity labels'] = 'Skipped' + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Module' -Status 'Skipped' -Detail '-SkipLabels was set' +} + +if (-not $SkipDLP) { + Write-Host "`n--- [3/5] DLP policies ---" -ForegroundColor White + $_taskSw = [System.Diagnostics.Stopwatch]::StartNew() + Add-RunLogEntry -Module 'Setup-DLP' -Action 'Module start' -Status 'Started' + try { + $taskArgs = @{ Config = $config } + if ($AdoptExisting) { $taskArgs['AdoptExisting'] = $true } + if ($BPOnly) { $taskArgs['BPOnly'] = $true } + & $dlpScript @taskArgs + $summary['DLP policies'] = 'OK' + $_taskSw.Stop() + Add-RunLogEntry -Module 'Setup-DLP' -Action 'Module complete' -Status 'Succeeded' -ElapsedMs ([int]$_taskSw.ElapsedMilliseconds) + } catch { + $summary['DLP policies'] = "FAILED: $($_.Exception.Message)" + $_taskSw.Stop() + Add-RunLogEntry -Module 'Setup-DLP' -Action 'Module complete' -Status 'Failed' -ElapsedMs ([int]$_taskSw.ElapsedMilliseconds) -Detail $_.Exception.Message + Write-Error $_ + } +} else { + $summary['DLP policies'] = 'Skipped' + Add-RunLogEntry -Module 'Setup-DLP' -Action 'Module' -Status 'Skipped' -Detail '-SkipDLP was set' +} + +if ($ApplyRetention) { + Write-Host "`n--- [4/5] Retention ---" -ForegroundColor White + $_taskSw = [System.Diagnostics.Stopwatch]::StartNew() + Add-RunLogEntry -Module 'Setup-Retention' -Action 'Module start' -Status 'Started' + try { + $taskArgs = @{ Config = $config } + if ($AdoptExisting) { $taskArgs['AdoptExisting'] = $true } + & $retentionScript @taskArgs + $summary['Retention'] = 'OK' + $_taskSw.Stop() + Add-RunLogEntry -Module 'Setup-Retention' -Action 'Module complete' -Status 'Succeeded' -ElapsedMs ([int]$_taskSw.ElapsedMilliseconds) + } catch { + $summary['Retention'] = "FAILED: $($_.Exception.Message)" + $_taskSw.Stop() + Add-RunLogEntry -Module 'Setup-Retention' -Action 'Module complete' -Status 'Failed' -ElapsedMs ([int]$_taskSw.ElapsedMilliseconds) -Detail $_.Exception.Message + Write-Error $_ + } +} else { + $summary['Retention'] = 'Skipped (opt-in — pass -ApplyRetention to enable; see docs/Retention-Default-Risk.md)' + Add-RunLogEntry -Module 'Setup-Retention' -Action 'Module' -Status 'Skipped' -Detail '-ApplyRetention not set (opt-in)' +} + +# AI governance is now default-on for E5 / Purview Suite tenants. +# Skip paths: +# * -SkipAIControls — explicit operator opt-out +# * $BPOnly — auto-detected Business Premium (Copilot DLP policy plane +# is an E5 / Purview Suite feature); we never call the module on BP +# When neither applies we run it. The module itself ALSO honours -BPOnly +# as a defense-in-depth guard for direct callers (the orchestrator never +# passes -BPOnly on this path because the gate above already excluded BP). +Write-Host "`n--- [5/5] AI governance (Copilot DLP) ---" -ForegroundColor White +if (-not $SkipAIControls -and -not $BPOnly) { + $_taskSw = [System.Diagnostics.Stopwatch]::StartNew() + Add-RunLogEntry -Module 'Setup-AIGovernance' -Action 'Module start' -Status 'Started' + try { + $taskArgs = @{ Config = $config } + if ($AdoptExisting) { $taskArgs['AdoptExisting'] = $true } + & $aiScript @taskArgs + $summary['AI governance'] = 'OK' + $_taskSw.Stop() + Add-RunLogEntry -Module 'Setup-AIGovernance' -Action 'Module complete' -Status 'Succeeded' -ElapsedMs ([int]$_taskSw.ElapsedMilliseconds) + } catch { + $summary['AI governance'] = "FAILED: $($_.Exception.Message)" + $_taskSw.Stop() + Add-RunLogEntry -Module 'Setup-AIGovernance' -Action 'Module complete' -Status 'Failed' -ElapsedMs ([int]$_taskSw.ElapsedMilliseconds) -Detail $_.Exception.Message + Write-Error $_ + } +} elseif ($SkipAIControls) { + Write-Host " Skipped (-SkipAIControls)." -ForegroundColor DarkGray + Write-Host " Microsoft 365 Copilot DLP policies in PurviewConfig.psd1 -> AIGovernance" -ForegroundColor DarkGray + Write-Host " will NOT be created or updated this run." -ForegroundColor DarkGray + $summary['AI governance'] = 'Skipped (-SkipAIControls)' + Add-RunLogEntry -Module 'Setup-AIGovernance' -Action 'Module' -Status 'Skipped' -Detail '-SkipAIControls was set' +} else { + # $BPOnly path (either explicit -BPOnly or auto-detected Business Premium). + Write-Host " Skipped (E5 / Purview Suite required; tenant is Business Premium)." -ForegroundColor DarkGray + Write-Host " Copilot DLP policies are part of the E5 / Purview Suite plane and" -ForegroundColor DarkGray + Write-Host " cannot be created on Business Premium. Upgrade the SKU or omit" -ForegroundColor DarkGray + Write-Host " -BPOnly to re-evaluate." -ForegroundColor DarkGray + $summary['AI governance'] = 'Skipped (E5 / Purview Suite required; Business Premium tenant)' + Add-RunLogEntry -Module 'Setup-AIGovernance' -Action 'Module' -Status 'Skipped' -Detail '-BPOnly was set (Copilot DLP requires E5 / Purview Suite)' +} + +} finally { + # --------------------------------------------------------------------------- + # Summary (always runs — even if a task threw a terminating error above). + # --------------------------------------------------------------------------- + Write-Host "`n==============================================================================" -ForegroundColor Cyan + Write-Host " Deployment summary" -ForegroundColor Cyan + Write-Host "==============================================================================" -ForegroundColor Cyan + if ($summary.Count -eq 0) { + Write-Host " (No tasks recorded — run aborted before any step started.)" -ForegroundColor DarkYellow + } else { + $summary.GetEnumerator() | ForEach-Object { + $color = switch -Wildcard ($_.Value) { + 'OK' { 'Green' } + 'Skipped*' { 'DarkGray' } + 'FAILED:*' { 'Red' } + default { 'White' } + } + Write-Host (" {0,-22} {1}" -f $_.Key, $_.Value) -ForegroundColor $color + } + } + Write-Host "==============================================================================" -ForegroundColor Cyan + + Write-Host "`nReminder: sensitivity-label and DLP changes can take up to 24 hours to fully propagate." -ForegroundColor DarkYellow + + # --------------------------------------------------------------------------- + # HTML report (Tier 1 + Tier 2) — emitted AFTER the CLI summary so a + # renderer failure can never hide the console output. Suppress with + # -NoReport. + # --------------------------------------------------------------------------- + if (-not $NoReport) { + try { + . (Join-Path $PSScriptRoot 'Modules\Write-PurviewHtmlReport.ps1') + + $resolvedReportPath = if ($ReportPath) { + $ReportPath + } else { + # Default: drop the report in the caller's working directory + # (where they ran the script from), NOT $PSScriptRoot. Operators + # expect output next to where they invoked the tool, and the + # install folder may be read-only on managed devices. + Join-Path (Get-Location).Path ("Deploy-PurviewBestPractice-Report-{0}.html" -f $script:StartTime.ToString('yyyyMMdd-HHmmss')) + } + + $endTime = Get-Date + $reportRunLog = Get-PurviewRunLog + + $reportParams = @{ + Summary = $summary + OutputPath = $resolvedReportPath + StartTime = $script:StartTime + EndTime = $endTime + RunId = $script:RunId + Parameters = $PSBoundParameters + ScriptVersion = $script:DeployVersion + } + if ($tenantIdentity) { $reportParams['TenantIdentity'] = $tenantIdentity } + if ($TenantAdminUpn) { $reportParams['TenantAdminUpn'] = $TenantAdminUpn } + if ($reportRunLog -and $reportRunLog.Count -gt 0) { + $reportParams['RunLog'] = $reportRunLog + } + + $written = Write-PurviewHtmlReport @reportParams + Write-Host ("`nHTML report written: {0}" -f $written) -ForegroundColor Cyan + + # JSON sidecar -- same path, .json extension. Machine-readable + # mirror of the run log + summary, handy for auditing or for + # piping into ticketing systems. + try { + $jsonPath = [System.IO.Path]::ChangeExtension($written, '.json') + $tid = if ($tenantIdentity) { [string]$tenantIdentity.TenantId } else { '' } + Save-PurviewRunLogJson ` + -Path $jsonPath ` + -RunId $script:RunId ` + -StartTime $script:StartTime ` + -EndTime $endTime ` + -ScriptVersion $script:DeployVersion ` + -TenantId $tid ` + -TenantAdminUpn $TenantAdminUpn ` + -Summary $summary | Out-Null + Write-Host ("JSON sidecar: {0}" -f $jsonPath) -ForegroundColor Cyan + } catch { + Write-Warning ("JSON sidecar could not be written: {0}" -f $_.Exception.Message) + } + } catch { + # Never throw out of the finally block — the CLI summary already + # printed; a report failure is a usability bug, not a deploy bug. + Write-Warning ("HTML report could not be written: {0}" -f $_.Exception.Message) + } finally { + # Clear $global:PurviewRunLog so it doesn't leak between runs + # in interactive PowerShell sessions. + try { Clear-PurviewRunLog } catch { } + } + } +} diff --git a/Products/Purview/Examples/DLP-Policy-Example.md b/Products/Purview/Examples/DLP-Policy-Example.md index 5f32e9e..d879a24 100644 --- a/Products/Purview/Examples/DLP-Policy-Example.md +++ b/Products/Purview/Examples/DLP-Policy-Example.md @@ -1,223 +1,223 @@ -# DLP Policy Creation Example (SMBTool Purview Toolkit) - -This is the **Microsoft Purview DLP** portion of the SMBTool Purview Best -Practice Toolkit. It creates DLP policies that block external sharing of -content labelled `Confidential` or `Highly Confidential`, per Microsoft's -Business Premium Data Security Best Practice guide, with an additional -**Endpoint DLP** policy that audits device-side activity (E5 / Purview -Suite only). - -Three policies are created by default: - -* **Exchange** — blocks send to external recipients -* **SharePoint + OneDrive** — blocks external sharing/access -* **Endpoint** — audits copy / print / USB / network-share / cloud-sync on - managed devices (E5 only — silently skipped under `-BPOnly`) - -All three policies match content via the **sensitivity label GUID** array -(resolved at runtime from `LabelPaths`), never by display name, so they -cannot collide with similarly-named labels. - -> 🛡️ **DLP starts in simulation mode by default** -> (`DlpStartInSimulation = $true` → `TestWithoutNotifications`). Telemetry -> accrues; nobody is blocked. You **must** explicitly promote out of -> simulation — see -> [`docs/DLP-Simulation-Exit-Runbook.md`](../docs/DLP-Simulation-Exit-Runbook.md) -> for the day-30 procedure. - -The script is **idempotent**: existing toolkit-managed policies/rules -are updated in place; foreign objects abort the run unless -`-AdoptExisting` is supplied. On re-runs the policy **mode is reconciled -against config** — if you flip a policy to Enable in the portal but leave -`DlpStartInSimulation = $true`, the next run pulls it back to simulation. - ---- - -## 1. Configuration (excerpt from `PurviewConfig.psd1`) - -```powershell -@{ - # Marker stamped in object descriptions for safe re-runs and rollback. - ManagedByTag = '[Managed by SMBTool Purview Toolkit]' - - # Master switch: every DLP policy is created in simulation mode - # (TestWithoutNotifications) when this is $true. Telemetry only; no - # user-visible block. Flip to $false to deploy directly into Enable mode. - DlpStartInSimulation = $true - - # ----- DLP policies ----- - # Per-workload, per Microsoft's recommendation. - # - # Workloads supported under Microsoft 365 Business Premium: - # * 'Exchange' - mailboxes - # * 'SharePointOneDrive' - SPO sites + OneDrive for Business - # - # E5 / Purview Suite ONLY (rejected when -BPOnly is set, soft-skipped - # at runtime when the toolkit auto-detects a Business Premium tenant): - # * 'Endpoint' / 'Devices' - Endpoint DLP - # * 'OnPremisesScanner' - on-prem file shares & SP servers - # * 'DefenderForCloudApps' - 3rd party apps via MCAS - # * 'PowerBI' - Power BI tenants - DlpPolicies = @( - @{ - Name = 'SMBTool - DLP - Confidential and HC external (EXO)' - Comment = 'Blocks Exchange messages labelled with any Confidential or Highly Confidential sub-label from being sent outside the organisation.' - Workload = 'Exchange' - RuleName = 'SMBTool - DLP Rule - Confidential and HC - Exchange' - # OR-match across every Confidential + Highly Confidential - # sub-label. Resolved to GUIDs at runtime via Resolve-LabelByPath. - # Single-LabelPath (string) is still accepted for backward compat. - LabelPaths = @( - 'Confidential/AllEmployees' - 'Confidential/ConfidentialSpecificPeople' - 'Confidential/ConfidentialInternalException' - 'HighlyConfidential/HCAllEmps' - 'HighlyConfidential/HCSpecificPeople' - 'HighlyConfidential/HCInternalException' - ) - BlockAccess = $true - # Exchange honours AccessScope=NotInOrganization (set in the script); - # BlockAccessScope is included for UI parity ("Block only people - # outside your organization" radio button). - BlockAccessScope = 'PerUser' - NotifyUser = @('SiteAdmin','LastModifier','Owner') - GenerateIncidentReport = @('SiteAdmin') - } - @{ - Name = 'SMBTool - DLP - Confidential and HC external (SPO+ODB)' - Comment = 'Blocks SharePoint and OneDrive files labelled with any Confidential or Highly Confidential sub-label from being shared externally.' - Workload = 'SharePointOneDrive' - RuleName = 'SMBTool - DLP Rule - Confidential and HC - SPO ODFB' - LabelPaths = @( - 'Confidential/AllEmployees' - 'Confidential/ConfidentialSpecificPeople' - 'Confidential/ConfidentialInternalException' - 'HighlyConfidential/HCAllEmps' - 'HighlyConfidential/HCSpecificPeople' - 'HighlyConfidential/HCInternalException' - ) - BlockAccess = $true - # SPO/ODFB: 'PerUser' = "Block only people outside your organization". - # 'All' would mean "Block everyone" (incl. internal users) — almost - # never what you want. 'PerAnonymousUser' only blocks anonymous link - # recipients (lets B2B guests through). - BlockAccessScope = 'PerUser' - NotifyUser = @('SiteAdmin','LastModifier','Owner') - GenerateIncidentReport = @('SiteAdmin') - } - # ----------------------------------------------------------------- - # Endpoint DLP — E5 / Purview Suite only. - # On Business Premium tenants the toolkit soft-skips this policy - # (warn + continue) so the rest of the DLP step still completes. - # - # WARNING: a deployed Endpoint policy enforces nothing if no devices - # have been onboarded into Microsoft Defender / Purview compliance. - # Confirm onboarding in the Purview portal (Settings → Device - # onboarding) before relying on this policy for protection. - # ----------------------------------------------------------------- - @{ - Name = 'SMBTool - DLP - Endpoint Confidential and HC' - Comment = 'Endpoint DLP - audits copy / print / USB / network-share / cloud-sync actions on managed devices when content is labelled Confidential or Highly Confidential.' - Workload = 'Endpoint' - RuleName = 'SMBTool - DLP Rule - Endpoint Confidential and HC' - LabelPaths = @( - 'Confidential/AllEmployees' - 'Confidential/ConfidentialSpecificPeople' - 'Confidential/ConfidentialInternalException' - 'HighlyConfidential/HCAllEmps' - 'HighlyConfidential/HCSpecificPeople' - 'HighlyConfidential/HCInternalException' - ) - # Endpoint device-action restrictions. Default 'Audit' for every - # action so simulation produces telemetry without interrupting - # users. Flip individual entries to 'Block' / 'BlockOverride' / - # 'Warn' after reviewing telemetry in DLP Activity Explorer. - # - # Valid Setting values (case-sensitive — these are the short - # forms the cmdlet accepts): - # Print, CopyPaste, ScreenCapture, RemovableMedia, NetworkShare, - # UnallowedApps, CloudEgress, UnallowedBluetoothTransferApps, - # RemoteDesktopServices, WebPagePrint, WebPageCopyPaste, - # WebPageSaveToLocal, PasteToBrowser, AccessByAnyAppDefault, - # UnallowedFtpTransferApps. - # Valid Value values: Audit, Block, Warn, BlockOverride. - EndpointDlpRestrictions = @( - @{ Setting = 'CopyPaste'; Value = 'Audit' } - @{ Setting = 'Print'; Value = 'Audit' } - @{ Setting = 'RemovableMedia'; Value = 'Audit' } - @{ Setting = 'NetworkShare'; Value = 'Audit' } - ) - EnforcePortalAccess = $true - ReportSeverityLevel = 'Medium' - GenerateAlert = $true - NotifyUser = @() - GenerateIncidentReport = @() - } - ) -} -``` - ---- - -## 2. How it's invoked - -The orchestrator calls the DLP module with the loaded config hashtable: - -```powershell -# From Deploy-PurviewBestPractice.ps1 -$Config = Import-PowerShellDataFile -Path .\Config\PurviewConfig.psd1 - -# Connect to Security & Compliance Center (IPPS) first -Connect-IPPSSession -UserPrincipalName admin@contoso.onmicrosoft.com - -# Dry-run first (always) -.\Modules\Setup-DLP.ps1 -Config $Config -BPOnly -WhatIf - -# Apply — on a Business Premium tenant -.\Modules\Setup-DLP.ps1 -Config $Config -BPOnly - -# Apply — on an E5 / Purview Suite tenant (Endpoint DLP policy is created) -.\Modules\Setup-DLP.ps1 -Config $Config -``` - -In practice you'd run the orchestrator (`Deploy-PurviewBestPractice.ps1`) -which handles license auto-detect (auto-setting `-BPOnly` when the tenant -is Business Premium) and forwards the right flags to this module. - ---- - -## 3. Behaviour summary - -| Aspect | Decision | Rationale | -|---|---|---| -| **Per-workload policies** | One for Exchange, one for SPO+ODFB, one for Endpoint (E5) | Microsoft's own recommendation; mixing workloads complicates rule scoping | -| **Match by label GUID** | `Resolve-LabelByPath` → `$label.Guid`, OR-matched across `LabelPaths` | Display names can collide; GUIDs are unambiguous | -| **`LabelPaths` (array) preferred** | `LabelPath` (single string) still accepted | Forward-compatible config; lets a single rule cover all Confidential + HC sub-labels | -| **Simulation by default** | `DlpStartInSimulation = $true` → `TestWithoutNotifications` mode | Zero user impact on day one; explicit promotion required at day 30 | -| **Mode reconciliation** | Re-runs pull policy mode back to whatever `DlpStartInSimulation` says | Prevents config drift; flip the config flag to promote, don't rely on portal-only edits | -| **Idempotency** | `Test-Owned` checks `[Managed by SMBTool Purview Toolkit]` tag in `Comment` | Safe to re-run; foreign objects need explicit `-AdoptExisting` | -| **64-char name limit** | Validated upfront | IPPS rejects longer names with an empty error; we surface a clear message | -| **`-BPOnly` guard** | Hard-rejects E5-only workloads (Endpoint, MCAS, OnPrem, PowerBI) | Prevents partners from creating policies their tenant can't enforce | -| **License auto-detect soft-skip** | When the toolkit detects a BP tenant and auto-sets `-BPOnly`, E5-only policies are warned and skipped (not thrown) | Other DLP policies still deploy; the run doesn't crash mid-step | -| **`AccessScope = 'NotInOrganization'`** | Exchange + SPO+ODFB | The "external" condition — only fires for outside-org recipients/sharers | -| **`BlockAccessScope = 'PerUser'`** | Exchange + SPO+ODFB | "Block only people outside your organization" — internal sharing is untouched | -| **Endpoint actions default to `Audit`** | Per-action — `CopyPaste`, `Print`, `RemovableMedia`, `NetworkShare` | Telemetry without user friction; promote per action once Activity Explorer shows expected behaviour | - ---- - -## 4. Cmdlets used (Security & Compliance PowerShell / IPPS) - -* `Get-DlpCompliancePolicy` / `New-DlpCompliancePolicy` / `Set-DlpCompliancePolicy` -* `Get-DlpComplianceRule` / `New-DlpComplianceRule` / `Set-DlpComplianceRule` -* `Get-Label` (to resolve label name → GUID) - -Connection: `Connect-IPPSSession` (ExchangeOnlineManagement module). - ---- - -## 5. Related reading - -* [`docs/Scenarios.md`](../docs/Scenarios.md) — Scenario 3 explains DLP at a higher level for non-PowerShell audiences. -* [`docs/Change-Management-Playbook.md`](../docs/Change-Management-Playbook.md) — pre-deploy / day-of / day 1–5 / day-30 wrapper around this technical artefact. -* [`docs/DLP-Simulation-Exit-Runbook.md`](../docs/DLP-Simulation-Exit-Runbook.md) — how to pull the "what would have leaked" report from Activity Explorer before promoting out of simulation. -* [`Modules/Setup-DLP.ps1`](../Modules/Setup-DLP.ps1) — the actual implementation (do not paste from this example; the example is for orientation only). +# DLP Policy Creation Example (SMBTool Purview Toolkit) + +This is the **Microsoft Purview DLP** portion of the SMBTool Purview Best +Practice Toolkit. It creates DLP policies that block external sharing of +content labelled `Confidential` or `Highly Confidential`, per Microsoft's +Business Premium Data Security Best Practice guide, with an additional +**Endpoint DLP** policy that audits device-side activity (E5 / Purview +Suite only). + +Three policies are created by default: + +* **Exchange** — blocks send to external recipients +* **SharePoint + OneDrive** — blocks external sharing/access +* **Endpoint** — audits copy / print / USB / network-share / cloud-sync on + managed devices (E5 only — silently skipped under `-BPOnly`) + +All three policies match content via the **sensitivity label GUID** array +(resolved at runtime from `LabelPaths`), never by display name, so they +cannot collide with similarly-named labels. + +> 🛡️ **DLP starts in simulation mode by default** +> (`DlpStartInSimulation = $true` → `TestWithoutNotifications`). Telemetry +> accrues; nobody is blocked. You **must** explicitly promote out of +> simulation — see +> [`docs/DLP-Simulation-Exit-Runbook.md`](../docs/DLP-Simulation-Exit-Runbook.md) +> for the day-30 procedure. + +The script is **idempotent**: existing toolkit-managed policies/rules +are updated in place; foreign objects abort the run unless +`-AdoptExisting` is supplied. On re-runs the policy **mode is reconciled +against config** — if you flip a policy to Enable in the portal but leave +`DlpStartInSimulation = $true`, the next run pulls it back to simulation. + +--- + +## 1. Configuration (excerpt from `PurviewConfig.psd1`) + +```powershell +@{ + # Marker stamped in object descriptions for safe re-runs and rollback. + ManagedByTag = '[Managed by SMBTool Purview Toolkit]' + + # Master switch: every DLP policy is created in simulation mode + # (TestWithoutNotifications) when this is $true. Telemetry only; no + # user-visible block. Flip to $false to deploy directly into Enable mode. + DlpStartInSimulation = $true + + # ----- DLP policies ----- + # Per-workload, per Microsoft's recommendation. + # + # Workloads supported under Microsoft 365 Business Premium: + # * 'Exchange' - mailboxes + # * 'SharePointOneDrive' - SPO sites + OneDrive for Business + # + # E5 / Purview Suite ONLY (rejected when -BPOnly is set, soft-skipped + # at runtime when the toolkit auto-detects a Business Premium tenant): + # * 'Endpoint' / 'Devices' - Endpoint DLP + # * 'OnPremisesScanner' - on-prem file shares & SP servers + # * 'DefenderForCloudApps' - 3rd party apps via MCAS + # * 'PowerBI' - Power BI tenants + DlpPolicies = @( + @{ + Name = 'SMBTool - DLP - Confidential and HC external (EXO)' + Comment = 'Blocks Exchange messages labelled with any Confidential or Highly Confidential sub-label from being sent outside the organisation.' + Workload = 'Exchange' + RuleName = 'SMBTool - DLP Rule - Confidential and HC - Exchange' + # OR-match across every Confidential + Highly Confidential + # sub-label. Resolved to GUIDs at runtime via Resolve-LabelByPath. + # Single-LabelPath (string) is still accepted for backward compat. + LabelPaths = @( + 'Confidential/AllEmployees' + 'Confidential/ConfidentialSpecificPeople' + 'Confidential/ConfidentialInternalException' + 'HighlyConfidential/HCAllEmps' + 'HighlyConfidential/HCSpecificPeople' + 'HighlyConfidential/HCInternalException' + ) + BlockAccess = $true + # Exchange honours AccessScope=NotInOrganization (set in the script); + # BlockAccessScope is included for UI parity ("Block only people + # outside your organization" radio button). + BlockAccessScope = 'PerUser' + NotifyUser = @('SiteAdmin','LastModifier','Owner') + GenerateIncidentReport = @('SiteAdmin') + } + @{ + Name = 'SMBTool - DLP - Confidential and HC external (SPO+ODB)' + Comment = 'Blocks SharePoint and OneDrive files labelled with any Confidential or Highly Confidential sub-label from being shared externally.' + Workload = 'SharePointOneDrive' + RuleName = 'SMBTool - DLP Rule - Confidential and HC - SPO ODFB' + LabelPaths = @( + 'Confidential/AllEmployees' + 'Confidential/ConfidentialSpecificPeople' + 'Confidential/ConfidentialInternalException' + 'HighlyConfidential/HCAllEmps' + 'HighlyConfidential/HCSpecificPeople' + 'HighlyConfidential/HCInternalException' + ) + BlockAccess = $true + # SPO/ODFB: 'PerUser' = "Block only people outside your organization". + # 'All' would mean "Block everyone" (incl. internal users) — almost + # never what you want. 'PerAnonymousUser' only blocks anonymous link + # recipients (lets B2B guests through). + BlockAccessScope = 'PerUser' + NotifyUser = @('SiteAdmin','LastModifier','Owner') + GenerateIncidentReport = @('SiteAdmin') + } + # ----------------------------------------------------------------- + # Endpoint DLP — E5 / Purview Suite only. + # On Business Premium tenants the toolkit soft-skips this policy + # (warn + continue) so the rest of the DLP step still completes. + # + # WARNING: a deployed Endpoint policy enforces nothing if no devices + # have been onboarded into Microsoft Defender / Purview compliance. + # Confirm onboarding in the Purview portal (Settings → Device + # onboarding) before relying on this policy for protection. + # ----------------------------------------------------------------- + @{ + Name = 'SMBTool - DLP - Endpoint Confidential and HC' + Comment = 'Endpoint DLP - audits copy / print / USB / network-share / cloud-sync actions on managed devices when content is labelled Confidential or Highly Confidential.' + Workload = 'Endpoint' + RuleName = 'SMBTool - DLP Rule - Endpoint Confidential and HC' + LabelPaths = @( + 'Confidential/AllEmployees' + 'Confidential/ConfidentialSpecificPeople' + 'Confidential/ConfidentialInternalException' + 'HighlyConfidential/HCAllEmps' + 'HighlyConfidential/HCSpecificPeople' + 'HighlyConfidential/HCInternalException' + ) + # Endpoint device-action restrictions. Default 'Audit' for every + # action so simulation produces telemetry without interrupting + # users. Flip individual entries to 'Block' / 'BlockOverride' / + # 'Warn' after reviewing telemetry in DLP Activity Explorer. + # + # Valid Setting values (case-sensitive — these are the short + # forms the cmdlet accepts): + # Print, CopyPaste, ScreenCapture, RemovableMedia, NetworkShare, + # UnallowedApps, CloudEgress, UnallowedBluetoothTransferApps, + # RemoteDesktopServices, WebPagePrint, WebPageCopyPaste, + # WebPageSaveToLocal, PasteToBrowser, AccessByAnyAppDefault, + # UnallowedFtpTransferApps. + # Valid Value values: Audit, Block, Warn, BlockOverride. + EndpointDlpRestrictions = @( + @{ Setting = 'CopyPaste'; Value = 'Audit' } + @{ Setting = 'Print'; Value = 'Audit' } + @{ Setting = 'RemovableMedia'; Value = 'Audit' } + @{ Setting = 'NetworkShare'; Value = 'Audit' } + ) + EnforcePortalAccess = $true + ReportSeverityLevel = 'Medium' + GenerateAlert = $true + NotifyUser = @() + GenerateIncidentReport = @() + } + ) +} +``` + +--- + +## 2. How it's invoked + +The orchestrator calls the DLP module with the loaded config hashtable: + +```powershell +# From Deploy-PurviewBestPractice.ps1 +$Config = Import-PowerShellDataFile -Path .\Config\PurviewConfig.psd1 + +# Connect to Security & Compliance Center (IPPS) first +Connect-IPPSSession -UserPrincipalName admin@contoso.onmicrosoft.com + +# Dry-run first (always) +.\Modules\Setup-DLP.ps1 -Config $Config -BPOnly -WhatIf + +# Apply — on a Business Premium tenant +.\Modules\Setup-DLP.ps1 -Config $Config -BPOnly + +# Apply — on an E5 / Purview Suite tenant (Endpoint DLP policy is created) +.\Modules\Setup-DLP.ps1 -Config $Config +``` + +In practice you'd run the orchestrator (`Deploy-PurviewBestPractice.ps1`) +which handles license auto-detect (auto-setting `-BPOnly` when the tenant +is Business Premium) and forwards the right flags to this module. + +--- + +## 3. Behaviour summary + +| Aspect | Decision | Rationale | +|---|---|---| +| **Per-workload policies** | One for Exchange, one for SPO+ODFB, one for Endpoint (E5) | Microsoft's own recommendation; mixing workloads complicates rule scoping | +| **Match by label GUID** | `Resolve-LabelByPath` → `$label.Guid`, OR-matched across `LabelPaths` | Display names can collide; GUIDs are unambiguous | +| **`LabelPaths` (array) preferred** | `LabelPath` (single string) still accepted | Forward-compatible config; lets a single rule cover all Confidential + HC sub-labels | +| **Simulation by default** | `DlpStartInSimulation = $true` → `TestWithoutNotifications` mode | Zero user impact on day one; explicit promotion required at day 30 | +| **Mode reconciliation** | Re-runs pull policy mode back to whatever `DlpStartInSimulation` says | Prevents config drift; flip the config flag to promote, don't rely on portal-only edits | +| **Idempotency** | `Test-Owned` checks `[Managed by SMBTool Purview Toolkit]` tag in `Comment` | Safe to re-run; foreign objects need explicit `-AdoptExisting` | +| **64-char name limit** | Validated upfront | IPPS rejects longer names with an empty error; we surface a clear message | +| **`-BPOnly` guard** | Hard-rejects E5-only workloads (Endpoint, MCAS, OnPrem, PowerBI) | Prevents partners from creating policies their tenant can't enforce | +| **License auto-detect soft-skip** | When the toolkit detects a BP tenant and auto-sets `-BPOnly`, E5-only policies are warned and skipped (not thrown) | Other DLP policies still deploy; the run doesn't crash mid-step | +| **`AccessScope = 'NotInOrganization'`** | Exchange + SPO+ODFB | The "external" condition — only fires for outside-org recipients/sharers | +| **`BlockAccessScope = 'PerUser'`** | Exchange + SPO+ODFB | "Block only people outside your organization" — internal sharing is untouched | +| **Endpoint actions default to `Audit`** | Per-action — `CopyPaste`, `Print`, `RemovableMedia`, `NetworkShare` | Telemetry without user friction; promote per action once Activity Explorer shows expected behaviour | + +--- + +## 4. Cmdlets used (Security & Compliance PowerShell / IPPS) + +* `Get-DlpCompliancePolicy` / `New-DlpCompliancePolicy` / `Set-DlpCompliancePolicy` +* `Get-DlpComplianceRule` / `New-DlpComplianceRule` / `Set-DlpComplianceRule` +* `Get-Label` (to resolve label name → GUID) + +Connection: `Connect-IPPSSession` (ExchangeOnlineManagement module). + +--- + +## 5. Related reading + +* [`docs/Scenarios.md`](../docs/Scenarios.md) — Scenario 3 explains DLP at a higher level for non-PowerShell audiences. +* [`docs/Change-Management-Playbook.md`](../docs/Change-Management-Playbook.md) — pre-deploy / day-of / day 1–5 / day-30 wrapper around this technical artefact. +* [`docs/DLP-Simulation-Exit-Runbook.md`](../docs/DLP-Simulation-Exit-Runbook.md) — how to pull the "what would have leaked" report from Activity Explorer before promoting out of simulation. +* [`Modules/Setup-DLP.ps1`](../Modules/Setup-DLP.ps1) — the actual implementation (do not paste from this example; the example is for orientation only). diff --git a/Products/Purview/Modules/Connect-PurviewServices.ps1 b/Products/Purview/Modules/Connect-PurviewServices.ps1 index 6b1cba8..2e6b49c 100644 --- a/Products/Purview/Modules/Connect-PurviewServices.ps1 +++ b/Products/Purview/Modules/Connect-PurviewServices.ps1 @@ -1,1624 +1,1624 @@ -#requires -Version 7.0 -<# -.SYNOPSIS - Connects to all Microsoft 365 services required by the Purview Best Practice toolkit. - -.DESCRIPTION - Idempotent connection helper. Detects existing sessions and reuses them. - Supports both standard customer-admin auth and partner GDAP delegated auth via - -DelegatedOrganization. - - Services connected: - * Exchange Online (Connect-ExchangeOnline) - * Security & Compliance (IPPS) (Connect-IPPSSession) - * SharePoint Online (Connect-SPOService) - * Microsoft Graph (Beta) (Connect-MgGraph) — only if -ConnectGraph is passed - - Required PowerShell modules: - * ExchangeOnlineManagement - * Microsoft.Online.SharePoint.PowerShell - * Microsoft.Graph.Beta.Identity.DirectoryManagement (only if -ConnectGraph) - -.PARAMETER TenantAdminUpn - UPN of the customer tenant admin (or partner GDAP admin) used for sign-in. - -.PARAMETER NeedsSharePoint - Connect to SharePoint Online (after Exchange Online). The admin URL is - auto-derived using the following precedence chain: - 1. -SharePointAdminUrl (explicit override) - 2. -DelegatedOrganization (GDAP — tenant primary domain) - 3. Admin UPN suffix (when *.onmicrosoft.com) - 4. EXO Get-AcceptedDomain (final fallback) - -.PARAMETER SharePointAdminUrl - Optional override for the SharePoint admin centre URL - (e.g. https://contoso-admin.sharepoint.com). When omitted, the URL is - auto-derived using the precedence chain described under -NeedsSharePoint. - Pass this explicitly for multi-geo, renamed tenants, or vanity-domain - admin UPNs where the chain cannot resolve. - -.PARAMETER DelegatedOrganization - Customer tenant primary domain (e.g. contoso.onmicrosoft.com) when a partner - is signing in via GDAP. Forwarded to Connect-ExchangeOnline and - Connect-IPPSSession. - -.PARAMETER ConnectGraph - Connect to Microsoft Graph (Beta). Required only when configuring container - labels (Group.Unified EnableMIPLabels). - -.PARAMETER GraphScopes - Delegated Microsoft Graph scopes to request when -ConnectGraph is used. - Defaults to the read-only set 'Organization.Read.All' (sufficient for license - auto-detect via /subscribedSkus and tenant-identity confirm via /organization). - The orchestrator adds 'Directory.ReadWrite.All' on top of this only - when container labels (Group.Unified EnableMIPLabels) will actually be - written, keeping the consented privilege as narrow as the run requires. - NOTE: Directory.ReadWrite.All is the historically-consented, known-working - scope on most tenants for /directorySettingTemplates and /settings. A - narrower scope (GroupSettings.ReadWrite.All) was tried but produced 403 - Authorization_RequestDenied on tenants where admins had only consented - Directory.ReadWrite.All historically. - -.PARAMETER AutoInstallModules - When set, missing PowerShell modules are installed automatically (scope - CurrentUser) without prompting. Without this switch the script prompts - interactively before installing. - -.OUTPUTS - PSCustomObject with the resolved SharePointAdminUrl (when SPO was connected). - -.EXAMPLE - .\Connect-PurviewServices.ps1 -TenantAdminUpn admin@contoso.onmicrosoft.com ` - -NeedsSharePoint - -.EXAMPLE - # Partner GDAP scenario with explicit SPO URL override - .\Connect-PurviewServices.ps1 -TenantAdminUpn partneradmin@fabrikam.onmicrosoft.com ` - -NeedsSharePoint -SharePointAdminUrl https://contoso-admin.sharepoint.com ` - -DelegatedOrganization contoso.onmicrosoft.com -#> -[CmdletBinding()] -param( - [Parameter(Mandatory)] - [string] $TenantAdminUpn, - - [Parameter()] - [switch] $NeedsSharePoint, - - [Parameter()] - [string] $SharePointAdminUrl, - - [Parameter()] - [string] $DelegatedOrganization, - - [Parameter()] - [switch] $ConnectGraph, - - [Parameter()] - [string[]] $GraphScopes = @('Organization.Read.All'), - - [Parameter()] - [switch] $AutoInstallModules, - - [Parameter()] - [switch] $NonInteractive, - - # When set, run the Copilot DLP module-readiness pre-check. Used by the - # orchestrator when the AI governance step is in scope (default-on for - # E5 / Purview Suite tenants); skipped when the operator passed - # -SkipAIControls / -BPOnly so we don't emit noise for runs that won't - # touch the Copilot DLP cmdlets. - [Parameter()] - [switch] $AIControlsInScope -) - -# Minimum ExchangeOnlineManagement version whose IPPS REST proxy exposes the -# Copilot DLP parameters (-Locations / -EnforcementPlanes) reliably. Below -# this version, Setup-AIGovernance's sanity check bails ("module does not -# expose the Copilot DLP parameters") because the dynamically-generated -# New-DlpCompliancePolicy proxy is missing those parameters. This constant -# drives a NON-blocking pre-req warning + HTML report callout — the actual -# hard backstop still lives in Setup-AIGovernance.ps1. -$script:CopilotDlpMinExoVersion = [version]'3.9.0' - -$ErrorActionPreference = 'Stop' - -# Connecting to services is a precondition, not a destructive change. Some -# auth flows (notably Connect-SPOService and Import-Module -UseWindowsPowerShell) -# have internal steps that respect $WhatIfPreference and silently skip work -# under -WhatIf, producing misleading "No valid OAuth 2.0 authentication -# session exists" errors. Force WhatIf off for the duration of this script. -$WhatIfPreference = $false -$ConfirmPreference = 'None' - -# --------------------------------------------------------------------------- -# Run log helper — idempotent dot-source so Add-RunLogEntry is available even -# when this script is invoked standalone (i.e. without the orchestrator). The -# helper itself wraps all writes in try/catch and lazy-inits $global:PurviewRunLog, -# so calling it when no run log was initialised is a silent no-op. -# Same pattern as Invoke-WithTransientRetry.ps1. -# --------------------------------------------------------------------------- -$_purviewRunLogPath = Join-Path $PSScriptRoot 'PurviewRunLog.ps1' -if (Test-Path $_purviewRunLogPath) { - . $_purviewRunLogPath -} - -function Add-ConnectGuardLog { - <# - Emits a structured Add-RunLogEntry for a connect-side session guard - decision. Detail is built as 'reason=; k1=v1; k2=v2; ...' using - stable key names so operators can grep / filter Get-PurviewRunLog - output after the fact. - - Status semantics for this helper: - * Info — guard accepted the cached session OR neutral observation - * Started — about to call Connect-* (cold start or after stale discard) - * Retried — guard rejected the cached session and will reconnect - * Skipped — guard intentionally bypassed (e.g. service disabled by flag) - * Failed — recoverable problem detected by a guard helper - (e.g. Beta version pin self-heal couldn't install / import). - Use 'Failed' (not 'Error') for parity with Add-RunLogEntry. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] [string] $Service, - [Parameter(Mandatory)] - [ValidateSet('Info','Started','Retried','Skipped','Failed')] - [string] $Status, - [Parameter(Mandatory)] [string] $ReasonKey, - [hashtable] $Detail - ) - - if (-not (Get-Command Add-RunLogEntry -ErrorAction SilentlyContinue)) { return } - - $parts = @("reason=$ReasonKey") - if ($Detail) { - foreach ($k in ($Detail.Keys | Sort-Object)) { - $v = $Detail[$k] - if ($null -eq $v) { $v = '' } - $parts += ('{0}={1}' -f $k, $v) - } - } - - try { - Add-RunLogEntry -Module 'Connect-PurviewServices' ` - -Action "SessionGuard:$Service" ` - -Status $Status ` - -Detail ($parts -join '; ') - } catch { - Write-Verbose "Add-ConnectGuardLog failed: $($_.Exception.Message)" - } -} - -function Test-WamBrokerReadiness { - <# - Best-effort readiness check for Windows Authentication Manager (WAM). - Returns a PSCustomObject with: - * Eligible — $true when no blocking issues were found - * Issues — list of short human-readable problem descriptions - * Info — diagnostic hashtable for the run log - - WAM is enabled by default on Windows 10 / Server 2019 and later in current - Microsoft.Graph.Authentication (>= 2.x) and ExchangeOnlineManagement - (>= 3.3). When WAM is active, second-and-subsequent sign-in prompts show - the single-click "Continue" account picker instead of a full browser - sign-in. When unavailable, every service connection falls back to a - browser popup — that is the cause of the "6 prompts per run" symptom. - - Per MS Learn: 'Signin by Web Account Manager (WAM) is enabled by default - on Windows and cannot be disabled' in current modules — so no runtime - DisableLoginByWAM check is performed; we only check environment + module - readiness here. - #> - [CmdletBinding()] - param() - - $issues = @() - $info = [ordered]@{ - wamEligible = $false - psEdition = $PSVersionTable.PSEdition - psVersion = $PSVersionTable.PSVersion.ToString() - isWindows = [bool]$IsWindows - windowsBuild = $null - productType = $null - userInteractive = [Environment]::UserInteractive - runningAsSystem = $false - graphAuthModuleVersion = 'not-installed' - exoModuleVersion = 'not-installed' - } - - if (-not $IsWindows) { - $issues += "Not running on Windows — WAM is a Windows-only broker; every connect call falls back to a browser popup." - return [pscustomobject]@{ Eligible = $false; Issues = $issues; Info = $info } - } - - try { - $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop - $build = [int]($os.BuildNumber) - $productType = [int]$os.ProductType # 1=workstation, 2=DC, 3=server - $info.windowsBuild = $build - $info.productType = $productType - - if ($productType -eq 1 -and $build -lt 10240) { - $issues += "Windows build $build is older than Windows 10 1507 (10240) — WAM not available." - } elseif ($productType -ge 2 -and $build -lt 17763) { - $issues += "Windows Server build $build is older than Server 2019 (17763) — WAM not available." - } - } catch { - # CIM failure is non-fatal; treat as 'unknown', not a blocker. - Write-Verbose "Could not detect Windows build via CIM: $($_.Exception.Message)" - } - - try { - $current = [Security.Principal.WindowsIdentity]::GetCurrent() - if ($current -and $current.IsSystem) { - $info.runningAsSystem = $true - $issues += "Running as SYSTEM — WAM requires a user desktop session; sign-in will fall back to browser." - } - } catch { } - - if (-not [Environment]::UserInteractive) { - $issues += "PowerShell host is not interactive — WAM popup requires an interactive desktop session." - } - - $mgMod = Get-Module Microsoft.Graph.Authentication -ListAvailable -ErrorAction SilentlyContinue | - Sort-Object Version -Descending | Select-Object -First 1 - if ($mgMod) { - $info.graphAuthModuleVersion = $mgMod.Version.ToString() - if ($mgMod.Version.Major -lt 2) { - $issues += "Microsoft.Graph.Authentication v$($mgMod.Version) predates built-in WAM (need 2.x). Update: Update-Module Microsoft.Graph.Authentication" - } - } - - $exoMod = Get-Module ExchangeOnlineManagement -ListAvailable -ErrorAction SilentlyContinue | - Sort-Object Version -Descending | Select-Object -First 1 - if ($exoMod) { - $info.exoModuleVersion = $exoMod.Version.ToString() - if ($exoMod.Version -lt [version]'3.3.0') { - $issues += "ExchangeOnlineManagement v$($exoMod.Version) predates WAM integration (need 3.3+). Update: Update-Module ExchangeOnlineManagement" - } - } - - $info.wamEligible = ($issues.Count -eq 0) - [pscustomobject]@{ - Eligible = $info.wamEligible - Issues = $issues - Info = $info - } -} - -function Test-MsalDllCompatibility { - <# - Detects mismatched Microsoft.Identity.Client.dll (MSAL) versions - bundled by ExchangeOnlineManagement and Microsoft.Graph.Authentication. - - Why this matters - ---------------- - Each module ships its OWN copy of Microsoft.Identity.Client.dll under - its module folder (e.g. ExchangeOnlineManagement\\netCore\ vs - Microsoft.Graph.Authentication\\Dependencies\Core\). When both - modules are imported into the same PowerShell process, the CLR binds - to whichever copy was loaded FIRST and ignores the other. This script - connects to Exchange Online BEFORE Microsoft Graph, so EXO's MSAL - wins the AppDomain. - - If EXO and Graph were compiled against different MSAL versions and a - method signature changed between those versions (a recurring event - in MSAL releases, e.g. WithLogging(IIdentityLogger, Boolean) moving - between AbstractApplicationBuilder and BaseAbstractApplicationBuilder - in 4.83), Connect-MgGraph throws: - - InteractiveBrowserCredential authentication failed: - Method not found: '!0 Microsoft.Identity.Client..(...)'. - - That error reads like a credential failure but is purely a DLL-binding - mismatch. We surface it as a structured pre-flight warning so the - operator knows exactly which two module versions disagree and how to - re-align them BEFORE Connect-MgGraph blows up. - - Returns a PSCustomObject with: - * Compatible — $true when MSAL DLLs are aligned (or only one of the - two modules is installed, so no in-process conflict - is possible from this script) - * Issues — list of short human-readable problem descriptions - * Info — diagnostic hashtable for the run log - #> - [CmdletBinding()] - param() - - $issues = @() - $info = [ordered]@{ - psEdition = $PSVersionTable.PSEdition - exoModuleVersion = $null - exoMsalDllPath = $null - exoMsalVersion = $null - graphModuleVersion = $null - graphMsalDllPath = $null - graphMsalVersion = $null - loadedMsalVersion = $null - loadedMsalLocation = $null - } - - function _findMsalDll([string]$ModuleBase) { - if (-not $ModuleBase -or -not (Test-Path $ModuleBase)) { return $null } - # Prefer the edition-specific subfolder (Core for PS 7, Desktop for - # PS 5.1 / Windows PowerShell). Module layouts vary: - # ExchangeOnlineManagement\\netCore\Microsoft.Identity.Client.dll - # ExchangeOnlineManagement\\netFramework\Microsoft.Identity.Client.dll - # Microsoft.Graph.Authentication\\Dependencies\Core\Microsoft.Identity.Client.dll - # Microsoft.Graph.Authentication\\Dependencies\Desktop\Microsoft.Identity.Client.dll - $isCore = ($PSVersionTable.PSEdition -eq 'Core') - $preferred = if ($isCore) { @('netCore', 'Dependencies\Core', 'Core') } - else { @('netFramework', 'Dependencies\Desktop', 'Desktop') } - foreach ($sub in $preferred) { - $path = Join-Path $ModuleBase $sub - if (Test-Path $path) { - $hit = Get-ChildItem -Path $path -Filter 'Microsoft.Identity.Client.dll' -Recurse -ErrorAction SilentlyContinue | - Select-Object -First 1 - if ($hit) { return $hit } - } - } - # Fallback: any copy under the module base. - Get-ChildItem -Path $ModuleBase -Filter 'Microsoft.Identity.Client.dll' -Recurse -ErrorAction SilentlyContinue | - Select-Object -First 1 - } - - function _toVersion([string]$raw) { - if (-not $raw) { return $null } - $m = [regex]::Match($raw, '^\d+(\.\d+){1,3}') - if ($m.Success) { try { return [version]$m.Value } catch { return $null } } - return $null - } - - $exoMod = Get-Module ExchangeOnlineManagement -ListAvailable -ErrorAction SilentlyContinue | - Sort-Object Version -Descending | Select-Object -First 1 - if ($exoMod) { - $info.exoModuleVersion = $exoMod.Version.ToString() - $dll = _findMsalDll $exoMod.ModuleBase - if ($dll) { - $info.exoMsalDllPath = $dll.FullName - try { $info.exoMsalVersion = (Get-Item $dll.FullName).VersionInfo.FileVersion } catch {} - } - } - - $mgMod = Get-Module Microsoft.Graph.Authentication -ListAvailable -ErrorAction SilentlyContinue | - Sort-Object Version -Descending | Select-Object -First 1 - if ($mgMod) { - $info.graphModuleVersion = $mgMod.Version.ToString() - $dll = _findMsalDll $mgMod.ModuleBase - if ($dll) { - $info.graphMsalDllPath = $dll.FullName - try { $info.graphMsalVersion = (Get-Item $dll.FullName).VersionInfo.FileVersion } catch {} - } - } - - # An MSAL assembly already loaded in this process wins for the rest of - # the session, regardless of which module updates we recommend below. - # .NET cannot swap a loaded assembly in-place — the only remedy is to - # close the PowerShell window and start a fresh one. - try { - $loaded = [System.AppDomain]::CurrentDomain.GetAssemblies() | - Where-Object { $_.GetName().Name -eq 'Microsoft.Identity.Client' } | - Select-Object -First 1 - if ($loaded) { - $info.loadedMsalVersion = $loaded.GetName().Version.ToString() - try { $info.loadedMsalLocation = $loaded.Location } catch {} - } - } catch { } - - if (-not $info.exoMsalVersion -or -not $info.graphMsalVersion) { - # Only one of the two modules is installed (or its MSAL DLL couldn't - # be located). No in-process conflict is possible from this script. - return [pscustomobject]@{ - Compatible = $true - Issues = $issues - Info = $info - } - } - - $exoVer = _toVersion $info.exoMsalVersion - $graphVer = _toVersion $info.graphMsalVersion - - if ($exoVer -and $graphVer -and $exoVer -ne $graphVer) { - # Direction-agnostic: any mismatch is risky because MSAL has shipped - # breaking method-signature changes in both directions across recent - # 4.x releases. The connect helper's calling site decides what to do - # about it — either auto-apply the pre-load workaround (when MSAL is - # not yet loaded in this process) or recommend opening a fresh window. - $issues += ("MSAL DLL mismatch: ExchangeOnlineManagement v{0} ships Microsoft.Identity.Client v{1}, " + - "but Microsoft.Graph.Authentication v{2} ships v{3}. The two modules cannot share an AppDomain " + - "without the older / mismatched MSAL causing Connect-MgGraph to throw 'Method not found' for " + - "an MSAL method whose signature changed between those versions.") -f ` - $info.exoModuleVersion, $exoVer, $info.graphModuleVersion, $graphVer - } - - $loadedVer = _toVersion $info.loadedMsalVersion - if ($loadedVer -and $graphVer -and $loadedVer -ne $graphVer) { - $issues += ("Microsoft.Identity.Client v{0} is ALREADY loaded in this PowerShell process (from '{1}'), " + - "but Microsoft.Graph.Authentication v{2} was built against v{3}. .NET cannot swap a loaded " + - "assembly — close this PowerShell window, open a fresh one, and re-run this script.") -f ` - $loadedVer, $info.loadedMsalLocation, $info.graphModuleVersion, $graphVer - } - - [pscustomobject]@{ - Compatible = ($issues.Count -eq 0) - Issues = $issues - Info = $info - } -} - -function Test-IsAdmin { - try { - $current = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() - return $current.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) - } catch { - return $false - } -} - -function Test-CopilotDlpModuleReadiness { - <# - Checks whether the locally installed ExchangeOnlineManagement module - is recent enough for its IPPS REST proxy to expose the Copilot DLP - parameters (-Locations / -EnforcementPlanes) that - New-DlpCompliancePolicy needs for Microsoft 365 Copilot location - policies. - - Returns a PSCustomObject with: - * Eligible — $true when no blocking issues were found - * Issues — list of short human-readable problem descriptions - * Info — diagnostic hashtable for the run log / HTML report - - This is a NON-blocking pre-req check: it produces a Write-Warning - and a run-log entry. The hard backstop ("does the live IPPS proxy - actually have the parameters?") still lives in Setup-AIGovernance.ps1. - - Why a separate helper from Test-WamBrokerReadiness: - * WAM readiness gates EVERY run (single connect prompt). - * Copilot DLP readiness only matters when the AI governance step - is in scope, so the orchestrator opts in via -AIControlsInScope. - #> - [CmdletBinding()] - param() - - $issues = @() - $info = [ordered]@{ - moduleName = 'ExchangeOnlineManagement' - installed = 'not-installed' - installedPath = $null - minimumRequired = $script:CopilotDlpMinExoVersion.ToString() - meetsMinimum = $false - } - - $exoMod = Get-Module ExchangeOnlineManagement -ListAvailable -ErrorAction SilentlyContinue | - Sort-Object Version -Descending | Select-Object -First 1 - - if (-not $exoMod) { - $issues += "ExchangeOnlineManagement module is not installed. Install: Install-Module ExchangeOnlineManagement -Scope CurrentUser -Force" - return [pscustomobject]@{ Eligible = $false; Issues = $issues; Info = $info } - } - - $info.installed = $exoMod.Version.ToString() - $info.installedPath = $exoMod.ModuleBase - $info.meetsMinimum = ($exoMod.Version -ge $script:CopilotDlpMinExoVersion) - - if (-not $info.meetsMinimum) { - $issues += "ExchangeOnlineManagement v$($exoMod.Version) is older than the recommended minimum (v$($script:CopilotDlpMinExoVersion)) for Microsoft 365 Copilot DLP. The IPPS REST proxy this version builds at connect time does NOT expose the -Locations / -EnforcementPlanes parameters that New-DlpCompliancePolicy needs for Copilot DLP policies, so step [5/5] AI governance will fail. Update: Update-Module ExchangeOnlineManagement -Force (or, in an elevated prompt: Install-Module ExchangeOnlineManagement -Scope AllUsers -Force -AllowClobber). Close and reopen PowerShell after updating." - } - - [pscustomobject]@{ - Eligible = ($issues.Count -eq 0) - Issues = $issues - Info = $info - } -} - -function Test-GraphBetaCompat { - <# - Detects mismatched Microsoft.Graph.Authentication / Microsoft.Graph.Beta.* - module versions BEFORE the orchestrator's container-labels step - (step [5/5]) tries to call Get-MgBetaDirectorySetting and fails with: - - The 'Get-MgBetaDirectorySetting' command was found in the module - 'Microsoft.Graph.Beta.Identity.DirectoryManagement', but the - module could not be loaded due to the following error: - [Could not load file or assembly 'Microsoft.Graph.Authentication, - Version=2.36.1.0...'. Assembly with same name is already loaded] - - Why this matters - ---------------- - Each Microsoft.Graph.Beta.* sub-module's manifest declares a - RequiredModules entry that strict-pins Microsoft.Graph.Authentication - to the EXACT same version (e.g. Beta.Identity.DirectoryManagement - v2.36.1 requires Authentication = 2.36.1.0, not >= 2.36.1.0). When - a newer Microsoft.Graph.Authentication is already loaded — typically - after the operator runs 'Update-Module Microsoft.Graph.Authentication - -Force' to fix an MSAL issue WITHOUT also updating the Beta family — - Import-Module on the Beta sub-module fails because .NET can't load - a second copy of Microsoft.Graph.Authentication into the AppDomain. - - Returns a PSCustomObject with: - * Compatible — $true when the highest installed Beta sub-module's - required Authentication version matches one of the - installed Authentication versions AND the loaded - Authentication version, OR when the Beta sub-module - is not installed (Ensure-RequiredModule will install - a matching version on demand) - * Issues — list of human-readable problem descriptions - * Info — diagnostic hashtable for the run log - * FixCommand — exact PowerShell command to recover - #> - [CmdletBinding()] - param( - [string] $BetaModuleName = 'Microsoft.Graph.Beta.Identity.DirectoryManagement' - ) - - $issues = @() - $info = [ordered]@{ - graphAuthInstalled = @() - graphAuthLoadedVersion = $null - betaModuleName = $BetaModuleName - betaInstalled = @() - betaHighestVersion = $null - betaHighestRequiredAuth = $null - } - $fixCommand = $null - - # Inventory: every installed Microsoft.Graph.Authentication version. - $authMods = @(Get-Module Microsoft.Graph.Authentication -ListAvailable -ErrorAction SilentlyContinue | - Sort-Object Version -Descending) - $info.graphAuthInstalled = @($authMods | ForEach-Object { $_.Version.ToString() }) - - # Whichever Authentication version is currently loaded wins for the rest - # of the session (.NET cannot load a second copy of an assembly). - $loadedAuth = Get-Module Microsoft.Graph.Authentication -ErrorAction SilentlyContinue | - Select-Object -First 1 - if ($loadedAuth) { - $info.graphAuthLoadedVersion = $loadedAuth.Version.ToString() - } - - # Inventory: every installed copy of the Beta sub-module + its pinned Auth dep. - $betaMods = @(Get-Module $BetaModuleName -ListAvailable -ErrorAction SilentlyContinue | - Sort-Object Version -Descending) - foreach ($b in $betaMods) { - $authReq = $b.RequiredModules | Where-Object Name -eq 'Microsoft.Graph.Authentication' | Select-Object -First 1 - $info.betaInstalled += [ordered]@{ - version = $b.Version.ToString() - requiredAuthVer = if ($authReq) { $authReq.Version.ToString() } else { '(none)' } - modulePath = $b.ModuleBase - } - } - - if (-not $betaMods) { - # No Beta sub-module installed at all. Because the orchestrator - # bypasses Ensure-RequiredModule for the Beta module (Ensure has - # a silent-success bug that returns OK when the module is merely - # listed in Get-Module -ListAvailable even if Import-Module would - # fail), we MUST surface this as incompatible here. The caller's - # auto-install branch will then run Install-Module against the - # currently loaded/installed Auth version. Without this signal - # the caller skips install and the picker downstream finds no - # matching Beta version → throws "no-matching-version-installed" - # on a fresh machine where Beta was never installed. - $effectiveAuth = if ($info.graphAuthLoadedVersion) { - $info.graphAuthLoadedVersion - } elseif ($info.graphAuthInstalled.Count -gt 0) { - $info.graphAuthInstalled[0] - } else { - $null - } - if ($effectiveAuth) { - $fixCommand = "Install-Module $BetaModuleName -RequiredVersion $effectiveAuth -Scope CurrentUser -Force -AllowClobber" - } else { - $fixCommand = "Install-Module $BetaModuleName -Scope CurrentUser -Force -AllowClobber" - } - return [pscustomobject]@{ - Compatible = $false - Issues = @("$BetaModuleName is not installed (required for container labels at step [5/5]). Will be installed side-by-side against Microsoft.Graph.Authentication v$effectiveAuth.") - Info = $info - FixCommand = $fixCommand - } - } - - $highestBeta = $betaMods[0] - $info.betaHighestVersion = $highestBeta.Version.ToString() - $highestBetaAuthReq = $highestBeta.RequiredModules | - Where-Object Name -eq 'Microsoft.Graph.Authentication' | - Select-Object -First 1 - if ($highestBetaAuthReq) { - $info.betaHighestRequiredAuth = $highestBetaAuthReq.Version.ToString() - } - - # The check that matters: can Import-Module on the highest Beta succeed - # against the CURRENTLY LOADED Authentication version? - # If Authentication isn't loaded yet, fall back to: does the highest Auth - # installed match what the highest Beta requires? (Import-Module Beta - # will trigger Import-Module Authentication on its required version.) - $effectiveAuth = if ($info.graphAuthLoadedVersion) { - $info.graphAuthLoadedVersion - } elseif ($info.graphAuthInstalled.Count -gt 0) { - $info.graphAuthInstalled[0] - } else { - $null - } - - if (-not $effectiveAuth) { - # No Auth installed at all — Ensure-RequiredModule will handle it. - return [pscustomobject]@{ - Compatible = $true - Issues = @() - Info = $info - FixCommand = $null - } - } - - # PowerShell only honours strict-pinned RequiredModules — version must match exactly. - if ($info.betaHighestRequiredAuth -and $info.betaHighestRequiredAuth -ne $effectiveAuth) { - # See if any OTHER installed Beta version pins to the loaded/installed Auth. - $matchingBeta = $info.betaInstalled | Where-Object { $_.requiredAuthVer -eq $effectiveAuth } | Select-Object -First 1 - if ($matchingBeta) { - # A matching Beta version IS installed — Import-Module would pick the - # highest by default though, so we still need to either pin import or - # tell the user to remove/update. Treat as incompatible with hint. - $issues += "An installed copy of $BetaModuleName v$($matchingBeta.version) pins Microsoft.Graph.Authentication v$effectiveAuth (matches loaded), but the highest installed version v$($info.betaHighestVersion) pins v$($info.betaHighestRequiredAuth). PowerShell's Import-Module picks the highest by default, so the import will still fail." - $fixCommand = "Install-Module $BetaModuleName -RequiredVersion $effectiveAuth -Scope CurrentUser -Force -AllowClobber" - } else { - $issues += "$BetaModuleName v$($info.betaHighestVersion) (the highest installed) strict-pins Microsoft.Graph.Authentication v$($info.betaHighestRequiredAuth), but the loaded/installed Authentication version is v$effectiveAuth. Microsoft.Graph.Beta sub-modules require an EXACT version match in their manifest's RequiredModules. Step [5/5] container labels will fail when it calls Get-MgBetaDirectorySetting." - $fixCommand = "Install-Module $BetaModuleName -RequiredVersion $effectiveAuth -Scope CurrentUser -Force -AllowClobber" - } - } - - [pscustomobject]@{ - Compatible = ($issues.Count -eq 0) - Issues = $issues - Info = $info - FixCommand = $fixCommand - } -} - -function Resolve-SpoAdminUrlFromInputs { - [CmdletBinding()] - param( - [string] $ExplicitUrl, - [string] $DelegatedOrganization, - [string] $TenantAdminUpn - ) - - if ($ExplicitUrl) { - return [pscustomobject]@{ Url = $ExplicitUrl; Source = 'explicit (-SharePointAdminUrl)' } - } - - if ($DelegatedOrganization -and $DelegatedOrganization -match '^(?[A-Za-z0-9-]+)\.onmicrosoft\.com$') { - $prefix = $Matches.t - return [pscustomobject]@{ - Url = "https://$prefix-admin.sharepoint.com" - Source = "-DelegatedOrganization ($DelegatedOrganization)" - } - } - - if ($TenantAdminUpn -and $TenantAdminUpn -match '@(?[A-Za-z0-9-]+)\.onmicrosoft\.com$') { - $prefix = $Matches.t - return [pscustomobject]@{ - Url = "https://$prefix-admin.sharepoint.com" - Source = "admin UPN suffix ($TenantAdminUpn)" - } - } - - return $null -} - -function Invoke-SpoConnectWithFallback { - [CmdletBinding()] - param( - [Parameter(Mandatory)] [string] $SharePointAdminUrl, - [Parameter(Mandatory)] [string] $TenantAdminUpn - ) - - $spoConnected = $false - $spoProbeError = $null - try { - $current = Get-SPOTenant -ErrorAction Stop - if ($current) { $spoConnected = $true } - } catch { - $spoConnected = $false - $spoProbeError = $_.Exception.Message - } - - if ($spoConnected) { - Add-ConnectGuardLog -Service 'SPO' -Status 'Info' -ReasonKey 'reused' -Detail @{ - check = 'Get-SPOTenant' - expectedAdminUrl = $SharePointAdminUrl - actualTenantIdentity = 'unverified' - } - Write-Host "SharePoint Online: existing session reused." -ForegroundColor DarkGray - return - } - - Add-ConnectGuardLog -Service 'SPO' -Status 'Started' ` - -ReasonKey ($(if ($spoProbeError) { 'probe-failed' } else { 'no-existing-session' })) ` - -Detail @{ - check = 'Get-SPOTenant' - expectedAdminUrl = $SharePointAdminUrl - probeError = ($spoProbeError -as [string]) - } - - $spoMod = Get-Module Microsoft.Online.SharePoint.PowerShell -ErrorAction SilentlyContinue | - Select-Object -First 1 - if (-not $spoMod) { - $spoMod = Get-Module Microsoft.Online.SharePoint.PowerShell -ListAvailable | - Sort-Object Version -Descending | Select-Object -First 1 - } - $spoVersion = if ($spoMod -and $spoMod.Version) { $spoMod.Version.ToString() } else { 'unknown' } - $isCore = $PSVersionTable.PSEdition -eq 'Core' - - Write-Host "Connecting to SharePoint Online ($SharePointAdminUrl)..." -ForegroundColor Cyan - Write-Host (" PowerShell: {0} {1} | SPO module: {2}{3}" -f ` - $PSVersionTable.PSEdition, $PSVersionTable.PSVersion, $spoVersion, - $(if ($isCore) { ' (loaded via Windows PowerShell 5.1 proxy)' } else { '' })) -ForegroundColor DarkGray - - # Clear any stale/cached SPO session state before attempting a fresh - # connect — this avoids "No valid OAuth 2.0 authentication session - # exists" caused by a prior failed/expired token. - try { Disconnect-SPOService -ErrorAction SilentlyContinue } catch { } - - try { - Connect-SPOService -Url $SharePointAdminUrl -ErrorAction Stop - return - } catch { - $errMsg = $_.Exception.Message - - # ZT-pattern: detect MSAL DLL conflict — recommend a pwsh restart. - $spoConflict = @($_.Exception, $_.Exception.InnerException) | - Where-Object { $_ -is [System.MissingMethodException] -or $_ -is [System.IO.FileLoadException] } | - Select-Object -First 1 - $isMsalConflict = $spoConflict -and ($spoConflict.Message -like '*Microsoft.Identity.Client*' -or $spoConflict.Message -like '*Microsoft.IdentityModel*') - - # Detect "the PS 5.1 proxy couldn't find the SPO module" — happens when - # PS 7 has the module installed but PS 5.1 does not (PS 7's - # Install-Module installs to the PS 7 module path only). - $proxyModuleMissing = $errMsg -match 'no valid module file was found' -or ` - $errMsg -match 'module .* was not loaded' -or ` - $errMsg -match 'Could not find the module' - - $hints = @() - if ($isMsalConflict) { - $hints += "* DLL conflict on Microsoft.Identity.Client. Another module loaded a conflicting MSAL into this session before SharePoint." - $hints += " Fix: CLOSE this PowerShell window, open a fresh pwsh, and re-run the script. Do not import other Microsoft modules first." - } - if ($errMsg -match 'No valid OAuth') { - $hints += "* The sign-in account ($TenantAdminUpn) must hold the SharePoint Administrator (or Global Administrator) role on the customer tenant." - $hints += "* Make sure the browser sign-in pop-up is allowed and not blocked by your default browser, and complete MFA if prompted." - $hints += "* You can pre-authenticate manually first, then re-run this script: Connect-SPOService -Url $SharePointAdminUrl" - } - if ($isCore -and $proxyModuleMissing) { - $hints += "* The Windows PowerShell 5.1 proxy could not find 'Microsoft.Online.SharePoint.PowerShell'." - $hints += " PS 7's 'Install-Module' installs to the PS 7 module path only; the proxy runs under PS 5.1 and needs the module in its path too." - $hints += " Fix: open Windows PowerShell 5.1 (powershell.exe) once and run:" - $hints += " Install-Module Microsoft.Online.SharePoint.PowerShell -Scope CurrentUser -Force -AllowClobber" - $hints += " Then re-run this script from pwsh." - } - $hints += "* If your SPO module is old, run: Update-Module Microsoft.Online.SharePoint.PowerShell -Force" - - $msg = "Connect-SPOService failed: $errMsg" - if ($hints) { $msg += "`nTroubleshooting:`n " + ($hints -join "`n ") } - throw $msg - } -} - -function Ensure-RequiredModule { - [CmdletBinding()] - param( - [Parameter(Mandatory)] [string] $Name, - [string] $RequiredCmdlet, - [switch] $AutoInstall, - [switch] $NonInteractive, - [switch] $UseWindowsPowerShellProxy - ) - - # On PS Core, the Microsoft.Online.SharePoint.PowerShell module is a Windows - # Desktop assembly. Loading it natively into PS 7 works for delegated auth - # but breaks certificate-based auth and can clash with EXO's bundled - # Microsoft.Identity.Client.dll. The Zero Trust Assessment connect helper - # solves this by loading SPO via -UseWindowsPowerShell (implicit WinPS 5.1 - # remoting) AFTER EXO is already connected. We follow the same pattern. - $proxy = $UseWindowsPowerShellProxy.IsPresent -and ($PSVersionTable.PSEdition -eq 'Core') - - $available = Get-Module -ListAvailable -Name $Name -ErrorAction SilentlyContinue - - if ($available) { - try { - if ($proxy) { - # WinCompat sessions use a stock PSModulePath that doesn't include the - # PS 7 user-module location. Discover the module's full path on the - # PS 7 side and pass it explicitly so the proxy import loads from the - # same on-disk location regardless of WinCompat's own PSModulePath. - $availMod = Get-Module -ListAvailable -Name $Name -ErrorAction SilentlyContinue | - Sort-Object Version -Descending | - Select-Object -First 1 - if ($availMod -and $availMod.Path) { - Import-Module -UseWindowsPowerShell -FullyQualifiedName $availMod.Path ` - -DisableNameChecking -WarningAction SilentlyContinue -ErrorAction Stop | Out-Null - } else { - Import-Module $Name -UseWindowsPowerShell -DisableNameChecking ` - -WarningAction SilentlyContinue -ErrorAction Stop | Out-Null - } - } else { - Import-Module $Name -DisableNameChecking -ErrorAction Stop | Out-Null - } - } catch { - Write-Verbose "Import-Module '$Name' failed: $($_.Exception.Message)" - } - } - - $cmdletOk = if ($RequiredCmdlet) { - [bool] (Get-Command $RequiredCmdlet -ErrorAction SilentlyContinue) - } else { - [bool] $available - } - - if ($cmdletOk) { return } - - Write-Warning "Required module '$Name' is missing or its cmdlets cannot load$(if ($RequiredCmdlet) { " ($RequiredCmdlet not found)" })." - - $shouldInstall = $AutoInstall.IsPresent - if (-not $shouldInstall -and $NonInteractive.IsPresent) { - $manual = "Install-Module $Name -Scope CurrentUser -Force -AllowClobber" - throw "Module '$Name' is required but is not installed and -NonInteractive is set (cannot prompt). Either pre-install the module or re-run with -AutoInstallModules:`n $manual" - } - if (-not $shouldInstall) { - $resp = Read-Host "Install '$Name' from PSGallery now to the current user scope? [Y/n]" - if ([string]::IsNullOrWhiteSpace($resp) -or $resp -match '^(y|yes)$') { - $shouldInstall = $true - } - } - - if (-not $shouldInstall) { - $manual = "Install-Module $Name -Scope CurrentUser -Force -AllowClobber" - throw "Module '$Name' is required but was not installed. Install manually and re-run:`n $manual" - } - - $isAdmin = Test-IsAdmin - if (-not $isAdmin) { - Write-Host " Note: this PowerShell session is NOT elevated." -ForegroundColor DarkGray - Write-Host " Installing to -Scope CurrentUser (no admin rights required)." -ForegroundColor DarkGray - Write-Host " If install fails with an access-denied error, re-run PowerShell as Administrator." -ForegroundColor DarkGray - } - - Write-Host "Installing '$Name' (Scope: CurrentUser)..." -ForegroundColor Cyan - $installError = $null - try { - Install-Module -Name $Name -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop - } catch { - $installError = $_ - } - - if ($installError) { - $hint = if ($isAdmin) { - "Try installing for all users:`n Install-Module $Name -Scope AllUsers -Force -AllowClobber" - } else { - "Close this window, right-click PowerShell -> 'Run as Administrator', then re-run this script (or install manually with: Install-Module $Name -Scope CurrentUser -Force -AllowClobber)." - } - throw "Failed to install module '$Name': $($installError.Exception.Message)`n$hint" - } - - try { - if ($proxy) { - # WinCompat sessions use a stock PSModulePath that doesn't include the - # PS 7 user-module location. Discover the module's full path on the - # PS 7 side and pass it explicitly so the proxy import loads from the - # same on-disk location regardless of WinCompat's own PSModulePath. - $availMod = Get-Module -ListAvailable -Name $Name -ErrorAction SilentlyContinue | - Sort-Object Version -Descending | - Select-Object -First 1 - if ($availMod -and $availMod.Path) { - Import-Module -UseWindowsPowerShell -FullyQualifiedName $availMod.Path ` - -DisableNameChecking -WarningAction SilentlyContinue -ErrorAction Stop | Out-Null - } else { - Import-Module $Name -UseWindowsPowerShell -DisableNameChecking ` - -WarningAction SilentlyContinue -ErrorAction Stop | Out-Null - } - } else { - Import-Module $Name -DisableNameChecking -ErrorAction Stop | Out-Null - } - } catch { - throw "Module '$Name' was installed but failed to import: $($_.Exception.Message)" - } - - if ($RequiredCmdlet -and -not (Get-Command $RequiredCmdlet -ErrorAction SilentlyContinue)) { - throw "Module '$Name' was installed but cmdlet '$RequiredCmdlet' is still not available. Restart PowerShell and re-run." - } - - Write-Host " Installed and loaded '$Name'." -ForegroundColor Green -} - -# --------------------------------------------------------------------------- -# WAM (Windows Authentication Manager) broker readiness -# --------------------------------------------------------------------------- -# Run BEFORE any module load / connect so the user gets a clear up-front -# explanation when prompts will be heavier than expected. This is observation -# only — we do not change connect arguments. WAM is on by default in current -# Microsoft.Graph.Authentication (>= 2.x) and ExchangeOnlineManagement -# (>= 3.3); the helper just surfaces blockers (non-Windows, SYSTEM, etc.). -$wamReadiness = Test-WamBrokerReadiness -if ($wamReadiness.Eligible) { - Write-Host "WAM broker available: sign-in prompts should be reduced (often 1-click 'Continue')." -ForegroundColor DarkGray - Write-Host " Note: first-time consent, tenant switches, missing scopes, stale sessions, or SharePoint can still trigger additional prompts." -ForegroundColor DarkGray -} else { - Write-Warning "Windows Authentication Manager (WAM) broker is not fully usable in this environment." - Write-Warning "Expect a browser sign-in popup per service (Exchange Online, Security & Compliance, SharePoint, Microsoft Graph)." - foreach ($issue in $wamReadiness.Issues) { - Write-Warning " * $issue" - } - Write-Warning " See Products/Purview/README.md -> 'Sign-in prompts (WAM broker)' for guidance." -} -Add-ConnectGuardLog -Service 'Startup' -Status 'Info' ` - -ReasonKey ($(if ($wamReadiness.Eligible) { 'wam-ready' } else { 'wam-not-ready' })) ` - -Detail $wamReadiness.Info - -# --------------------------------------------------------------------------- -# MSAL DLL compatibility (Microsoft.Identity.Client.dll) between EXO and Graph -# --------------------------------------------------------------------------- -# ExchangeOnlineManagement and Microsoft.Graph.Authentication each ship their -# OWN copy of Microsoft.Identity.Client.dll. When both are imported into the -# same PowerShell process, the CLR binds to whichever copy was loaded FIRST. -# This script imports EXO before Graph, so EXO's MSAL wins the AppDomain. If -# the two modules disagree on MSAL version (a recurring source of breakage — -# MSAL has shipped method-signature changes in both 4.82 -> 4.83 and earlier) -# Connect-MgGraph throws a cryptic 'Method not found' exception that reads -# like an auth failure but is actually a DLL-binding mismatch. We detect it -# up-front so the operator gets actionable guidance BEFORE the deploy spends -# minutes connecting to everything else only to die at the Graph step. -# Observation only — we never block; the connect call still runs and remains -# the definitive backstop. -$msalCompat = Test-MsalDllCompatibility -if ($msalCompat.Compatible) { - if ($msalCompat.Info.exoMsalVersion -and $msalCompat.Info.graphMsalVersion) { - Write-Host ("MSAL DLL check: ExchangeOnlineManagement and Microsoft.Graph.Authentication both ship Microsoft.Identity.Client v{0}." -f ` - $msalCompat.Info.graphMsalVersion) -ForegroundColor DarkGray - } -} else { - Write-Warning "Microsoft.Identity.Client (MSAL) DLL mismatch between Exchange Online and Microsoft Graph modules." - Write-Warning "This is the #1 cause of 'InteractiveBrowserCredential authentication failed: Method not found' errors during Connect-MgGraph." - foreach ($issue in $msalCompat.Issues) { - Write-Warning " * $issue" - } - if ($msalCompat.Info.loadedMsalVersion) { - # MSAL is already loaded in this process — we can't change which - # version is bound. The user must restart PowerShell. - Write-Warning " Action required: close this PowerShell window and open a fresh one — .NET cannot swap a loaded MSAL assembly in-place." - } elseif ($ConnectGraph) { - # We mitigate at runtime by connecting Microsoft Graph FIRST (before - # ExchangeOnlineManagement loads). This is the same pattern used by - # microsoft/zerotrustassessment's Connect-ZtAssessment. When Graph - # loads its bundled MSAL into the AppDomain first, EXO loads on top - # and its MSAL calls still succeed — verified end-to-end. - # If MSAL is somehow loaded by another module before us, the JIT will - # throw 'Method not found' and there is NO runtime workaround - # (-UseDeviceCode does NOT help — DeviceCodeCredential invokes the - # same broken MSAL method overload). In that case the only fix is - # to close pwsh and reopen. - Write-Warning " Mitigation 1: this script connects Microsoft Graph FIRST so Graph's MSAL wins the AppDomain race (Zero Trust Assessment pattern). This prevents 'Method not found' in Connect-MgGraph." - Write-Warning " Mitigation 2: Connect-ExchangeOnline / Connect-IPPSSession will be invoked with -DisableWAM so EXO falls back to the standard interactive browser flow. This avoids 'NullReferenceException at Microsoft.Identity.Client.Platforms.Features.RuntimeBroker.RuntimeBroker..ctor', which fires when EXO's WAM code path is loaded against Graph's older MSAL." - Write-Warning " If a 'Method not found' or RuntimeBroker NullReferenceException still fires below, CLOSE this PowerShell window and open a fresh one — runtime mitigation is not possible once MSAL is loaded with the wrong version." - } else { - Write-Warning " Manual fix: update both modules in a FRESH PowerShell window — 'Update-Module ExchangeOnlineManagement -Force; Update-Module Microsoft.Graph.Authentication -Force' — then re-run." - } -} -# Whether subsequent EXO/IPPS Connect calls should pass -DisableWAM to dodge -# the WAM RuntimeBroker NullReferenceException that fires when EXO 3.10's WAM -# code path runs against Graph's older MSAL. We only opt out of WAM when a -# mismatch is detected — matched MSAL versions keep the 1-click WAM UX. -$script:MsalRequiresDisableWam = -not $msalCompat.Compatible -Add-ConnectGuardLog -Service 'Startup' -Status ($(if ($msalCompat.Compatible) { 'Info' } else { 'Retried' })) ` - -ReasonKey ($(if ($msalCompat.Compatible) { 'msal-aligned' } else { 'msal-mismatch' })) ` - -Detail $msalCompat.Info - -# --------------------------------------------------------------------------- -# Copilot DLP module readiness (pre-req, non-blocking) -# --------------------------------------------------------------------------- -# AI governance / Copilot DLP is default-on for E5 / Purview Suite tenants. -# Step [5/5] in the orchestrator calls New-DlpCompliancePolicy with -# -Locations / -EnforcementPlanes, which only appear on the IPPS REST proxy -# built by ExchangeOnlineManagement v3.9.0+. On older modules the proxy -# silently lacks the parameters and Setup-AIGovernance throws a clear -# instruction to update. We surface the same instruction up-front so the -# operator sees it BEFORE the run blows 5 minutes setting up everything else. -# Observation only — never throws; the AI step still runs and provides the -# definitive backstop. -if ($AIControlsInScope) { - $copilotDlpReadiness = Test-CopilotDlpModuleReadiness - if ($copilotDlpReadiness.Eligible) { - Write-Host ("ExchangeOnlineManagement v{0} meets the Copilot DLP minimum (v{1}+)." -f ` - $copilotDlpReadiness.Info.installed, $copilotDlpReadiness.Info.minimumRequired) -ForegroundColor DarkGray - } else { - Write-Warning "Copilot DLP pre-req check: your ExchangeOnlineManagement module is older than the recommended minimum for AI governance (step [5/5])." - foreach ($issue in $copilotDlpReadiness.Issues) { - Write-Warning " * $issue" - } - Write-Warning " This is a WARNING only — the rest of the deploy continues. Step [5/5] will fail with the same guidance unless you update the module before that step runs." - Write-Warning " See the HTML report 'Module currency' section for copy-pasteable install commands." - } - Add-ConnectGuardLog -Service 'CopilotDlp' -Status ($(if ($copilotDlpReadiness.Eligible) { 'Info' } else { 'Retried' })) ` - -ReasonKey ($(if ($copilotDlpReadiness.Eligible) { 'exo-version-ok' } else { 'exo-version-below-copilot-dlp-minimum' })) ` - -Detail $copilotDlpReadiness.Info -} - -# --------------------------------------------------------------------------- -# Module checks (auto-install on demand) -# --------------------------------------------------------------------------- -# Following the Zero Trust Assessment pattern: load EXO module up front, -# but defer the SPO module import until AFTER EXO is connected. SPO's WinPS -# proxy import has a chance of perturbing PS 7's AppDomain — doing it AFTER -# EXO has already loaded its MSAL is the safest order. -# -# MSAL mismatch handling (verified end-to-end + matches the pattern in -# microsoft/zerotrustassessment's Connect-ZtAssessment): -# ExchangeOnlineManagement and Microsoft.Graph.Authentication each ship -# their OWN copy of Microsoft.Identity.Client.dll, and PowerShell loads -# them lazily on first cmdlet call. Once one is loaded with a given -# strong name + public key, .NET binds ALL subsequent requests for that -# simple name to the already-loaded version — even if a different -# version is requested. So whichever module CONNECTS first wins. -# -# * If EXO 3.10's MSAL 4.83 loads first, Graph 2.37's -# InteractiveBrowserCredential ctor invokes -# BaseAbstractApplicationBuilder.WithLogging(IIdentityLogger, Boolean) -# which moved between 4.82 and 4.83 → "Method not found". -# (DeviceCodeCredential hits the SAME ctor path → -UseDeviceCode does -# NOT help.) -# * Pre-loading Graph's older MSAL via [Reflection.Assembly]::LoadFrom -# does NOT work either — EXO's own module then refuses to load with -# FileLoadException 0x80131040 ("manifest does not match"). -# -# The fix is to connect Microsoft Graph FIRST, so Graph's MSAL wins the -# AppDomain race. EXO's MSAL calls remain forwards-compatible enough to -# work on Graph's older MSAL (validated: Connect-ExchangeOnline reaches -# the MSAL token request without binding errors). This is the order -# Connect-PurviewServices uses below. -# -# If MSAL was already loaded into the process by another module before -# this script ran, there is no runtime fix — the operator must restart -# pwsh. We detect that case in the pre-flight check above. - -Ensure-RequiredModule -Name 'ExchangeOnlineManagement' ` - -RequiredCmdlet 'Connect-ExchangeOnline' ` - -AutoInstall:$AutoInstallModules ` - -NonInteractive:$NonInteractive - -if ($ConnectGraph) { - # Connect-MgGraph / Get-MgContext live in Microsoft.Graph.Authentication. - Ensure-RequiredModule -Name 'Microsoft.Graph.Authentication' ` - -RequiredCmdlet 'Connect-MgGraph' ` - -AutoInstall:$AutoInstallModules ` - -NonInteractive:$NonInteractive - # The Beta directory management module is needed for setting Group.Unified - # values used by container labels (step [5/5] orchestrator). Microsoft.Graph.Beta.* - # sub-modules strict-pin Microsoft.Graph.Authentication to an EXACT version in - # their RequiredModules manifest. If the installed Beta version pins a different - # Auth version than what's already loaded, Import-Module fails with: - # Could not load file or assembly 'Microsoft.Graph.Authentication, Version=X.Y.Z.0'. - # Assembly with same name is already loaded - # This typically happens after the operator runs 'Update-Module - # Microsoft.Graph.Authentication -Force' (e.g. to fix the MSAL/EXO issue) WITHOUT - # also updating the Beta family. - # - # Recovery strategy: this is a deterministic, non-destructive self-heal - # (installing one specific version of a sub-module the operator already - # has other versions of). It runs UNCONDITIONALLY when a mismatch is - # detected — matching the existing self-heal pattern used for PSGallery - # trust restore (#11). If PSGallery is unreachable, we degrade - # gracefully with the exact fix command. -NonInteractive operators in - # locked-down environments who don't want auto-install can pre-pin the - # matching Beta version themselves. - $betaCompat = Test-GraphBetaCompat -BetaModuleName 'Microsoft.Graph.Beta.Identity.DirectoryManagement' - $targetAuthVer = if ($betaCompat.Info.graphAuthLoadedVersion) { $betaCompat.Info.graphAuthLoadedVersion } else { $betaCompat.Info.graphAuthInstalled | Select-Object -First 1 } - if (-not $betaCompat.Compatible) { - Write-Warning "Microsoft.Graph.Beta module version mismatch detected (would break container labels at step [5/5])." - foreach ($issue in $betaCompat.Issues) { - Write-Warning " * $issue" - } - if ($targetAuthVer) { - Write-Host " Self-healing: installing Microsoft.Graph.Beta.Identity.DirectoryManagement v$targetAuthVer side-by-side to match the loaded Authentication module..." -ForegroundColor Cyan - try { - Install-Module -Name 'Microsoft.Graph.Beta.Identity.DirectoryManagement' ` - -RequiredVersion $targetAuthVer ` - -Scope CurrentUser ` - -Force ` - -AllowClobber ` - -ErrorAction Stop - Write-Host " Installed. Re-validating..." -ForegroundColor DarkGray - $betaCompat = Test-GraphBetaCompat -BetaModuleName 'Microsoft.Graph.Beta.Identity.DirectoryManagement' - if ($betaCompat.Compatible) { - Write-Host " Microsoft.Graph.Beta v$targetAuthVer now aligned with Microsoft.Graph.Authentication v$targetAuthVer." -ForegroundColor Green - Add-ConnectGuardLog -Service 'GraphBeta' -Status 'Info' -ReasonKey 'auto-installed-matching-version' -Detail $betaCompat.Info - } else { - Add-ConnectGuardLog -Service 'GraphBeta' -Status 'Retried' -ReasonKey 'auto-install-incomplete' -Detail $betaCompat.Info - } - } catch { - Write-Warning " Auto-install failed: $($_.Exception.Message)" - Write-Warning " Manual fix: $($betaCompat.FixCommand)" - Write-Warning " Then re-run this script." - Add-ConnectGuardLog -Service 'GraphBeta' -Status 'Failed' -ReasonKey 'auto-install-failed' -Detail @{ - error = $_.Exception.Message - fixCommand = $betaCompat.FixCommand - } - throw "Microsoft.Graph.Beta sub-module version mismatch and auto-install failed. Run: $($betaCompat.FixCommand)" - } - } else { - $cmd = if ($betaCompat.FixCommand) { $betaCompat.FixCommand } else { "Update-Module Microsoft.Graph.Beta.Identity.DirectoryManagement -Force" } - Add-ConnectGuardLog -Service 'GraphBeta' -Status 'Failed' -ReasonKey 'version-pin-mismatch-no-target' -Detail @{ - betaCompat = $betaCompat.Info - fixCommand = $betaCompat.FixCommand - } - throw "Microsoft.Graph.Beta sub-module version mismatch but Authentication version could not be resolved. Manual fix: $cmd" - } - } else { - Add-ConnectGuardLog -Service 'GraphBeta' -Status 'Info' -ReasonKey 'version-pin-aligned' -Detail $betaCompat.Info - } - - # Force the Beta module import to the version whose Auth pin matches the - # loaded/installed Authentication version. Without this, PowerShell's - # default Import-Module picks the highest-installed Beta version — which - # may still be the mismatched copy when older Beta versions are present - # alongside the freshly-installed matching one. - $betaMatch = $null - if ($targetAuthVer) { - $betaMatch = Get-Module -ListAvailable -Name 'Microsoft.Graph.Beta.Identity.DirectoryManagement' -ErrorAction SilentlyContinue | - Where-Object { - $req = $_.RequiredModules | Where-Object Name -eq 'Microsoft.Graph.Authentication' | Select-Object -First 1 - $req -and $req.Version.ToString() -eq $targetAuthVer - } | - Sort-Object Version -Descending | - Select-Object -First 1 - } - if ($betaMatch) { - try { - Import-Module -Name 'Microsoft.Graph.Beta.Identity.DirectoryManagement' ` - -RequiredVersion $betaMatch.Version ` - -DisableNameChecking -ErrorAction Stop | Out-Null - Add-ConnectGuardLog -Service 'GraphBeta' -Status 'Info' -ReasonKey 'imported-matching-version' -Detail @{ - importedVersion = $betaMatch.Version.ToString() - requiredAuthVer = $targetAuthVer - } - } catch { - Write-Warning "Failed to import Microsoft.Graph.Beta.Identity.DirectoryManagement v$($betaMatch.Version): $($_.Exception.Message)" - throw - } - } else { - # No matching version installed (and auto-install above either threw - # or no target was resolvable). Surface a clear actionable error - # rather than falling through to a PowerShell auto-load failure - # later in the deploy. - $cmd = if ($betaCompat -and $betaCompat.FixCommand) { - $betaCompat.FixCommand - } else { - "Install-Module Microsoft.Graph.Beta.Identity.DirectoryManagement -RequiredVersion $targetAuthVer -Scope CurrentUser -Force -AllowClobber" - } - Add-ConnectGuardLog -Service 'GraphBeta' -Status 'Failed' -ReasonKey 'no-matching-version-installed' -Detail @{ - requiredAuthVer = $targetAuthVer - fixCommand = $cmd - } - throw "No installed Microsoft.Graph.Beta.Identity.DirectoryManagement version matches the loaded Microsoft.Graph.Authentication v$targetAuthVer. Manual fix: $cmd" - } -} - -# --------------------------------------------------------------------------- -# Microsoft Graph (Beta) — connect FIRST (ZTA pattern) -# --------------------------------------------------------------------------- -# Connect Microsoft Graph BEFORE Exchange Online so Graph's bundled -# Microsoft.Identity.Client.dll (MSAL) wins the AppDomain race. EXO's -# MSAL surface is forwards-compatible enough to work on Graph's MSAL, but -# Graph 2.37's InteractiveBrowserCredential / DeviceCodeCredential ctors -# invoke method overloads that don't exist on EXO 3.10's newer MSAL — so -# the order matters and Graph MUST go first. This matches the pattern -# used by microsoft/zerotrustassessment's Connect-ZtAssessment. -if ($ConnectGraph) { - # GDAP FIX: When the deploy targets a customer via GDAP (-DelegatedOrganization), - # the Connect-MgGraph -TenantId MUST be the CUSTOMER tenant, not the partner - # tenant. The previous code split the admin UPN suffix - # (e.g. 'partnerAdmin@fabrikam.onmicrosoft.com' -> 'fabrikam.onmicrosoft.com') - # and silently authed Graph into the partner tenant — so license auto-detect - # and container-label setup ran against the wrong tenant on every partner-led run. - $targetTenantDomain = if ($DelegatedOrganization) { - $DelegatedOrganization - } else { - ($TenantAdminUpn -split '@')[-1] - } - # Normalise scope set (drop empties / dupes, preserve order) before reuse-check. - $requiredScopes = @($GraphScopes | Where-Object { $_ } | Select-Object -Unique) - if ($requiredScopes.Count -eq 0) { $requiredScopes = @('Organization.Read.All') } - $graphConnected = $false - $graphGuardReason = $null - $graphGuardDetail = @{ - expectedUpn = $TenantAdminUpn - expectedTenantDomain = $targetTenantDomain - expectedScopes = ($requiredScopes -join ',') - } - try { - $ctx = Get-MgContext -ErrorAction Stop - # Reuse only when the cached session belongs to the SAME admin, has the - # required scopes, AND (for GDAP) is authenticated against the customer - # tenant. A stale session for a different tenant produces a "Selected - # user account does not exist in tenant" popup the moment any Graph - # cmdlet triggers an MSAL token refresh. - $cachedScopes = @($ctx.Scopes) - $missingScopes = @($requiredScopes | Where-Object { $cachedScopes -notcontains $_ }) - if ($ctx -and ` - $ctx.Account -and ($ctx.Account -ieq $TenantAdminUpn) -and ` - $missingScopes.Count -eq 0) { - - $tenantOk = $true - $tenantCheckFailureKey = $null - if ($DelegatedOrganization) { - # Confirm the cached session is actually authed against the customer - # tenant by listing verifiedDomains on /organization and looking for - # the DelegatedOrganization. Mismatch ⇒ a stale partner-tenant session. - try { - $org = Invoke-MgGraphRequest -Method GET ` - -Uri 'https://graph.microsoft.com/v1.0/organization?$select=verifiedDomains' ` - -ErrorAction Stop - $domains = @() - foreach ($o in @($org.value)) { - foreach ($d in @($o.verifiedDomains)) { if ($d.name) { $domains += $d.name.ToLowerInvariant() } } - } - $tenantOk = $domains -contains $DelegatedOrganization.ToLowerInvariant() - if (-not $tenantOk) { $tenantCheckFailureKey = 'tenant-domain-mismatch' } - } catch { - $tenantOk = $false - $tenantCheckFailureKey = 'tenant-verification-failed' - $graphGuardDetail.tenantVerifyError = $_.Exception.Message - } - } - - if ($tenantOk) { - $graphConnected = $true - $graphGuardReason = 'reused' - $graphGuardDetail.cachedAccount = $ctx.Account - $graphGuardDetail.cachedTenantId = ($ctx.TenantId -as [string]) - } else { - $graphGuardReason = $tenantCheckFailureKey - $graphGuardDetail.cachedAccount = $ctx.Account - $graphGuardDetail.cachedTenantId = ($ctx.TenantId -as [string]) - $graphGuardDetail.actualContainsExpected = $false - Write-Host "Microsoft Graph: cached session is not authed against '$DelegatedOrganization'; reconnecting." -ForegroundColor DarkYellow - Add-ConnectGuardLog -Service 'Graph' -Status 'Retried' -ReasonKey $graphGuardReason -Detail $graphGuardDetail - try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch {} - } - } elseif ($ctx) { - $cachedAcct = if ($ctx.Account) { $ctx.Account } else { '(no account)' } - if ($missingScopes.Count -gt 0 -and $ctx.Account -ieq $TenantAdminUpn) { - $graphGuardReason = 'missing-scopes' - $graphGuardDetail.cachedAccount = $cachedAcct - $graphGuardDetail.missingScopes = ($missingScopes -join ',') - Write-Host "Microsoft Graph: cached session for '$cachedAcct' is missing required scopes ($($missingScopes -join ', ')); reconnecting." -ForegroundColor DarkYellow - } else { - $graphGuardReason = 'account-mismatch' - $graphGuardDetail.cachedAccount = $cachedAcct - Write-Host "Microsoft Graph: discarding cached session for '$cachedAcct' (does not match '$TenantAdminUpn' or required scopes)." -ForegroundColor DarkYellow - } - Add-ConnectGuardLog -Service 'Graph' -Status 'Retried' -ReasonKey $graphGuardReason -Detail $graphGuardDetail - try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch {} - } else { - $graphGuardReason = 'no-context' - } - } catch { - $graphConnected = $false - $graphGuardReason = 'get-mgcontext-failed' - $graphGuardDetail.error = $_.Exception.Message - } - - if ($graphConnected) { - Add-ConnectGuardLog -Service 'Graph' -Status 'Info' -ReasonKey 'reused' -Detail $graphGuardDetail - Write-Host "Microsoft Graph: existing session reused (account + tenant + scopes match)." -ForegroundColor DarkGray - } else { - $startedReason = if ($graphGuardReason) { "reconnect-$graphGuardReason" } else { 'no-existing-session' } - Add-ConnectGuardLog -Service 'Graph' -Status 'Started' -ReasonKey $startedReason -Detail $graphGuardDetail - Write-Host ("Connecting to Microsoft Graph (Beta) for tenant {0} with scopes: {1}..." -f $targetTenantDomain, ($requiredScopes -join ', ')) -ForegroundColor Cyan - # ZTA pattern: catch MissingMethodException from Microsoft.Identity - # and emit the only fix that actually works — restart pwsh. No - # runtime workaround exists once MSAL has been loaded with the - # "wrong" version (including -UseDeviceCode, which hits the same - # broken ctor path). - try { - Connect-MgGraph -TenantId $targetTenantDomain -Scopes $requiredScopes -NoWelcome -ErrorAction Stop - } catch { - $methodNotFound = $null - if ($_.Exception -is [System.MissingMethodException]) { - $methodNotFound = $_.Exception - } elseif ($_.Exception.InnerException -is [System.MissingMethodException]) { - $methodNotFound = $_.Exception.InnerException - } elseif ($_.Exception.Message -match 'Method not found.*Microsoft\.Identity') { - # Some .NET versions wrap the inner MissingMethodException in - # a generic AuthenticationFailedException — message-match it. - $methodNotFound = $_.Exception - } - if ($methodNotFound) { - Write-Warning "" - Write-Warning "Microsoft Graph sign-in failed with a Microsoft.Identity.Client (MSAL) DLL conflict." - Write-Warning "This happens when another module loaded its own MSAL into this PowerShell process before Microsoft Graph could load its own." - Write-Warning "" - Write-Warning "There is NO runtime workaround once MSAL is loaded with the wrong version:" - Write-Warning " * -UseDeviceCode does NOT help (it hits the same broken MSAL method)." - Write-Warning " * Pre-loading the other MSAL via [Reflection.Assembly]::LoadFrom does NOT help (EXO refuses)." - Write-Warning "" - Write-Warning "Fix: CLOSE this PowerShell window, open a FRESH pwsh, and re-run this script." - Write-Warning " Do not import Microsoft.Graph.*, Az.*, PnP.PowerShell or ExchangeOnlineManagement in the new window before running this script." - Add-ConnectGuardLog -Service 'Graph' -Status 'Failed' -ReasonKey 'msal-method-not-found-restart-pwsh' -Detail @{ - error = $methodNotFound.Message - exoModule = $msalCompat.Info.exoModuleVersion - exoMsal = $msalCompat.Info.exoMsalVersion - graphModule = $msalCompat.Info.graphModuleVersion - graphMsal = $msalCompat.Info.graphMsalVersion - loadedMsal = $msalCompat.Info.loadedMsalVersion - } - } - throw - } - } -} - -# --------------------------------------------------------------------------- -# Exchange Online — connect AFTER Graph (Graph-first ordering preserves MSAL) -# --------------------------------------------------------------------------- -# Reuse an existing EXO session ONLY when it matches BOTH the target admin UPN -# AND the GDAP delegated organisation. Without this guard, back-to-back deploys -# against different customer tenants (or after a partner-tenant context switch) -# silently reuse the stale session and every Set-* call runs against the WRONG -# tenant. -$exoConnected = $false -$staleExo = @() -$exoLookupOk = $true -try { - $exoSessions = @(Get-ConnectionInformation -ErrorAction Stop | - Where-Object { $_.State -eq 'Connected' -and $_.TokenStatus -eq 'Active' -and $_.Name -like 'ExchangeOnline*' -and $_.ConnectionUri -notlike '*compliance.protection.outlook.com*' }) - foreach ($s in $exoSessions) { - $upnOk = $s.UserPrincipalName -ieq $TenantAdminUpn - $delegOk = if ($DelegatedOrganization) { - $s.DelegatedOrganization -ieq $DelegatedOrganization - } else { - [string]::IsNullOrEmpty($s.DelegatedOrganization) - } - if ($upnOk -and $delegOk) { $exoConnected = $true } - else { - $reasonKey = if (-not $upnOk -and -not $delegOk) { 'upn-and-deleg-org-mismatch' } - elseif (-not $upnOk) { 'upn-mismatch' } - else { 'deleg-org-mismatch' } - Add-ConnectGuardLog -Service 'EXO' -Status 'Retried' -ReasonKey $reasonKey -Detail @{ - expectedUpn = $TenantAdminUpn - actualUpn = ($s.UserPrincipalName -as [string]) - expectedDelegatedOrg = ($DelegatedOrganization -as [string]) - actualDelegatedOrg = ($s.DelegatedOrganization -as [string]) - } - $staleExo += $s - } - } -} catch { - $exoConnected = $false - $exoLookupOk = $false - Add-ConnectGuardLog -Service 'EXO' -Status 'Info' -ReasonKey 'get-connectioninformation-failed' -Detail @{ - error = $_.Exception.Message - } -} - -if ($staleExo.Count -gt 0) { - $targetDesc = if ($DelegatedOrganization) { "$TenantAdminUpn -> $DelegatedOrganization" } else { $TenantAdminUpn } - Write-Host "Discarding $($staleExo.Count) stale Exchange Online session(s) (target: $targetDesc)." -ForegroundColor DarkYellow - foreach ($s in $staleExo) { - try { - Disconnect-ExchangeOnline -ConnectionId $s.ConnectionId -Confirm:$false -ErrorAction SilentlyContinue | Out-Null - } catch { - Write-Verbose " Could not disconnect stale EXO session $($s.ConnectionId): $($_.Exception.Message)" - } - } -} - -if ($exoConnected) { - Add-ConnectGuardLog -Service 'EXO' -Status 'Info' -ReasonKey 'reused' -Detail @{ - expectedUpn = $TenantAdminUpn - expectedDelegatedOrg = ($DelegatedOrganization -as [string]) - } - Write-Host "Exchange Online: existing session reused (UPN + delegated-org match)." -ForegroundColor DarkGray -} else { - $exoColdReason = if (-not $exoLookupOk) { 'reconnect-after-lookup-failure' } - elseif ($staleExo.Count -gt 0) { 'reconnect-after-stale-discard' } - else { 'no-existing-session' } - Add-ConnectGuardLog -Service 'EXO' -Status 'Started' -ReasonKey $exoColdReason -Detail @{ - expectedUpn = $TenantAdminUpn - expectedDelegatedOrg = ($DelegatedOrganization -as [string]) - } - $signInBanner = if ($DelegatedOrganization) { "$TenantAdminUpn (GDAP delegated: $DelegatedOrganization)" } else { $TenantAdminUpn } - Write-Host "Connecting to Exchange Online as $signInBanner..." -ForegroundColor Cyan - $exoArgs = @{ UserPrincipalName = $TenantAdminUpn; ShowBanner = $false } - if ($DelegatedOrganization) { $exoArgs['DelegatedOrganization'] = $DelegatedOrganization } - if ($script:MsalRequiresDisableWam) { - # MSAL mismatch detected up-front. EXO 3.10's WAM RuntimeBroker - # constructor null-refs when called against Graph 2.37's MSAL 4.82. - # -DisableWAM falls back to interactive browser (still 1 click via - # default browser) and avoids the broken WAM code path. - $exoArgs['DisableWAM'] = $true - Write-Host " Using -DisableWAM for Exchange Online (MSAL mismatch — falls back to interactive browser)." -ForegroundColor DarkGray - Add-ConnectGuardLog -Service 'EXO' -Status 'Info' -ReasonKey 'disable-wam-msal-mismatch' -Detail @{ - exoMsalVersion = $msalCompat.Info.exoMsalVersion - graphMsalVersion = $msalCompat.Info.graphMsalVersion - } - } - try { - Connect-ExchangeOnline @exoArgs - } catch { - # ZT-pattern: detect MSAL DLL conflict and tell the user to restart pwsh. - # No script can recover from this — once a conflicting Microsoft.Identity.Client - # is loaded into PS 7's AppDomain it cannot be unloaded. - $exoConflict = @($_.Exception, $_.Exception.InnerException) | - Where-Object { $_ -is [System.MissingMethodException] -or $_ -is [System.IO.FileLoadException] } | - Select-Object -First 1 - if ($exoConflict -and ($exoConflict.Message -like '*Microsoft.Identity.Client*' -or $exoConflict.Message -like '*Microsoft.IdentityModel*')) { - Write-Host "" - Write-Warning "DLL conflict detected ($($exoConflict.GetType().Name)) loading Exchange Online's Microsoft.Identity.Client.dll." - Write-Warning "This means a conflicting Microsoft.Identity.Client was loaded into this PowerShell session before Exchange Online." - Write-Warning "Common causes: Microsoft.Graph, PnP.PowerShell, Az.* or Microsoft.Online.SharePoint.PowerShell was imported earlier in this session." - Write-Warning "Fix: CLOSE this PowerShell window, open a fresh pwsh, and re-run the script. Do not import any other Microsoft modules first." - Write-Host "" - } - # WAM RuntimeBroker NullReferenceException — fires when EXO's WAM - # code path is loaded against Graph's older MSAL. Should not happen - # now that we auto-pass -DisableWAM on detected mismatch, but keep - # the structured guidance for the edge case where mismatch detection - # under-reports (e.g. MSAL already loaded before script start). - $errText = ($_.Exception.Message + "`n" + $_.ScriptStackTrace + "`n" + $_.Exception.StackTrace) - if ($errText -match 'Microsoft\.Identity\.Client\.Platforms\.Features\.RuntimeBroker' -or - ($_.Exception -is [System.NullReferenceException] -and $errText -match 'RuntimeBroker')) { - Write-Host "" - Write-Warning "WAM broker crash detected (NullReferenceException in Microsoft.Identity.Client.Platforms.Features.RuntimeBroker)." - Write-Warning "This means EXO's WAM code path was loaded against a mismatched Microsoft.Identity.Client version (typically Graph 2.37's MSAL 4.82 + EXO 3.10's WAM expectations)." - Write-Warning "Fix: re-run with the script's -DisableWAM mitigation (auto-applied when MSAL mismatch is detected). If this fires anyway, CLOSE this PowerShell window, open a fresh pwsh, and re-run the script." - Write-Host "" - Add-ConnectGuardLog -Service 'EXO' -Status 'Failed' -ReasonKey 'wam-runtimebroker-nullref' -Detail @{ - exoMsalVersion = $msalCompat.Info.exoMsalVersion - graphMsalVersion = $msalCompat.Info.graphMsalVersion - loadedMsalVersion = $msalCompat.Info.loadedMsalVersion - } - } - throw - } -} - -# --------------------------------------------------------------------------- -# Security & Compliance (IPPS) — separate connection from EXO -# --------------------------------------------------------------------------- -# Same tenant-match guard as EXO: a stale IPPS session for a different tenant -# silently routes every Set-Label / Set-Dlp* call to the wrong customer. -$ippsConnected = $false -$staleIpps = @() -$ippsLookupOk = $true -try { - $ippsSessions = @(Get-ConnectionInformation -ErrorAction Stop | - Where-Object { $_.State -eq 'Connected' -and $_.TokenStatus -eq 'Active' -and $_.ConnectionUri -like '*compliance.protection.outlook.com*' }) - foreach ($s in $ippsSessions) { - $upnOk = $s.UserPrincipalName -ieq $TenantAdminUpn - $delegOk = if ($DelegatedOrganization) { - $s.DelegatedOrganization -ieq $DelegatedOrganization - } else { - [string]::IsNullOrEmpty($s.DelegatedOrganization) - } - if ($upnOk -and $delegOk) { $ippsConnected = $true } - else { - $reasonKey = if (-not $upnOk -and -not $delegOk) { 'upn-and-deleg-org-mismatch' } - elseif (-not $upnOk) { 'upn-mismatch' } - else { 'deleg-org-mismatch' } - Add-ConnectGuardLog -Service 'IPPS' -Status 'Retried' -ReasonKey $reasonKey -Detail @{ - expectedUpn = $TenantAdminUpn - actualUpn = ($s.UserPrincipalName -as [string]) - expectedDelegatedOrg = ($DelegatedOrganization -as [string]) - actualDelegatedOrg = ($s.DelegatedOrganization -as [string]) - } - $staleIpps += $s - } - } -} catch { - $ippsConnected = $false - $ippsLookupOk = $false - Add-ConnectGuardLog -Service 'IPPS' -Status 'Info' -ReasonKey 'get-connectioninformation-failed' -Detail @{ - error = $_.Exception.Message - } -} - -if ($staleIpps.Count -gt 0) { - $targetDesc = if ($DelegatedOrganization) { "$TenantAdminUpn -> $DelegatedOrganization" } else { $TenantAdminUpn } - Write-Host "Discarding $($staleIpps.Count) stale Security & Compliance (IPPS) session(s) (target: $targetDesc)." -ForegroundColor DarkYellow - foreach ($s in $staleIpps) { - try { - Disconnect-ExchangeOnline -ConnectionId $s.ConnectionId -Confirm:$false -ErrorAction SilentlyContinue | Out-Null - } catch { - Write-Verbose " Could not disconnect stale IPPS session $($s.ConnectionId): $($_.Exception.Message)" - } - } -} - -if ($ippsConnected) { - Add-ConnectGuardLog -Service 'IPPS' -Status 'Info' -ReasonKey 'reused' -Detail @{ - expectedUpn = $TenantAdminUpn - expectedDelegatedOrg = ($DelegatedOrganization -as [string]) - } - Write-Host "Security & Compliance (IPPS): existing session reused (UPN + delegated-org match)." -ForegroundColor DarkGray -} else { - $ippsColdReason = if (-not $ippsLookupOk) { 'reconnect-after-lookup-failure' } - elseif ($staleIpps.Count -gt 0) { 'reconnect-after-stale-discard' } - else { 'no-existing-session' } - Add-ConnectGuardLog -Service 'IPPS' -Status 'Started' -ReasonKey $ippsColdReason -Detail @{ - expectedUpn = $TenantAdminUpn - expectedDelegatedOrg = ($DelegatedOrganization -as [string]) - } - $ippsBanner = if ($DelegatedOrganization) { " (GDAP delegated: $DelegatedOrganization)" } else { '' } - Write-Host "Connecting to Security & Compliance Center$ippsBanner..." -ForegroundColor Cyan - $ippsArgs = @{ UserPrincipalName = $TenantAdminUpn; ShowBanner = $false } - if ($DelegatedOrganization) { $ippsArgs['DelegatedOrganization'] = $DelegatedOrganization } - if ($script:MsalRequiresDisableWam) { - # Same WAM-RuntimeBroker NullRef applies to IPPS (same module / same - # MSAL surface). -DisableWAM keeps the interactive browser flow. - $ippsArgs['DisableWAM'] = $true - Write-Host " Using -DisableWAM for Security & Compliance (IPPS) (MSAL mismatch — falls back to interactive browser)." -ForegroundColor DarkGray - Add-ConnectGuardLog -Service 'IPPS' -Status 'Info' -ReasonKey 'disable-wam-msal-mismatch' -Detail @{ - exoMsalVersion = $msalCompat.Info.exoMsalVersion - graphMsalVersion = $msalCompat.Info.graphMsalVersion - } - } - Connect-IPPSSession @ippsArgs -} - -# --------------------------------------------------------------------------- -# SharePoint Online — module loaded LAST (via WinPS proxy) and connected LAST -# --------------------------------------------------------------------------- -# ZT-aligned pattern: Microsoft.Online.SharePoint.PowerShell is a Windows-only -# module. On PS Core we load it via -UseWindowsPowerShell so its bundled -# Microsoft.Identity.Client.dll never enters PS 7's AppDomain (it stays in the -# hidden PS 5.1 sub-process). The module import happens HERE — after EXO/IPPS -# are already connected — to guarantee EXO's own MSAL has loaded cleanly first. -# -# Resolver precedence for the admin URL: -# 1. -SharePointAdminUrl -# 2. -DelegatedOrganization (.onmicrosoft.com) -# 3. Admin UPN suffix (when @.onmicrosoft.com) -# 4. EXO Get-AcceptedDomain (final fallback — uses the EXO session above) -if ($NeedsSharePoint) { - Ensure-RequiredModule -Name 'Microsoft.Online.SharePoint.PowerShell' ` - -RequiredCmdlet 'Connect-SPOService' ` - -AutoInstall:$AutoInstallModules ` - -NonInteractive:$NonInteractive ` - -UseWindowsPowerShellProxy - - $resolved = Resolve-SpoAdminUrlFromInputs ` - -ExplicitUrl $SharePointAdminUrl ` - -DelegatedOrganization $DelegatedOrganization ` - -TenantAdminUpn $TenantAdminUpn - - if ($resolved) { - $SharePointAdminUrl = $resolved.Url - Write-Host "SharePoint admin URL resolved via $($resolved.Source): $SharePointAdminUrl" -ForegroundColor DarkGray - } else { - Write-Host "Resolving SharePoint admin URL from tenant initial domain (EXO Get-AcceptedDomain)..." -ForegroundColor Cyan - try { - # Get-AcceptedDomain runs in the EXO session we just connected. - # The InitialDomain (always .onmicrosoft.com) is the - # reliable basis for the SPO admin URL. - $initial = Get-AcceptedDomain | - Where-Object { $_.InitialDomain } | - Select-Object -First 1 - if (-not $initial) { - throw "No initial (.onmicrosoft.com) domain found via Get-AcceptedDomain." - } - $tenantPrefix = ($initial.DomainName -split '\.')[0] - $SharePointAdminUrl = "https://$tenantPrefix-admin.sharepoint.com" - Write-Host " Resolved: $SharePointAdminUrl" -ForegroundColor Green - } catch { - throw "Could not auto-derive SharePoint admin URL: $($_.Exception.Message)`nPass -SharePointAdminUrl explicitly (e.g. https://-admin.sharepoint.com)." - } - } - - Invoke-SpoConnectWithFallback -SharePointAdminUrl $SharePointAdminUrl -TenantAdminUpn $TenantAdminUpn -} - -Write-Host "All required services connected." -ForegroundColor Green - -[pscustomobject]@{ - SharePointAdminUrl = if ($NeedsSharePoint) { $SharePointAdminUrl } else { $null } -} +#requires -Version 7.0 +<# +.SYNOPSIS + Connects to all Microsoft 365 services required by the Purview Best Practice toolkit. + +.DESCRIPTION + Idempotent connection helper. Detects existing sessions and reuses them. + Supports both standard customer-admin auth and partner GDAP delegated auth via + -DelegatedOrganization. + + Services connected: + * Exchange Online (Connect-ExchangeOnline) + * Security & Compliance (IPPS) (Connect-IPPSSession) + * SharePoint Online (Connect-SPOService) + * Microsoft Graph (Beta) (Connect-MgGraph) — only if -ConnectGraph is passed + + Required PowerShell modules: + * ExchangeOnlineManagement + * Microsoft.Online.SharePoint.PowerShell + * Microsoft.Graph.Beta.Identity.DirectoryManagement (only if -ConnectGraph) + +.PARAMETER TenantAdminUpn + UPN of the customer tenant admin (or partner GDAP admin) used for sign-in. + +.PARAMETER NeedsSharePoint + Connect to SharePoint Online (after Exchange Online). The admin URL is + auto-derived using the following precedence chain: + 1. -SharePointAdminUrl (explicit override) + 2. -DelegatedOrganization (GDAP — tenant primary domain) + 3. Admin UPN suffix (when *.onmicrosoft.com) + 4. EXO Get-AcceptedDomain (final fallback) + +.PARAMETER SharePointAdminUrl + Optional override for the SharePoint admin centre URL + (e.g. https://contoso-admin.sharepoint.com). When omitted, the URL is + auto-derived using the precedence chain described under -NeedsSharePoint. + Pass this explicitly for multi-geo, renamed tenants, or vanity-domain + admin UPNs where the chain cannot resolve. + +.PARAMETER DelegatedOrganization + Customer tenant primary domain (e.g. contoso.onmicrosoft.com) when a partner + is signing in via GDAP. Forwarded to Connect-ExchangeOnline and + Connect-IPPSSession. + +.PARAMETER ConnectGraph + Connect to Microsoft Graph (Beta). Required only when configuring container + labels (Group.Unified EnableMIPLabels). + +.PARAMETER GraphScopes + Delegated Microsoft Graph scopes to request when -ConnectGraph is used. + Defaults to the read-only set 'Organization.Read.All' (sufficient for license + auto-detect via /subscribedSkus and tenant-identity confirm via /organization). + The orchestrator adds 'Directory.ReadWrite.All' on top of this only + when container labels (Group.Unified EnableMIPLabels) will actually be + written, keeping the consented privilege as narrow as the run requires. + NOTE: Directory.ReadWrite.All is the historically-consented, known-working + scope on most tenants for /directorySettingTemplates and /settings. A + narrower scope (GroupSettings.ReadWrite.All) was tried but produced 403 + Authorization_RequestDenied on tenants where admins had only consented + Directory.ReadWrite.All historically. + +.PARAMETER AutoInstallModules + When set, missing PowerShell modules are installed automatically (scope + CurrentUser) without prompting. Without this switch the script prompts + interactively before installing. + +.OUTPUTS + PSCustomObject with the resolved SharePointAdminUrl (when SPO was connected). + +.EXAMPLE + .\Connect-PurviewServices.ps1 -TenantAdminUpn admin@contoso.onmicrosoft.com ` + -NeedsSharePoint + +.EXAMPLE + # Partner GDAP scenario with explicit SPO URL override + .\Connect-PurviewServices.ps1 -TenantAdminUpn partneradmin@fabrikam.onmicrosoft.com ` + -NeedsSharePoint -SharePointAdminUrl https://contoso-admin.sharepoint.com ` + -DelegatedOrganization contoso.onmicrosoft.com +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $TenantAdminUpn, + + [Parameter()] + [switch] $NeedsSharePoint, + + [Parameter()] + [string] $SharePointAdminUrl, + + [Parameter()] + [string] $DelegatedOrganization, + + [Parameter()] + [switch] $ConnectGraph, + + [Parameter()] + [string[]] $GraphScopes = @('Organization.Read.All'), + + [Parameter()] + [switch] $AutoInstallModules, + + [Parameter()] + [switch] $NonInteractive, + + # When set, run the Copilot DLP module-readiness pre-check. Used by the + # orchestrator when the AI governance step is in scope (default-on for + # E5 / Purview Suite tenants); skipped when the operator passed + # -SkipAIControls / -BPOnly so we don't emit noise for runs that won't + # touch the Copilot DLP cmdlets. + [Parameter()] + [switch] $AIControlsInScope +) + +# Minimum ExchangeOnlineManagement version whose IPPS REST proxy exposes the +# Copilot DLP parameters (-Locations / -EnforcementPlanes) reliably. Below +# this version, Setup-AIGovernance's sanity check bails ("module does not +# expose the Copilot DLP parameters") because the dynamically-generated +# New-DlpCompliancePolicy proxy is missing those parameters. This constant +# drives a NON-blocking pre-req warning + HTML report callout — the actual +# hard backstop still lives in Setup-AIGovernance.ps1. +$script:CopilotDlpMinExoVersion = [version]'3.9.0' + +$ErrorActionPreference = 'Stop' + +# Connecting to services is a precondition, not a destructive change. Some +# auth flows (notably Connect-SPOService and Import-Module -UseWindowsPowerShell) +# have internal steps that respect $WhatIfPreference and silently skip work +# under -WhatIf, producing misleading "No valid OAuth 2.0 authentication +# session exists" errors. Force WhatIf off for the duration of this script. +$WhatIfPreference = $false +$ConfirmPreference = 'None' + +# --------------------------------------------------------------------------- +# Run log helper — idempotent dot-source so Add-RunLogEntry is available even +# when this script is invoked standalone (i.e. without the orchestrator). The +# helper itself wraps all writes in try/catch and lazy-inits $global:PurviewRunLog, +# so calling it when no run log was initialised is a silent no-op. +# Same pattern as Invoke-WithTransientRetry.ps1. +# --------------------------------------------------------------------------- +$_purviewRunLogPath = Join-Path $PSScriptRoot 'PurviewRunLog.ps1' +if (Test-Path $_purviewRunLogPath) { + . $_purviewRunLogPath +} + +function Add-ConnectGuardLog { + <# + Emits a structured Add-RunLogEntry for a connect-side session guard + decision. Detail is built as 'reason=; k1=v1; k2=v2; ...' using + stable key names so operators can grep / filter Get-PurviewRunLog + output after the fact. + + Status semantics for this helper: + * Info — guard accepted the cached session OR neutral observation + * Started — about to call Connect-* (cold start or after stale discard) + * Retried — guard rejected the cached session and will reconnect + * Skipped — guard intentionally bypassed (e.g. service disabled by flag) + * Failed — recoverable problem detected by a guard helper + (e.g. Beta version pin self-heal couldn't install / import). + Use 'Failed' (not 'Error') for parity with Add-RunLogEntry. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Service, + [Parameter(Mandatory)] + [ValidateSet('Info','Started','Retried','Skipped','Failed')] + [string] $Status, + [Parameter(Mandatory)] [string] $ReasonKey, + [hashtable] $Detail + ) + + if (-not (Get-Command Add-RunLogEntry -ErrorAction SilentlyContinue)) { return } + + $parts = @("reason=$ReasonKey") + if ($Detail) { + foreach ($k in ($Detail.Keys | Sort-Object)) { + $v = $Detail[$k] + if ($null -eq $v) { $v = '' } + $parts += ('{0}={1}' -f $k, $v) + } + } + + try { + Add-RunLogEntry -Module 'Connect-PurviewServices' ` + -Action "SessionGuard:$Service" ` + -Status $Status ` + -Detail ($parts -join '; ') + } catch { + Write-Verbose "Add-ConnectGuardLog failed: $($_.Exception.Message)" + } +} + +function Test-WamBrokerReadiness { + <# + Best-effort readiness check for Windows Authentication Manager (WAM). + Returns a PSCustomObject with: + * Eligible — $true when no blocking issues were found + * Issues — list of short human-readable problem descriptions + * Info — diagnostic hashtable for the run log + + WAM is enabled by default on Windows 10 / Server 2019 and later in current + Microsoft.Graph.Authentication (>= 2.x) and ExchangeOnlineManagement + (>= 3.3). When WAM is active, second-and-subsequent sign-in prompts show + the single-click "Continue" account picker instead of a full browser + sign-in. When unavailable, every service connection falls back to a + browser popup — that is the cause of the "6 prompts per run" symptom. + + Per MS Learn: 'Signin by Web Account Manager (WAM) is enabled by default + on Windows and cannot be disabled' in current modules — so no runtime + DisableLoginByWAM check is performed; we only check environment + module + readiness here. + #> + [CmdletBinding()] + param() + + $issues = @() + $info = [ordered]@{ + wamEligible = $false + psEdition = $PSVersionTable.PSEdition + psVersion = $PSVersionTable.PSVersion.ToString() + isWindows = [bool]$IsWindows + windowsBuild = $null + productType = $null + userInteractive = [Environment]::UserInteractive + runningAsSystem = $false + graphAuthModuleVersion = 'not-installed' + exoModuleVersion = 'not-installed' + } + + if (-not $IsWindows) { + $issues += "Not running on Windows — WAM is a Windows-only broker; every connect call falls back to a browser popup." + return [pscustomobject]@{ Eligible = $false; Issues = $issues; Info = $info } + } + + try { + $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop + $build = [int]($os.BuildNumber) + $productType = [int]$os.ProductType # 1=workstation, 2=DC, 3=server + $info.windowsBuild = $build + $info.productType = $productType + + if ($productType -eq 1 -and $build -lt 10240) { + $issues += "Windows build $build is older than Windows 10 1507 (10240) — WAM not available." + } elseif ($productType -ge 2 -and $build -lt 17763) { + $issues += "Windows Server build $build is older than Server 2019 (17763) — WAM not available." + } + } catch { + # CIM failure is non-fatal; treat as 'unknown', not a blocker. + Write-Verbose "Could not detect Windows build via CIM: $($_.Exception.Message)" + } + + try { + $current = [Security.Principal.WindowsIdentity]::GetCurrent() + if ($current -and $current.IsSystem) { + $info.runningAsSystem = $true + $issues += "Running as SYSTEM — WAM requires a user desktop session; sign-in will fall back to browser." + } + } catch { } + + if (-not [Environment]::UserInteractive) { + $issues += "PowerShell host is not interactive — WAM popup requires an interactive desktop session." + } + + $mgMod = Get-Module Microsoft.Graph.Authentication -ListAvailable -ErrorAction SilentlyContinue | + Sort-Object Version -Descending | Select-Object -First 1 + if ($mgMod) { + $info.graphAuthModuleVersion = $mgMod.Version.ToString() + if ($mgMod.Version.Major -lt 2) { + $issues += "Microsoft.Graph.Authentication v$($mgMod.Version) predates built-in WAM (need 2.x). Update: Update-Module Microsoft.Graph.Authentication" + } + } + + $exoMod = Get-Module ExchangeOnlineManagement -ListAvailable -ErrorAction SilentlyContinue | + Sort-Object Version -Descending | Select-Object -First 1 + if ($exoMod) { + $info.exoModuleVersion = $exoMod.Version.ToString() + if ($exoMod.Version -lt [version]'3.3.0') { + $issues += "ExchangeOnlineManagement v$($exoMod.Version) predates WAM integration (need 3.3+). Update: Update-Module ExchangeOnlineManagement" + } + } + + $info.wamEligible = ($issues.Count -eq 0) + [pscustomobject]@{ + Eligible = $info.wamEligible + Issues = $issues + Info = $info + } +} + +function Test-MsalDllCompatibility { + <# + Detects mismatched Microsoft.Identity.Client.dll (MSAL) versions + bundled by ExchangeOnlineManagement and Microsoft.Graph.Authentication. + + Why this matters + ---------------- + Each module ships its OWN copy of Microsoft.Identity.Client.dll under + its module folder (e.g. ExchangeOnlineManagement\\netCore\ vs + Microsoft.Graph.Authentication\\Dependencies\Core\). When both + modules are imported into the same PowerShell process, the CLR binds + to whichever copy was loaded FIRST and ignores the other. This script + connects to Exchange Online BEFORE Microsoft Graph, so EXO's MSAL + wins the AppDomain. + + If EXO and Graph were compiled against different MSAL versions and a + method signature changed between those versions (a recurring event + in MSAL releases, e.g. WithLogging(IIdentityLogger, Boolean) moving + between AbstractApplicationBuilder and BaseAbstractApplicationBuilder + in 4.83), Connect-MgGraph throws: + + InteractiveBrowserCredential authentication failed: + Method not found: '!0 Microsoft.Identity.Client..(...)'. + + That error reads like a credential failure but is purely a DLL-binding + mismatch. We surface it as a structured pre-flight warning so the + operator knows exactly which two module versions disagree and how to + re-align them BEFORE Connect-MgGraph blows up. + + Returns a PSCustomObject with: + * Compatible — $true when MSAL DLLs are aligned (or only one of the + two modules is installed, so no in-process conflict + is possible from this script) + * Issues — list of short human-readable problem descriptions + * Info — diagnostic hashtable for the run log + #> + [CmdletBinding()] + param() + + $issues = @() + $info = [ordered]@{ + psEdition = $PSVersionTable.PSEdition + exoModuleVersion = $null + exoMsalDllPath = $null + exoMsalVersion = $null + graphModuleVersion = $null + graphMsalDllPath = $null + graphMsalVersion = $null + loadedMsalVersion = $null + loadedMsalLocation = $null + } + + function _findMsalDll([string]$ModuleBase) { + if (-not $ModuleBase -or -not (Test-Path $ModuleBase)) { return $null } + # Prefer the edition-specific subfolder (Core for PS 7, Desktop for + # PS 5.1 / Windows PowerShell). Module layouts vary: + # ExchangeOnlineManagement\\netCore\Microsoft.Identity.Client.dll + # ExchangeOnlineManagement\\netFramework\Microsoft.Identity.Client.dll + # Microsoft.Graph.Authentication\\Dependencies\Core\Microsoft.Identity.Client.dll + # Microsoft.Graph.Authentication\\Dependencies\Desktop\Microsoft.Identity.Client.dll + $isCore = ($PSVersionTable.PSEdition -eq 'Core') + $preferred = if ($isCore) { @('netCore', 'Dependencies\Core', 'Core') } + else { @('netFramework', 'Dependencies\Desktop', 'Desktop') } + foreach ($sub in $preferred) { + $path = Join-Path $ModuleBase $sub + if (Test-Path $path) { + $hit = Get-ChildItem -Path $path -Filter 'Microsoft.Identity.Client.dll' -Recurse -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($hit) { return $hit } + } + } + # Fallback: any copy under the module base. + Get-ChildItem -Path $ModuleBase -Filter 'Microsoft.Identity.Client.dll' -Recurse -ErrorAction SilentlyContinue | + Select-Object -First 1 + } + + function _toVersion([string]$raw) { + if (-not $raw) { return $null } + $m = [regex]::Match($raw, '^\d+(\.\d+){1,3}') + if ($m.Success) { try { return [version]$m.Value } catch { return $null } } + return $null + } + + $exoMod = Get-Module ExchangeOnlineManagement -ListAvailable -ErrorAction SilentlyContinue | + Sort-Object Version -Descending | Select-Object -First 1 + if ($exoMod) { + $info.exoModuleVersion = $exoMod.Version.ToString() + $dll = _findMsalDll $exoMod.ModuleBase + if ($dll) { + $info.exoMsalDllPath = $dll.FullName + try { $info.exoMsalVersion = (Get-Item $dll.FullName).VersionInfo.FileVersion } catch {} + } + } + + $mgMod = Get-Module Microsoft.Graph.Authentication -ListAvailable -ErrorAction SilentlyContinue | + Sort-Object Version -Descending | Select-Object -First 1 + if ($mgMod) { + $info.graphModuleVersion = $mgMod.Version.ToString() + $dll = _findMsalDll $mgMod.ModuleBase + if ($dll) { + $info.graphMsalDllPath = $dll.FullName + try { $info.graphMsalVersion = (Get-Item $dll.FullName).VersionInfo.FileVersion } catch {} + } + } + + # An MSAL assembly already loaded in this process wins for the rest of + # the session, regardless of which module updates we recommend below. + # .NET cannot swap a loaded assembly in-place — the only remedy is to + # close the PowerShell window and start a fresh one. + try { + $loaded = [System.AppDomain]::CurrentDomain.GetAssemblies() | + Where-Object { $_.GetName().Name -eq 'Microsoft.Identity.Client' } | + Select-Object -First 1 + if ($loaded) { + $info.loadedMsalVersion = $loaded.GetName().Version.ToString() + try { $info.loadedMsalLocation = $loaded.Location } catch {} + } + } catch { } + + if (-not $info.exoMsalVersion -or -not $info.graphMsalVersion) { + # Only one of the two modules is installed (or its MSAL DLL couldn't + # be located). No in-process conflict is possible from this script. + return [pscustomobject]@{ + Compatible = $true + Issues = $issues + Info = $info + } + } + + $exoVer = _toVersion $info.exoMsalVersion + $graphVer = _toVersion $info.graphMsalVersion + + if ($exoVer -and $graphVer -and $exoVer -ne $graphVer) { + # Direction-agnostic: any mismatch is risky because MSAL has shipped + # breaking method-signature changes in both directions across recent + # 4.x releases. The connect helper's calling site decides what to do + # about it — either auto-apply the pre-load workaround (when MSAL is + # not yet loaded in this process) or recommend opening a fresh window. + $issues += ("MSAL DLL mismatch: ExchangeOnlineManagement v{0} ships Microsoft.Identity.Client v{1}, " + + "but Microsoft.Graph.Authentication v{2} ships v{3}. The two modules cannot share an AppDomain " + + "without the older / mismatched MSAL causing Connect-MgGraph to throw 'Method not found' for " + + "an MSAL method whose signature changed between those versions.") -f ` + $info.exoModuleVersion, $exoVer, $info.graphModuleVersion, $graphVer + } + + $loadedVer = _toVersion $info.loadedMsalVersion + if ($loadedVer -and $graphVer -and $loadedVer -ne $graphVer) { + $issues += ("Microsoft.Identity.Client v{0} is ALREADY loaded in this PowerShell process (from '{1}'), " + + "but Microsoft.Graph.Authentication v{2} was built against v{3}. .NET cannot swap a loaded " + + "assembly — close this PowerShell window, open a fresh one, and re-run this script.") -f ` + $loadedVer, $info.loadedMsalLocation, $info.graphModuleVersion, $graphVer + } + + [pscustomobject]@{ + Compatible = ($issues.Count -eq 0) + Issues = $issues + Info = $info + } +} + +function Test-IsAdmin { + try { + $current = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() + return $current.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } catch { + return $false + } +} + +function Test-CopilotDlpModuleReadiness { + <# + Checks whether the locally installed ExchangeOnlineManagement module + is recent enough for its IPPS REST proxy to expose the Copilot DLP + parameters (-Locations / -EnforcementPlanes) that + New-DlpCompliancePolicy needs for Microsoft 365 Copilot location + policies. + + Returns a PSCustomObject with: + * Eligible — $true when no blocking issues were found + * Issues — list of short human-readable problem descriptions + * Info — diagnostic hashtable for the run log / HTML report + + This is a NON-blocking pre-req check: it produces a Write-Warning + and a run-log entry. The hard backstop ("does the live IPPS proxy + actually have the parameters?") still lives in Setup-AIGovernance.ps1. + + Why a separate helper from Test-WamBrokerReadiness: + * WAM readiness gates EVERY run (single connect prompt). + * Copilot DLP readiness only matters when the AI governance step + is in scope, so the orchestrator opts in via -AIControlsInScope. + #> + [CmdletBinding()] + param() + + $issues = @() + $info = [ordered]@{ + moduleName = 'ExchangeOnlineManagement' + installed = 'not-installed' + installedPath = $null + minimumRequired = $script:CopilotDlpMinExoVersion.ToString() + meetsMinimum = $false + } + + $exoMod = Get-Module ExchangeOnlineManagement -ListAvailable -ErrorAction SilentlyContinue | + Sort-Object Version -Descending | Select-Object -First 1 + + if (-not $exoMod) { + $issues += "ExchangeOnlineManagement module is not installed. Install: Install-Module ExchangeOnlineManagement -Scope CurrentUser -Force" + return [pscustomobject]@{ Eligible = $false; Issues = $issues; Info = $info } + } + + $info.installed = $exoMod.Version.ToString() + $info.installedPath = $exoMod.ModuleBase + $info.meetsMinimum = ($exoMod.Version -ge $script:CopilotDlpMinExoVersion) + + if (-not $info.meetsMinimum) { + $issues += "ExchangeOnlineManagement v$($exoMod.Version) is older than the recommended minimum (v$($script:CopilotDlpMinExoVersion)) for Microsoft 365 Copilot DLP. The IPPS REST proxy this version builds at connect time does NOT expose the -Locations / -EnforcementPlanes parameters that New-DlpCompliancePolicy needs for Copilot DLP policies, so step [5/5] AI governance will fail. Update: Update-Module ExchangeOnlineManagement -Force (or, in an elevated prompt: Install-Module ExchangeOnlineManagement -Scope AllUsers -Force -AllowClobber). Close and reopen PowerShell after updating." + } + + [pscustomobject]@{ + Eligible = ($issues.Count -eq 0) + Issues = $issues + Info = $info + } +} + +function Test-GraphBetaCompat { + <# + Detects mismatched Microsoft.Graph.Authentication / Microsoft.Graph.Beta.* + module versions BEFORE the orchestrator's container-labels step + (step [5/5]) tries to call Get-MgBetaDirectorySetting and fails with: + + The 'Get-MgBetaDirectorySetting' command was found in the module + 'Microsoft.Graph.Beta.Identity.DirectoryManagement', but the + module could not be loaded due to the following error: + [Could not load file or assembly 'Microsoft.Graph.Authentication, + Version=2.36.1.0...'. Assembly with same name is already loaded] + + Why this matters + ---------------- + Each Microsoft.Graph.Beta.* sub-module's manifest declares a + RequiredModules entry that strict-pins Microsoft.Graph.Authentication + to the EXACT same version (e.g. Beta.Identity.DirectoryManagement + v2.36.1 requires Authentication = 2.36.1.0, not >= 2.36.1.0). When + a newer Microsoft.Graph.Authentication is already loaded — typically + after the operator runs 'Update-Module Microsoft.Graph.Authentication + -Force' to fix an MSAL issue WITHOUT also updating the Beta family — + Import-Module on the Beta sub-module fails because .NET can't load + a second copy of Microsoft.Graph.Authentication into the AppDomain. + + Returns a PSCustomObject with: + * Compatible — $true when the highest installed Beta sub-module's + required Authentication version matches one of the + installed Authentication versions AND the loaded + Authentication version, OR when the Beta sub-module + is not installed (Ensure-RequiredModule will install + a matching version on demand) + * Issues — list of human-readable problem descriptions + * Info — diagnostic hashtable for the run log + * FixCommand — exact PowerShell command to recover + #> + [CmdletBinding()] + param( + [string] $BetaModuleName = 'Microsoft.Graph.Beta.Identity.DirectoryManagement' + ) + + $issues = @() + $info = [ordered]@{ + graphAuthInstalled = @() + graphAuthLoadedVersion = $null + betaModuleName = $BetaModuleName + betaInstalled = @() + betaHighestVersion = $null + betaHighestRequiredAuth = $null + } + $fixCommand = $null + + # Inventory: every installed Microsoft.Graph.Authentication version. + $authMods = @(Get-Module Microsoft.Graph.Authentication -ListAvailable -ErrorAction SilentlyContinue | + Sort-Object Version -Descending) + $info.graphAuthInstalled = @($authMods | ForEach-Object { $_.Version.ToString() }) + + # Whichever Authentication version is currently loaded wins for the rest + # of the session (.NET cannot load a second copy of an assembly). + $loadedAuth = Get-Module Microsoft.Graph.Authentication -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($loadedAuth) { + $info.graphAuthLoadedVersion = $loadedAuth.Version.ToString() + } + + # Inventory: every installed copy of the Beta sub-module + its pinned Auth dep. + $betaMods = @(Get-Module $BetaModuleName -ListAvailable -ErrorAction SilentlyContinue | + Sort-Object Version -Descending) + foreach ($b in $betaMods) { + $authReq = $b.RequiredModules | Where-Object Name -eq 'Microsoft.Graph.Authentication' | Select-Object -First 1 + $info.betaInstalled += [ordered]@{ + version = $b.Version.ToString() + requiredAuthVer = if ($authReq) { $authReq.Version.ToString() } else { '(none)' } + modulePath = $b.ModuleBase + } + } + + if (-not $betaMods) { + # No Beta sub-module installed at all. Because the orchestrator + # bypasses Ensure-RequiredModule for the Beta module (Ensure has + # a silent-success bug that returns OK when the module is merely + # listed in Get-Module -ListAvailable even if Import-Module would + # fail), we MUST surface this as incompatible here. The caller's + # auto-install branch will then run Install-Module against the + # currently loaded/installed Auth version. Without this signal + # the caller skips install and the picker downstream finds no + # matching Beta version → throws "no-matching-version-installed" + # on a fresh machine where Beta was never installed. + $effectiveAuth = if ($info.graphAuthLoadedVersion) { + $info.graphAuthLoadedVersion + } elseif ($info.graphAuthInstalled.Count -gt 0) { + $info.graphAuthInstalled[0] + } else { + $null + } + if ($effectiveAuth) { + $fixCommand = "Install-Module $BetaModuleName -RequiredVersion $effectiveAuth -Scope CurrentUser -Force -AllowClobber" + } else { + $fixCommand = "Install-Module $BetaModuleName -Scope CurrentUser -Force -AllowClobber" + } + return [pscustomobject]@{ + Compatible = $false + Issues = @("$BetaModuleName is not installed (required for container labels at step [5/5]). Will be installed side-by-side against Microsoft.Graph.Authentication v$effectiveAuth.") + Info = $info + FixCommand = $fixCommand + } + } + + $highestBeta = $betaMods[0] + $info.betaHighestVersion = $highestBeta.Version.ToString() + $highestBetaAuthReq = $highestBeta.RequiredModules | + Where-Object Name -eq 'Microsoft.Graph.Authentication' | + Select-Object -First 1 + if ($highestBetaAuthReq) { + $info.betaHighestRequiredAuth = $highestBetaAuthReq.Version.ToString() + } + + # The check that matters: can Import-Module on the highest Beta succeed + # against the CURRENTLY LOADED Authentication version? + # If Authentication isn't loaded yet, fall back to: does the highest Auth + # installed match what the highest Beta requires? (Import-Module Beta + # will trigger Import-Module Authentication on its required version.) + $effectiveAuth = if ($info.graphAuthLoadedVersion) { + $info.graphAuthLoadedVersion + } elseif ($info.graphAuthInstalled.Count -gt 0) { + $info.graphAuthInstalled[0] + } else { + $null + } + + if (-not $effectiveAuth) { + # No Auth installed at all — Ensure-RequiredModule will handle it. + return [pscustomobject]@{ + Compatible = $true + Issues = @() + Info = $info + FixCommand = $null + } + } + + # PowerShell only honours strict-pinned RequiredModules — version must match exactly. + if ($info.betaHighestRequiredAuth -and $info.betaHighestRequiredAuth -ne $effectiveAuth) { + # See if any OTHER installed Beta version pins to the loaded/installed Auth. + $matchingBeta = $info.betaInstalled | Where-Object { $_.requiredAuthVer -eq $effectiveAuth } | Select-Object -First 1 + if ($matchingBeta) { + # A matching Beta version IS installed — Import-Module would pick the + # highest by default though, so we still need to either pin import or + # tell the user to remove/update. Treat as incompatible with hint. + $issues += "An installed copy of $BetaModuleName v$($matchingBeta.version) pins Microsoft.Graph.Authentication v$effectiveAuth (matches loaded), but the highest installed version v$($info.betaHighestVersion) pins v$($info.betaHighestRequiredAuth). PowerShell's Import-Module picks the highest by default, so the import will still fail." + $fixCommand = "Install-Module $BetaModuleName -RequiredVersion $effectiveAuth -Scope CurrentUser -Force -AllowClobber" + } else { + $issues += "$BetaModuleName v$($info.betaHighestVersion) (the highest installed) strict-pins Microsoft.Graph.Authentication v$($info.betaHighestRequiredAuth), but the loaded/installed Authentication version is v$effectiveAuth. Microsoft.Graph.Beta sub-modules require an EXACT version match in their manifest's RequiredModules. Step [5/5] container labels will fail when it calls Get-MgBetaDirectorySetting." + $fixCommand = "Install-Module $BetaModuleName -RequiredVersion $effectiveAuth -Scope CurrentUser -Force -AllowClobber" + } + } + + [pscustomobject]@{ + Compatible = ($issues.Count -eq 0) + Issues = $issues + Info = $info + FixCommand = $fixCommand + } +} + +function Resolve-SpoAdminUrlFromInputs { + [CmdletBinding()] + param( + [string] $ExplicitUrl, + [string] $DelegatedOrganization, + [string] $TenantAdminUpn + ) + + if ($ExplicitUrl) { + return [pscustomobject]@{ Url = $ExplicitUrl; Source = 'explicit (-SharePointAdminUrl)' } + } + + if ($DelegatedOrganization -and $DelegatedOrganization -match '^(?[A-Za-z0-9-]+)\.onmicrosoft\.com$') { + $prefix = $Matches.t + return [pscustomobject]@{ + Url = "https://$prefix-admin.sharepoint.com" + Source = "-DelegatedOrganization ($DelegatedOrganization)" + } + } + + if ($TenantAdminUpn -and $TenantAdminUpn -match '@(?[A-Za-z0-9-]+)\.onmicrosoft\.com$') { + $prefix = $Matches.t + return [pscustomobject]@{ + Url = "https://$prefix-admin.sharepoint.com" + Source = "admin UPN suffix ($TenantAdminUpn)" + } + } + + return $null +} + +function Invoke-SpoConnectWithFallback { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $SharePointAdminUrl, + [Parameter(Mandatory)] [string] $TenantAdminUpn + ) + + $spoConnected = $false + $spoProbeError = $null + try { + $current = Get-SPOTenant -ErrorAction Stop + if ($current) { $spoConnected = $true } + } catch { + $spoConnected = $false + $spoProbeError = $_.Exception.Message + } + + if ($spoConnected) { + Add-ConnectGuardLog -Service 'SPO' -Status 'Info' -ReasonKey 'reused' -Detail @{ + check = 'Get-SPOTenant' + expectedAdminUrl = $SharePointAdminUrl + actualTenantIdentity = 'unverified' + } + Write-Host "SharePoint Online: existing session reused." -ForegroundColor DarkGray + return + } + + Add-ConnectGuardLog -Service 'SPO' -Status 'Started' ` + -ReasonKey ($(if ($spoProbeError) { 'probe-failed' } else { 'no-existing-session' })) ` + -Detail @{ + check = 'Get-SPOTenant' + expectedAdminUrl = $SharePointAdminUrl + probeError = ($spoProbeError -as [string]) + } + + $spoMod = Get-Module Microsoft.Online.SharePoint.PowerShell -ErrorAction SilentlyContinue | + Select-Object -First 1 + if (-not $spoMod) { + $spoMod = Get-Module Microsoft.Online.SharePoint.PowerShell -ListAvailable | + Sort-Object Version -Descending | Select-Object -First 1 + } + $spoVersion = if ($spoMod -and $spoMod.Version) { $spoMod.Version.ToString() } else { 'unknown' } + $isCore = $PSVersionTable.PSEdition -eq 'Core' + + Write-Host "Connecting to SharePoint Online ($SharePointAdminUrl)..." -ForegroundColor Cyan + Write-Host (" PowerShell: {0} {1} | SPO module: {2}{3}" -f ` + $PSVersionTable.PSEdition, $PSVersionTable.PSVersion, $spoVersion, + $(if ($isCore) { ' (loaded via Windows PowerShell 5.1 proxy)' } else { '' })) -ForegroundColor DarkGray + + # Clear any stale/cached SPO session state before attempting a fresh + # connect — this avoids "No valid OAuth 2.0 authentication session + # exists" caused by a prior failed/expired token. + try { Disconnect-SPOService -ErrorAction SilentlyContinue } catch { } + + try { + Connect-SPOService -Url $SharePointAdminUrl -ErrorAction Stop + return + } catch { + $errMsg = $_.Exception.Message + + # ZT-pattern: detect MSAL DLL conflict — recommend a pwsh restart. + $spoConflict = @($_.Exception, $_.Exception.InnerException) | + Where-Object { $_ -is [System.MissingMethodException] -or $_ -is [System.IO.FileLoadException] } | + Select-Object -First 1 + $isMsalConflict = $spoConflict -and ($spoConflict.Message -like '*Microsoft.Identity.Client*' -or $spoConflict.Message -like '*Microsoft.IdentityModel*') + + # Detect "the PS 5.1 proxy couldn't find the SPO module" — happens when + # PS 7 has the module installed but PS 5.1 does not (PS 7's + # Install-Module installs to the PS 7 module path only). + $proxyModuleMissing = $errMsg -match 'no valid module file was found' -or ` + $errMsg -match 'module .* was not loaded' -or ` + $errMsg -match 'Could not find the module' + + $hints = @() + if ($isMsalConflict) { + $hints += "* DLL conflict on Microsoft.Identity.Client. Another module loaded a conflicting MSAL into this session before SharePoint." + $hints += " Fix: CLOSE this PowerShell window, open a fresh pwsh, and re-run the script. Do not import other Microsoft modules first." + } + if ($errMsg -match 'No valid OAuth') { + $hints += "* The sign-in account ($TenantAdminUpn) must hold the SharePoint Administrator (or Global Administrator) role on the customer tenant." + $hints += "* Make sure the browser sign-in pop-up is allowed and not blocked by your default browser, and complete MFA if prompted." + $hints += "* You can pre-authenticate manually first, then re-run this script: Connect-SPOService -Url $SharePointAdminUrl" + } + if ($isCore -and $proxyModuleMissing) { + $hints += "* The Windows PowerShell 5.1 proxy could not find 'Microsoft.Online.SharePoint.PowerShell'." + $hints += " PS 7's 'Install-Module' installs to the PS 7 module path only; the proxy runs under PS 5.1 and needs the module in its path too." + $hints += " Fix: open Windows PowerShell 5.1 (powershell.exe) once and run:" + $hints += " Install-Module Microsoft.Online.SharePoint.PowerShell -Scope CurrentUser -Force -AllowClobber" + $hints += " Then re-run this script from pwsh." + } + $hints += "* If your SPO module is old, run: Update-Module Microsoft.Online.SharePoint.PowerShell -Force" + + $msg = "Connect-SPOService failed: $errMsg" + if ($hints) { $msg += "`nTroubleshooting:`n " + ($hints -join "`n ") } + throw $msg + } +} + +function Ensure-RequiredModule { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Name, + [string] $RequiredCmdlet, + [switch] $AutoInstall, + [switch] $NonInteractive, + [switch] $UseWindowsPowerShellProxy + ) + + # On PS Core, the Microsoft.Online.SharePoint.PowerShell module is a Windows + # Desktop assembly. Loading it natively into PS 7 works for delegated auth + # but breaks certificate-based auth and can clash with EXO's bundled + # Microsoft.Identity.Client.dll. The Zero Trust Assessment connect helper + # solves this by loading SPO via -UseWindowsPowerShell (implicit WinPS 5.1 + # remoting) AFTER EXO is already connected. We follow the same pattern. + $proxy = $UseWindowsPowerShellProxy.IsPresent -and ($PSVersionTable.PSEdition -eq 'Core') + + $available = Get-Module -ListAvailable -Name $Name -ErrorAction SilentlyContinue + + if ($available) { + try { + if ($proxy) { + # WinCompat sessions use a stock PSModulePath that doesn't include the + # PS 7 user-module location. Discover the module's full path on the + # PS 7 side and pass it explicitly so the proxy import loads from the + # same on-disk location regardless of WinCompat's own PSModulePath. + $availMod = Get-Module -ListAvailable -Name $Name -ErrorAction SilentlyContinue | + Sort-Object Version -Descending | + Select-Object -First 1 + if ($availMod -and $availMod.Path) { + Import-Module -UseWindowsPowerShell -FullyQualifiedName $availMod.Path ` + -DisableNameChecking -WarningAction SilentlyContinue -ErrorAction Stop | Out-Null + } else { + Import-Module $Name -UseWindowsPowerShell -DisableNameChecking ` + -WarningAction SilentlyContinue -ErrorAction Stop | Out-Null + } + } else { + Import-Module $Name -DisableNameChecking -ErrorAction Stop | Out-Null + } + } catch { + Write-Verbose "Import-Module '$Name' failed: $($_.Exception.Message)" + } + } + + $cmdletOk = if ($RequiredCmdlet) { + [bool] (Get-Command $RequiredCmdlet -ErrorAction SilentlyContinue) + } else { + [bool] $available + } + + if ($cmdletOk) { return } + + Write-Warning "Required module '$Name' is missing or its cmdlets cannot load$(if ($RequiredCmdlet) { " ($RequiredCmdlet not found)" })." + + $shouldInstall = $AutoInstall.IsPresent + if (-not $shouldInstall -and $NonInteractive.IsPresent) { + $manual = "Install-Module $Name -Scope CurrentUser -Force -AllowClobber" + throw "Module '$Name' is required but is not installed and -NonInteractive is set (cannot prompt). Either pre-install the module or re-run with -AutoInstallModules:`n $manual" + } + if (-not $shouldInstall) { + $resp = Read-Host "Install '$Name' from PSGallery now to the current user scope? [Y/n]" + if ([string]::IsNullOrWhiteSpace($resp) -or $resp -match '^(y|yes)$') { + $shouldInstall = $true + } + } + + if (-not $shouldInstall) { + $manual = "Install-Module $Name -Scope CurrentUser -Force -AllowClobber" + throw "Module '$Name' is required but was not installed. Install manually and re-run:`n $manual" + } + + $isAdmin = Test-IsAdmin + if (-not $isAdmin) { + Write-Host " Note: this PowerShell session is NOT elevated." -ForegroundColor DarkGray + Write-Host " Installing to -Scope CurrentUser (no admin rights required)." -ForegroundColor DarkGray + Write-Host " If install fails with an access-denied error, re-run PowerShell as Administrator." -ForegroundColor DarkGray + } + + Write-Host "Installing '$Name' (Scope: CurrentUser)..." -ForegroundColor Cyan + $installError = $null + try { + Install-Module -Name $Name -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop + } catch { + $installError = $_ + } + + if ($installError) { + $hint = if ($isAdmin) { + "Try installing for all users:`n Install-Module $Name -Scope AllUsers -Force -AllowClobber" + } else { + "Close this window, right-click PowerShell -> 'Run as Administrator', then re-run this script (or install manually with: Install-Module $Name -Scope CurrentUser -Force -AllowClobber)." + } + throw "Failed to install module '$Name': $($installError.Exception.Message)`n$hint" + } + + try { + if ($proxy) { + # WinCompat sessions use a stock PSModulePath that doesn't include the + # PS 7 user-module location. Discover the module's full path on the + # PS 7 side and pass it explicitly so the proxy import loads from the + # same on-disk location regardless of WinCompat's own PSModulePath. + $availMod = Get-Module -ListAvailable -Name $Name -ErrorAction SilentlyContinue | + Sort-Object Version -Descending | + Select-Object -First 1 + if ($availMod -and $availMod.Path) { + Import-Module -UseWindowsPowerShell -FullyQualifiedName $availMod.Path ` + -DisableNameChecking -WarningAction SilentlyContinue -ErrorAction Stop | Out-Null + } else { + Import-Module $Name -UseWindowsPowerShell -DisableNameChecking ` + -WarningAction SilentlyContinue -ErrorAction Stop | Out-Null + } + } else { + Import-Module $Name -DisableNameChecking -ErrorAction Stop | Out-Null + } + } catch { + throw "Module '$Name' was installed but failed to import: $($_.Exception.Message)" + } + + if ($RequiredCmdlet -and -not (Get-Command $RequiredCmdlet -ErrorAction SilentlyContinue)) { + throw "Module '$Name' was installed but cmdlet '$RequiredCmdlet' is still not available. Restart PowerShell and re-run." + } + + Write-Host " Installed and loaded '$Name'." -ForegroundColor Green +} + +# --------------------------------------------------------------------------- +# WAM (Windows Authentication Manager) broker readiness +# --------------------------------------------------------------------------- +# Run BEFORE any module load / connect so the user gets a clear up-front +# explanation when prompts will be heavier than expected. This is observation +# only — we do not change connect arguments. WAM is on by default in current +# Microsoft.Graph.Authentication (>= 2.x) and ExchangeOnlineManagement +# (>= 3.3); the helper just surfaces blockers (non-Windows, SYSTEM, etc.). +$wamReadiness = Test-WamBrokerReadiness +if ($wamReadiness.Eligible) { + Write-Host "WAM broker available: sign-in prompts should be reduced (often 1-click 'Continue')." -ForegroundColor DarkGray + Write-Host " Note: first-time consent, tenant switches, missing scopes, stale sessions, or SharePoint can still trigger additional prompts." -ForegroundColor DarkGray +} else { + Write-Warning "Windows Authentication Manager (WAM) broker is not fully usable in this environment." + Write-Warning "Expect a browser sign-in popup per service (Exchange Online, Security & Compliance, SharePoint, Microsoft Graph)." + foreach ($issue in $wamReadiness.Issues) { + Write-Warning " * $issue" + } + Write-Warning " See Products/Purview/README.md -> 'Sign-in prompts (WAM broker)' for guidance." +} +Add-ConnectGuardLog -Service 'Startup' -Status 'Info' ` + -ReasonKey ($(if ($wamReadiness.Eligible) { 'wam-ready' } else { 'wam-not-ready' })) ` + -Detail $wamReadiness.Info + +# --------------------------------------------------------------------------- +# MSAL DLL compatibility (Microsoft.Identity.Client.dll) between EXO and Graph +# --------------------------------------------------------------------------- +# ExchangeOnlineManagement and Microsoft.Graph.Authentication each ship their +# OWN copy of Microsoft.Identity.Client.dll. When both are imported into the +# same PowerShell process, the CLR binds to whichever copy was loaded FIRST. +# This script imports EXO before Graph, so EXO's MSAL wins the AppDomain. If +# the two modules disagree on MSAL version (a recurring source of breakage — +# MSAL has shipped method-signature changes in both 4.82 -> 4.83 and earlier) +# Connect-MgGraph throws a cryptic 'Method not found' exception that reads +# like an auth failure but is actually a DLL-binding mismatch. We detect it +# up-front so the operator gets actionable guidance BEFORE the deploy spends +# minutes connecting to everything else only to die at the Graph step. +# Observation only — we never block; the connect call still runs and remains +# the definitive backstop. +$msalCompat = Test-MsalDllCompatibility +if ($msalCompat.Compatible) { + if ($msalCompat.Info.exoMsalVersion -and $msalCompat.Info.graphMsalVersion) { + Write-Host ("MSAL DLL check: ExchangeOnlineManagement and Microsoft.Graph.Authentication both ship Microsoft.Identity.Client v{0}." -f ` + $msalCompat.Info.graphMsalVersion) -ForegroundColor DarkGray + } +} else { + Write-Warning "Microsoft.Identity.Client (MSAL) DLL mismatch between Exchange Online and Microsoft Graph modules." + Write-Warning "This is the #1 cause of 'InteractiveBrowserCredential authentication failed: Method not found' errors during Connect-MgGraph." + foreach ($issue in $msalCompat.Issues) { + Write-Warning " * $issue" + } + if ($msalCompat.Info.loadedMsalVersion) { + # MSAL is already loaded in this process — we can't change which + # version is bound. The user must restart PowerShell. + Write-Warning " Action required: close this PowerShell window and open a fresh one — .NET cannot swap a loaded MSAL assembly in-place." + } elseif ($ConnectGraph) { + # We mitigate at runtime by connecting Microsoft Graph FIRST (before + # ExchangeOnlineManagement loads). This is the same pattern used by + # microsoft/zerotrustassessment's Connect-ZtAssessment. When Graph + # loads its bundled MSAL into the AppDomain first, EXO loads on top + # and its MSAL calls still succeed — verified end-to-end. + # If MSAL is somehow loaded by another module before us, the JIT will + # throw 'Method not found' and there is NO runtime workaround + # (-UseDeviceCode does NOT help — DeviceCodeCredential invokes the + # same broken MSAL method overload). In that case the only fix is + # to close pwsh and reopen. + Write-Warning " Mitigation 1: this script connects Microsoft Graph FIRST so Graph's MSAL wins the AppDomain race (Zero Trust Assessment pattern). This prevents 'Method not found' in Connect-MgGraph." + Write-Warning " Mitigation 2: Connect-ExchangeOnline / Connect-IPPSSession will be invoked with -DisableWAM so EXO falls back to the standard interactive browser flow. This avoids 'NullReferenceException at Microsoft.Identity.Client.Platforms.Features.RuntimeBroker.RuntimeBroker..ctor', which fires when EXO's WAM code path is loaded against Graph's older MSAL." + Write-Warning " If a 'Method not found' or RuntimeBroker NullReferenceException still fires below, CLOSE this PowerShell window and open a fresh one — runtime mitigation is not possible once MSAL is loaded with the wrong version." + } else { + Write-Warning " Manual fix: update both modules in a FRESH PowerShell window — 'Update-Module ExchangeOnlineManagement -Force; Update-Module Microsoft.Graph.Authentication -Force' — then re-run." + } +} +# Whether subsequent EXO/IPPS Connect calls should pass -DisableWAM to dodge +# the WAM RuntimeBroker NullReferenceException that fires when EXO 3.10's WAM +# code path runs against Graph's older MSAL. We only opt out of WAM when a +# mismatch is detected — matched MSAL versions keep the 1-click WAM UX. +$script:MsalRequiresDisableWam = -not $msalCompat.Compatible +Add-ConnectGuardLog -Service 'Startup' -Status ($(if ($msalCompat.Compatible) { 'Info' } else { 'Retried' })) ` + -ReasonKey ($(if ($msalCompat.Compatible) { 'msal-aligned' } else { 'msal-mismatch' })) ` + -Detail $msalCompat.Info + +# --------------------------------------------------------------------------- +# Copilot DLP module readiness (pre-req, non-blocking) +# --------------------------------------------------------------------------- +# AI governance / Copilot DLP is default-on for E5 / Purview Suite tenants. +# Step [5/5] in the orchestrator calls New-DlpCompliancePolicy with +# -Locations / -EnforcementPlanes, which only appear on the IPPS REST proxy +# built by ExchangeOnlineManagement v3.9.0+. On older modules the proxy +# silently lacks the parameters and Setup-AIGovernance throws a clear +# instruction to update. We surface the same instruction up-front so the +# operator sees it BEFORE the run blows 5 minutes setting up everything else. +# Observation only — never throws; the AI step still runs and provides the +# definitive backstop. +if ($AIControlsInScope) { + $copilotDlpReadiness = Test-CopilotDlpModuleReadiness + if ($copilotDlpReadiness.Eligible) { + Write-Host ("ExchangeOnlineManagement v{0} meets the Copilot DLP minimum (v{1}+)." -f ` + $copilotDlpReadiness.Info.installed, $copilotDlpReadiness.Info.minimumRequired) -ForegroundColor DarkGray + } else { + Write-Warning "Copilot DLP pre-req check: your ExchangeOnlineManagement module is older than the recommended minimum for AI governance (step [5/5])." + foreach ($issue in $copilotDlpReadiness.Issues) { + Write-Warning " * $issue" + } + Write-Warning " This is a WARNING only — the rest of the deploy continues. Step [5/5] will fail with the same guidance unless you update the module before that step runs." + Write-Warning " See the HTML report 'Module currency' section for copy-pasteable install commands." + } + Add-ConnectGuardLog -Service 'CopilotDlp' -Status ($(if ($copilotDlpReadiness.Eligible) { 'Info' } else { 'Retried' })) ` + -ReasonKey ($(if ($copilotDlpReadiness.Eligible) { 'exo-version-ok' } else { 'exo-version-below-copilot-dlp-minimum' })) ` + -Detail $copilotDlpReadiness.Info +} + +# --------------------------------------------------------------------------- +# Module checks (auto-install on demand) +# --------------------------------------------------------------------------- +# Following the Zero Trust Assessment pattern: load EXO module up front, +# but defer the SPO module import until AFTER EXO is connected. SPO's WinPS +# proxy import has a chance of perturbing PS 7's AppDomain — doing it AFTER +# EXO has already loaded its MSAL is the safest order. +# +# MSAL mismatch handling (verified end-to-end + matches the pattern in +# microsoft/zerotrustassessment's Connect-ZtAssessment): +# ExchangeOnlineManagement and Microsoft.Graph.Authentication each ship +# their OWN copy of Microsoft.Identity.Client.dll, and PowerShell loads +# them lazily on first cmdlet call. Once one is loaded with a given +# strong name + public key, .NET binds ALL subsequent requests for that +# simple name to the already-loaded version — even if a different +# version is requested. So whichever module CONNECTS first wins. +# +# * If EXO 3.10's MSAL 4.83 loads first, Graph 2.37's +# InteractiveBrowserCredential ctor invokes +# BaseAbstractApplicationBuilder.WithLogging(IIdentityLogger, Boolean) +# which moved between 4.82 and 4.83 → "Method not found". +# (DeviceCodeCredential hits the SAME ctor path → -UseDeviceCode does +# NOT help.) +# * Pre-loading Graph's older MSAL via [Reflection.Assembly]::LoadFrom +# does NOT work either — EXO's own module then refuses to load with +# FileLoadException 0x80131040 ("manifest does not match"). +# +# The fix is to connect Microsoft Graph FIRST, so Graph's MSAL wins the +# AppDomain race. EXO's MSAL calls remain forwards-compatible enough to +# work on Graph's older MSAL (validated: Connect-ExchangeOnline reaches +# the MSAL token request without binding errors). This is the order +# Connect-PurviewServices uses below. +# +# If MSAL was already loaded into the process by another module before +# this script ran, there is no runtime fix — the operator must restart +# pwsh. We detect that case in the pre-flight check above. + +Ensure-RequiredModule -Name 'ExchangeOnlineManagement' ` + -RequiredCmdlet 'Connect-ExchangeOnline' ` + -AutoInstall:$AutoInstallModules ` + -NonInteractive:$NonInteractive + +if ($ConnectGraph) { + # Connect-MgGraph / Get-MgContext live in Microsoft.Graph.Authentication. + Ensure-RequiredModule -Name 'Microsoft.Graph.Authentication' ` + -RequiredCmdlet 'Connect-MgGraph' ` + -AutoInstall:$AutoInstallModules ` + -NonInteractive:$NonInteractive + # The Beta directory management module is needed for setting Group.Unified + # values used by container labels (step [5/5] orchestrator). Microsoft.Graph.Beta.* + # sub-modules strict-pin Microsoft.Graph.Authentication to an EXACT version in + # their RequiredModules manifest. If the installed Beta version pins a different + # Auth version than what's already loaded, Import-Module fails with: + # Could not load file or assembly 'Microsoft.Graph.Authentication, Version=X.Y.Z.0'. + # Assembly with same name is already loaded + # This typically happens after the operator runs 'Update-Module + # Microsoft.Graph.Authentication -Force' (e.g. to fix the MSAL/EXO issue) WITHOUT + # also updating the Beta family. + # + # Recovery strategy: this is a deterministic, non-destructive self-heal + # (installing one specific version of a sub-module the operator already + # has other versions of). It runs UNCONDITIONALLY when a mismatch is + # detected — matching the existing self-heal pattern used for PSGallery + # trust restore (#11). If PSGallery is unreachable, we degrade + # gracefully with the exact fix command. -NonInteractive operators in + # locked-down environments who don't want auto-install can pre-pin the + # matching Beta version themselves. + $betaCompat = Test-GraphBetaCompat -BetaModuleName 'Microsoft.Graph.Beta.Identity.DirectoryManagement' + $targetAuthVer = if ($betaCompat.Info.graphAuthLoadedVersion) { $betaCompat.Info.graphAuthLoadedVersion } else { $betaCompat.Info.graphAuthInstalled | Select-Object -First 1 } + if (-not $betaCompat.Compatible) { + Write-Warning "Microsoft.Graph.Beta module version mismatch detected (would break container labels at step [5/5])." + foreach ($issue in $betaCompat.Issues) { + Write-Warning " * $issue" + } + if ($targetAuthVer) { + Write-Host " Self-healing: installing Microsoft.Graph.Beta.Identity.DirectoryManagement v$targetAuthVer side-by-side to match the loaded Authentication module..." -ForegroundColor Cyan + try { + Install-Module -Name 'Microsoft.Graph.Beta.Identity.DirectoryManagement' ` + -RequiredVersion $targetAuthVer ` + -Scope CurrentUser ` + -Force ` + -AllowClobber ` + -ErrorAction Stop + Write-Host " Installed. Re-validating..." -ForegroundColor DarkGray + $betaCompat = Test-GraphBetaCompat -BetaModuleName 'Microsoft.Graph.Beta.Identity.DirectoryManagement' + if ($betaCompat.Compatible) { + Write-Host " Microsoft.Graph.Beta v$targetAuthVer now aligned with Microsoft.Graph.Authentication v$targetAuthVer." -ForegroundColor Green + Add-ConnectGuardLog -Service 'GraphBeta' -Status 'Info' -ReasonKey 'auto-installed-matching-version' -Detail $betaCompat.Info + } else { + Add-ConnectGuardLog -Service 'GraphBeta' -Status 'Retried' -ReasonKey 'auto-install-incomplete' -Detail $betaCompat.Info + } + } catch { + Write-Warning " Auto-install failed: $($_.Exception.Message)" + Write-Warning " Manual fix: $($betaCompat.FixCommand)" + Write-Warning " Then re-run this script." + Add-ConnectGuardLog -Service 'GraphBeta' -Status 'Failed' -ReasonKey 'auto-install-failed' -Detail @{ + error = $_.Exception.Message + fixCommand = $betaCompat.FixCommand + } + throw "Microsoft.Graph.Beta sub-module version mismatch and auto-install failed. Run: $($betaCompat.FixCommand)" + } + } else { + $cmd = if ($betaCompat.FixCommand) { $betaCompat.FixCommand } else { "Update-Module Microsoft.Graph.Beta.Identity.DirectoryManagement -Force" } + Add-ConnectGuardLog -Service 'GraphBeta' -Status 'Failed' -ReasonKey 'version-pin-mismatch-no-target' -Detail @{ + betaCompat = $betaCompat.Info + fixCommand = $betaCompat.FixCommand + } + throw "Microsoft.Graph.Beta sub-module version mismatch but Authentication version could not be resolved. Manual fix: $cmd" + } + } else { + Add-ConnectGuardLog -Service 'GraphBeta' -Status 'Info' -ReasonKey 'version-pin-aligned' -Detail $betaCompat.Info + } + + # Force the Beta module import to the version whose Auth pin matches the + # loaded/installed Authentication version. Without this, PowerShell's + # default Import-Module picks the highest-installed Beta version — which + # may still be the mismatched copy when older Beta versions are present + # alongside the freshly-installed matching one. + $betaMatch = $null + if ($targetAuthVer) { + $betaMatch = Get-Module -ListAvailable -Name 'Microsoft.Graph.Beta.Identity.DirectoryManagement' -ErrorAction SilentlyContinue | + Where-Object { + $req = $_.RequiredModules | Where-Object Name -eq 'Microsoft.Graph.Authentication' | Select-Object -First 1 + $req -and $req.Version.ToString() -eq $targetAuthVer + } | + Sort-Object Version -Descending | + Select-Object -First 1 + } + if ($betaMatch) { + try { + Import-Module -Name 'Microsoft.Graph.Beta.Identity.DirectoryManagement' ` + -RequiredVersion $betaMatch.Version ` + -DisableNameChecking -ErrorAction Stop | Out-Null + Add-ConnectGuardLog -Service 'GraphBeta' -Status 'Info' -ReasonKey 'imported-matching-version' -Detail @{ + importedVersion = $betaMatch.Version.ToString() + requiredAuthVer = $targetAuthVer + } + } catch { + Write-Warning "Failed to import Microsoft.Graph.Beta.Identity.DirectoryManagement v$($betaMatch.Version): $($_.Exception.Message)" + throw + } + } else { + # No matching version installed (and auto-install above either threw + # or no target was resolvable). Surface a clear actionable error + # rather than falling through to a PowerShell auto-load failure + # later in the deploy. + $cmd = if ($betaCompat -and $betaCompat.FixCommand) { + $betaCompat.FixCommand + } else { + "Install-Module Microsoft.Graph.Beta.Identity.DirectoryManagement -RequiredVersion $targetAuthVer -Scope CurrentUser -Force -AllowClobber" + } + Add-ConnectGuardLog -Service 'GraphBeta' -Status 'Failed' -ReasonKey 'no-matching-version-installed' -Detail @{ + requiredAuthVer = $targetAuthVer + fixCommand = $cmd + } + throw "No installed Microsoft.Graph.Beta.Identity.DirectoryManagement version matches the loaded Microsoft.Graph.Authentication v$targetAuthVer. Manual fix: $cmd" + } +} + +# --------------------------------------------------------------------------- +# Microsoft Graph (Beta) — connect FIRST (ZTA pattern) +# --------------------------------------------------------------------------- +# Connect Microsoft Graph BEFORE Exchange Online so Graph's bundled +# Microsoft.Identity.Client.dll (MSAL) wins the AppDomain race. EXO's +# MSAL surface is forwards-compatible enough to work on Graph's MSAL, but +# Graph 2.37's InteractiveBrowserCredential / DeviceCodeCredential ctors +# invoke method overloads that don't exist on EXO 3.10's newer MSAL — so +# the order matters and Graph MUST go first. This matches the pattern +# used by microsoft/zerotrustassessment's Connect-ZtAssessment. +if ($ConnectGraph) { + # GDAP FIX: When the deploy targets a customer via GDAP (-DelegatedOrganization), + # the Connect-MgGraph -TenantId MUST be the CUSTOMER tenant, not the partner + # tenant. The previous code split the admin UPN suffix + # (e.g. 'partnerAdmin@fabrikam.onmicrosoft.com' -> 'fabrikam.onmicrosoft.com') + # and silently authed Graph into the partner tenant — so license auto-detect + # and container-label setup ran against the wrong tenant on every partner-led run. + $targetTenantDomain = if ($DelegatedOrganization) { + $DelegatedOrganization + } else { + ($TenantAdminUpn -split '@')[-1] + } + # Normalise scope set (drop empties / dupes, preserve order) before reuse-check. + $requiredScopes = @($GraphScopes | Where-Object { $_ } | Select-Object -Unique) + if ($requiredScopes.Count -eq 0) { $requiredScopes = @('Organization.Read.All') } + $graphConnected = $false + $graphGuardReason = $null + $graphGuardDetail = @{ + expectedUpn = $TenantAdminUpn + expectedTenantDomain = $targetTenantDomain + expectedScopes = ($requiredScopes -join ',') + } + try { + $ctx = Get-MgContext -ErrorAction Stop + # Reuse only when the cached session belongs to the SAME admin, has the + # required scopes, AND (for GDAP) is authenticated against the customer + # tenant. A stale session for a different tenant produces a "Selected + # user account does not exist in tenant" popup the moment any Graph + # cmdlet triggers an MSAL token refresh. + $cachedScopes = @($ctx.Scopes) + $missingScopes = @($requiredScopes | Where-Object { $cachedScopes -notcontains $_ }) + if ($ctx -and ` + $ctx.Account -and ($ctx.Account -ieq $TenantAdminUpn) -and ` + $missingScopes.Count -eq 0) { + + $tenantOk = $true + $tenantCheckFailureKey = $null + if ($DelegatedOrganization) { + # Confirm the cached session is actually authed against the customer + # tenant by listing verifiedDomains on /organization and looking for + # the DelegatedOrganization. Mismatch ⇒ a stale partner-tenant session. + try { + $org = Invoke-MgGraphRequest -Method GET ` + -Uri 'https://graph.microsoft.com/v1.0/organization?$select=verifiedDomains' ` + -ErrorAction Stop + $domains = @() + foreach ($o in @($org.value)) { + foreach ($d in @($o.verifiedDomains)) { if ($d.name) { $domains += $d.name.ToLowerInvariant() } } + } + $tenantOk = $domains -contains $DelegatedOrganization.ToLowerInvariant() + if (-not $tenantOk) { $tenantCheckFailureKey = 'tenant-domain-mismatch' } + } catch { + $tenantOk = $false + $tenantCheckFailureKey = 'tenant-verification-failed' + $graphGuardDetail.tenantVerifyError = $_.Exception.Message + } + } + + if ($tenantOk) { + $graphConnected = $true + $graphGuardReason = 'reused' + $graphGuardDetail.cachedAccount = $ctx.Account + $graphGuardDetail.cachedTenantId = ($ctx.TenantId -as [string]) + } else { + $graphGuardReason = $tenantCheckFailureKey + $graphGuardDetail.cachedAccount = $ctx.Account + $graphGuardDetail.cachedTenantId = ($ctx.TenantId -as [string]) + $graphGuardDetail.actualContainsExpected = $false + Write-Host "Microsoft Graph: cached session is not authed against '$DelegatedOrganization'; reconnecting." -ForegroundColor DarkYellow + Add-ConnectGuardLog -Service 'Graph' -Status 'Retried' -ReasonKey $graphGuardReason -Detail $graphGuardDetail + try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch {} + } + } elseif ($ctx) { + $cachedAcct = if ($ctx.Account) { $ctx.Account } else { '(no account)' } + if ($missingScopes.Count -gt 0 -and $ctx.Account -ieq $TenantAdminUpn) { + $graphGuardReason = 'missing-scopes' + $graphGuardDetail.cachedAccount = $cachedAcct + $graphGuardDetail.missingScopes = ($missingScopes -join ',') + Write-Host "Microsoft Graph: cached session for '$cachedAcct' is missing required scopes ($($missingScopes -join ', ')); reconnecting." -ForegroundColor DarkYellow + } else { + $graphGuardReason = 'account-mismatch' + $graphGuardDetail.cachedAccount = $cachedAcct + Write-Host "Microsoft Graph: discarding cached session for '$cachedAcct' (does not match '$TenantAdminUpn' or required scopes)." -ForegroundColor DarkYellow + } + Add-ConnectGuardLog -Service 'Graph' -Status 'Retried' -ReasonKey $graphGuardReason -Detail $graphGuardDetail + try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch {} + } else { + $graphGuardReason = 'no-context' + } + } catch { + $graphConnected = $false + $graphGuardReason = 'get-mgcontext-failed' + $graphGuardDetail.error = $_.Exception.Message + } + + if ($graphConnected) { + Add-ConnectGuardLog -Service 'Graph' -Status 'Info' -ReasonKey 'reused' -Detail $graphGuardDetail + Write-Host "Microsoft Graph: existing session reused (account + tenant + scopes match)." -ForegroundColor DarkGray + } else { + $startedReason = if ($graphGuardReason) { "reconnect-$graphGuardReason" } else { 'no-existing-session' } + Add-ConnectGuardLog -Service 'Graph' -Status 'Started' -ReasonKey $startedReason -Detail $graphGuardDetail + Write-Host ("Connecting to Microsoft Graph (Beta) for tenant {0} with scopes: {1}..." -f $targetTenantDomain, ($requiredScopes -join ', ')) -ForegroundColor Cyan + # ZTA pattern: catch MissingMethodException from Microsoft.Identity + # and emit the only fix that actually works — restart pwsh. No + # runtime workaround exists once MSAL has been loaded with the + # "wrong" version (including -UseDeviceCode, which hits the same + # broken ctor path). + try { + Connect-MgGraph -TenantId $targetTenantDomain -Scopes $requiredScopes -NoWelcome -ErrorAction Stop + } catch { + $methodNotFound = $null + if ($_.Exception -is [System.MissingMethodException]) { + $methodNotFound = $_.Exception + } elseif ($_.Exception.InnerException -is [System.MissingMethodException]) { + $methodNotFound = $_.Exception.InnerException + } elseif ($_.Exception.Message -match 'Method not found.*Microsoft\.Identity') { + # Some .NET versions wrap the inner MissingMethodException in + # a generic AuthenticationFailedException — message-match it. + $methodNotFound = $_.Exception + } + if ($methodNotFound) { + Write-Warning "" + Write-Warning "Microsoft Graph sign-in failed with a Microsoft.Identity.Client (MSAL) DLL conflict." + Write-Warning "This happens when another module loaded its own MSAL into this PowerShell process before Microsoft Graph could load its own." + Write-Warning "" + Write-Warning "There is NO runtime workaround once MSAL is loaded with the wrong version:" + Write-Warning " * -UseDeviceCode does NOT help (it hits the same broken MSAL method)." + Write-Warning " * Pre-loading the other MSAL via [Reflection.Assembly]::LoadFrom does NOT help (EXO refuses)." + Write-Warning "" + Write-Warning "Fix: CLOSE this PowerShell window, open a FRESH pwsh, and re-run this script." + Write-Warning " Do not import Microsoft.Graph.*, Az.*, PnP.PowerShell or ExchangeOnlineManagement in the new window before running this script." + Add-ConnectGuardLog -Service 'Graph' -Status 'Failed' -ReasonKey 'msal-method-not-found-restart-pwsh' -Detail @{ + error = $methodNotFound.Message + exoModule = $msalCompat.Info.exoModuleVersion + exoMsal = $msalCompat.Info.exoMsalVersion + graphModule = $msalCompat.Info.graphModuleVersion + graphMsal = $msalCompat.Info.graphMsalVersion + loadedMsal = $msalCompat.Info.loadedMsalVersion + } + } + throw + } + } +} + +# --------------------------------------------------------------------------- +# Exchange Online — connect AFTER Graph (Graph-first ordering preserves MSAL) +# --------------------------------------------------------------------------- +# Reuse an existing EXO session ONLY when it matches BOTH the target admin UPN +# AND the GDAP delegated organisation. Without this guard, back-to-back deploys +# against different customer tenants (or after a partner-tenant context switch) +# silently reuse the stale session and every Set-* call runs against the WRONG +# tenant. +$exoConnected = $false +$staleExo = @() +$exoLookupOk = $true +try { + $exoSessions = @(Get-ConnectionInformation -ErrorAction Stop | + Where-Object { $_.State -eq 'Connected' -and $_.TokenStatus -eq 'Active' -and $_.Name -like 'ExchangeOnline*' -and $_.ConnectionUri -notlike '*compliance.protection.outlook.com*' }) + foreach ($s in $exoSessions) { + $upnOk = $s.UserPrincipalName -ieq $TenantAdminUpn + $delegOk = if ($DelegatedOrganization) { + $s.DelegatedOrganization -ieq $DelegatedOrganization + } else { + [string]::IsNullOrEmpty($s.DelegatedOrganization) + } + if ($upnOk -and $delegOk) { $exoConnected = $true } + else { + $reasonKey = if (-not $upnOk -and -not $delegOk) { 'upn-and-deleg-org-mismatch' } + elseif (-not $upnOk) { 'upn-mismatch' } + else { 'deleg-org-mismatch' } + Add-ConnectGuardLog -Service 'EXO' -Status 'Retried' -ReasonKey $reasonKey -Detail @{ + expectedUpn = $TenantAdminUpn + actualUpn = ($s.UserPrincipalName -as [string]) + expectedDelegatedOrg = ($DelegatedOrganization -as [string]) + actualDelegatedOrg = ($s.DelegatedOrganization -as [string]) + } + $staleExo += $s + } + } +} catch { + $exoConnected = $false + $exoLookupOk = $false + Add-ConnectGuardLog -Service 'EXO' -Status 'Info' -ReasonKey 'get-connectioninformation-failed' -Detail @{ + error = $_.Exception.Message + } +} + +if ($staleExo.Count -gt 0) { + $targetDesc = if ($DelegatedOrganization) { "$TenantAdminUpn -> $DelegatedOrganization" } else { $TenantAdminUpn } + Write-Host "Discarding $($staleExo.Count) stale Exchange Online session(s) (target: $targetDesc)." -ForegroundColor DarkYellow + foreach ($s in $staleExo) { + try { + Disconnect-ExchangeOnline -ConnectionId $s.ConnectionId -Confirm:$false -ErrorAction SilentlyContinue | Out-Null + } catch { + Write-Verbose " Could not disconnect stale EXO session $($s.ConnectionId): $($_.Exception.Message)" + } + } +} + +if ($exoConnected) { + Add-ConnectGuardLog -Service 'EXO' -Status 'Info' -ReasonKey 'reused' -Detail @{ + expectedUpn = $TenantAdminUpn + expectedDelegatedOrg = ($DelegatedOrganization -as [string]) + } + Write-Host "Exchange Online: existing session reused (UPN + delegated-org match)." -ForegroundColor DarkGray +} else { + $exoColdReason = if (-not $exoLookupOk) { 'reconnect-after-lookup-failure' } + elseif ($staleExo.Count -gt 0) { 'reconnect-after-stale-discard' } + else { 'no-existing-session' } + Add-ConnectGuardLog -Service 'EXO' -Status 'Started' -ReasonKey $exoColdReason -Detail @{ + expectedUpn = $TenantAdminUpn + expectedDelegatedOrg = ($DelegatedOrganization -as [string]) + } + $signInBanner = if ($DelegatedOrganization) { "$TenantAdminUpn (GDAP delegated: $DelegatedOrganization)" } else { $TenantAdminUpn } + Write-Host "Connecting to Exchange Online as $signInBanner..." -ForegroundColor Cyan + $exoArgs = @{ UserPrincipalName = $TenantAdminUpn; ShowBanner = $false } + if ($DelegatedOrganization) { $exoArgs['DelegatedOrganization'] = $DelegatedOrganization } + if ($script:MsalRequiresDisableWam) { + # MSAL mismatch detected up-front. EXO 3.10's WAM RuntimeBroker + # constructor null-refs when called against Graph 2.37's MSAL 4.82. + # -DisableWAM falls back to interactive browser (still 1 click via + # default browser) and avoids the broken WAM code path. + $exoArgs['DisableWAM'] = $true + Write-Host " Using -DisableWAM for Exchange Online (MSAL mismatch — falls back to interactive browser)." -ForegroundColor DarkGray + Add-ConnectGuardLog -Service 'EXO' -Status 'Info' -ReasonKey 'disable-wam-msal-mismatch' -Detail @{ + exoMsalVersion = $msalCompat.Info.exoMsalVersion + graphMsalVersion = $msalCompat.Info.graphMsalVersion + } + } + try { + Connect-ExchangeOnline @exoArgs + } catch { + # ZT-pattern: detect MSAL DLL conflict and tell the user to restart pwsh. + # No script can recover from this — once a conflicting Microsoft.Identity.Client + # is loaded into PS 7's AppDomain it cannot be unloaded. + $exoConflict = @($_.Exception, $_.Exception.InnerException) | + Where-Object { $_ -is [System.MissingMethodException] -or $_ -is [System.IO.FileLoadException] } | + Select-Object -First 1 + if ($exoConflict -and ($exoConflict.Message -like '*Microsoft.Identity.Client*' -or $exoConflict.Message -like '*Microsoft.IdentityModel*')) { + Write-Host "" + Write-Warning "DLL conflict detected ($($exoConflict.GetType().Name)) loading Exchange Online's Microsoft.Identity.Client.dll." + Write-Warning "This means a conflicting Microsoft.Identity.Client was loaded into this PowerShell session before Exchange Online." + Write-Warning "Common causes: Microsoft.Graph, PnP.PowerShell, Az.* or Microsoft.Online.SharePoint.PowerShell was imported earlier in this session." + Write-Warning "Fix: CLOSE this PowerShell window, open a fresh pwsh, and re-run the script. Do not import any other Microsoft modules first." + Write-Host "" + } + # WAM RuntimeBroker NullReferenceException — fires when EXO's WAM + # code path is loaded against Graph's older MSAL. Should not happen + # now that we auto-pass -DisableWAM on detected mismatch, but keep + # the structured guidance for the edge case where mismatch detection + # under-reports (e.g. MSAL already loaded before script start). + $errText = ($_.Exception.Message + "`n" + $_.ScriptStackTrace + "`n" + $_.Exception.StackTrace) + if ($errText -match 'Microsoft\.Identity\.Client\.Platforms\.Features\.RuntimeBroker' -or + ($_.Exception -is [System.NullReferenceException] -and $errText -match 'RuntimeBroker')) { + Write-Host "" + Write-Warning "WAM broker crash detected (NullReferenceException in Microsoft.Identity.Client.Platforms.Features.RuntimeBroker)." + Write-Warning "This means EXO's WAM code path was loaded against a mismatched Microsoft.Identity.Client version (typically Graph 2.37's MSAL 4.82 + EXO 3.10's WAM expectations)." + Write-Warning "Fix: re-run with the script's -DisableWAM mitigation (auto-applied when MSAL mismatch is detected). If this fires anyway, CLOSE this PowerShell window, open a fresh pwsh, and re-run the script." + Write-Host "" + Add-ConnectGuardLog -Service 'EXO' -Status 'Failed' -ReasonKey 'wam-runtimebroker-nullref' -Detail @{ + exoMsalVersion = $msalCompat.Info.exoMsalVersion + graphMsalVersion = $msalCompat.Info.graphMsalVersion + loadedMsalVersion = $msalCompat.Info.loadedMsalVersion + } + } + throw + } +} + +# --------------------------------------------------------------------------- +# Security & Compliance (IPPS) — separate connection from EXO +# --------------------------------------------------------------------------- +# Same tenant-match guard as EXO: a stale IPPS session for a different tenant +# silently routes every Set-Label / Set-Dlp* call to the wrong customer. +$ippsConnected = $false +$staleIpps = @() +$ippsLookupOk = $true +try { + $ippsSessions = @(Get-ConnectionInformation -ErrorAction Stop | + Where-Object { $_.State -eq 'Connected' -and $_.TokenStatus -eq 'Active' -and $_.ConnectionUri -like '*compliance.protection.outlook.com*' }) + foreach ($s in $ippsSessions) { + $upnOk = $s.UserPrincipalName -ieq $TenantAdminUpn + $delegOk = if ($DelegatedOrganization) { + $s.DelegatedOrganization -ieq $DelegatedOrganization + } else { + [string]::IsNullOrEmpty($s.DelegatedOrganization) + } + if ($upnOk -and $delegOk) { $ippsConnected = $true } + else { + $reasonKey = if (-not $upnOk -and -not $delegOk) { 'upn-and-deleg-org-mismatch' } + elseif (-not $upnOk) { 'upn-mismatch' } + else { 'deleg-org-mismatch' } + Add-ConnectGuardLog -Service 'IPPS' -Status 'Retried' -ReasonKey $reasonKey -Detail @{ + expectedUpn = $TenantAdminUpn + actualUpn = ($s.UserPrincipalName -as [string]) + expectedDelegatedOrg = ($DelegatedOrganization -as [string]) + actualDelegatedOrg = ($s.DelegatedOrganization -as [string]) + } + $staleIpps += $s + } + } +} catch { + $ippsConnected = $false + $ippsLookupOk = $false + Add-ConnectGuardLog -Service 'IPPS' -Status 'Info' -ReasonKey 'get-connectioninformation-failed' -Detail @{ + error = $_.Exception.Message + } +} + +if ($staleIpps.Count -gt 0) { + $targetDesc = if ($DelegatedOrganization) { "$TenantAdminUpn -> $DelegatedOrganization" } else { $TenantAdminUpn } + Write-Host "Discarding $($staleIpps.Count) stale Security & Compliance (IPPS) session(s) (target: $targetDesc)." -ForegroundColor DarkYellow + foreach ($s in $staleIpps) { + try { + Disconnect-ExchangeOnline -ConnectionId $s.ConnectionId -Confirm:$false -ErrorAction SilentlyContinue | Out-Null + } catch { + Write-Verbose " Could not disconnect stale IPPS session $($s.ConnectionId): $($_.Exception.Message)" + } + } +} + +if ($ippsConnected) { + Add-ConnectGuardLog -Service 'IPPS' -Status 'Info' -ReasonKey 'reused' -Detail @{ + expectedUpn = $TenantAdminUpn + expectedDelegatedOrg = ($DelegatedOrganization -as [string]) + } + Write-Host "Security & Compliance (IPPS): existing session reused (UPN + delegated-org match)." -ForegroundColor DarkGray +} else { + $ippsColdReason = if (-not $ippsLookupOk) { 'reconnect-after-lookup-failure' } + elseif ($staleIpps.Count -gt 0) { 'reconnect-after-stale-discard' } + else { 'no-existing-session' } + Add-ConnectGuardLog -Service 'IPPS' -Status 'Started' -ReasonKey $ippsColdReason -Detail @{ + expectedUpn = $TenantAdminUpn + expectedDelegatedOrg = ($DelegatedOrganization -as [string]) + } + $ippsBanner = if ($DelegatedOrganization) { " (GDAP delegated: $DelegatedOrganization)" } else { '' } + Write-Host "Connecting to Security & Compliance Center$ippsBanner..." -ForegroundColor Cyan + $ippsArgs = @{ UserPrincipalName = $TenantAdminUpn; ShowBanner = $false } + if ($DelegatedOrganization) { $ippsArgs['DelegatedOrganization'] = $DelegatedOrganization } + if ($script:MsalRequiresDisableWam) { + # Same WAM-RuntimeBroker NullRef applies to IPPS (same module / same + # MSAL surface). -DisableWAM keeps the interactive browser flow. + $ippsArgs['DisableWAM'] = $true + Write-Host " Using -DisableWAM for Security & Compliance (IPPS) (MSAL mismatch — falls back to interactive browser)." -ForegroundColor DarkGray + Add-ConnectGuardLog -Service 'IPPS' -Status 'Info' -ReasonKey 'disable-wam-msal-mismatch' -Detail @{ + exoMsalVersion = $msalCompat.Info.exoMsalVersion + graphMsalVersion = $msalCompat.Info.graphMsalVersion + } + } + Connect-IPPSSession @ippsArgs +} + +# --------------------------------------------------------------------------- +# SharePoint Online — module loaded LAST (via WinPS proxy) and connected LAST +# --------------------------------------------------------------------------- +# ZT-aligned pattern: Microsoft.Online.SharePoint.PowerShell is a Windows-only +# module. On PS Core we load it via -UseWindowsPowerShell so its bundled +# Microsoft.Identity.Client.dll never enters PS 7's AppDomain (it stays in the +# hidden PS 5.1 sub-process). The module import happens HERE — after EXO/IPPS +# are already connected — to guarantee EXO's own MSAL has loaded cleanly first. +# +# Resolver precedence for the admin URL: +# 1. -SharePointAdminUrl +# 2. -DelegatedOrganization (.onmicrosoft.com) +# 3. Admin UPN suffix (when @.onmicrosoft.com) +# 4. EXO Get-AcceptedDomain (final fallback — uses the EXO session above) +if ($NeedsSharePoint) { + Ensure-RequiredModule -Name 'Microsoft.Online.SharePoint.PowerShell' ` + -RequiredCmdlet 'Connect-SPOService' ` + -AutoInstall:$AutoInstallModules ` + -NonInteractive:$NonInteractive ` + -UseWindowsPowerShellProxy + + $resolved = Resolve-SpoAdminUrlFromInputs ` + -ExplicitUrl $SharePointAdminUrl ` + -DelegatedOrganization $DelegatedOrganization ` + -TenantAdminUpn $TenantAdminUpn + + if ($resolved) { + $SharePointAdminUrl = $resolved.Url + Write-Host "SharePoint admin URL resolved via $($resolved.Source): $SharePointAdminUrl" -ForegroundColor DarkGray + } else { + Write-Host "Resolving SharePoint admin URL from tenant initial domain (EXO Get-AcceptedDomain)..." -ForegroundColor Cyan + try { + # Get-AcceptedDomain runs in the EXO session we just connected. + # The InitialDomain (always .onmicrosoft.com) is the + # reliable basis for the SPO admin URL. + $initial = Get-AcceptedDomain | + Where-Object { $_.InitialDomain } | + Select-Object -First 1 + if (-not $initial) { + throw "No initial (.onmicrosoft.com) domain found via Get-AcceptedDomain." + } + $tenantPrefix = ($initial.DomainName -split '\.')[0] + $SharePointAdminUrl = "https://$tenantPrefix-admin.sharepoint.com" + Write-Host " Resolved: $SharePointAdminUrl" -ForegroundColor Green + } catch { + throw "Could not auto-derive SharePoint admin URL: $($_.Exception.Message)`nPass -SharePointAdminUrl explicitly (e.g. https://-admin.sharepoint.com)." + } + } + + Invoke-SpoConnectWithFallback -SharePointAdminUrl $SharePointAdminUrl -TenantAdminUpn $TenantAdminUpn +} + +Write-Host "All required services connected." -ForegroundColor Green + +[pscustomobject]@{ + SharePointAdminUrl = if ($NeedsSharePoint) { $SharePointAdminUrl } else { $null } +} diff --git a/Products/Purview/Modules/Connect-PurviewValidationServices.ps1 b/Products/Purview/Modules/Connect-PurviewValidationServices.ps1 new file mode 100644 index 0000000..3a565bb --- /dev/null +++ b/Products/Purview/Modules/Connect-PurviewValidationServices.ps1 @@ -0,0 +1,337 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Read-only service connections for Purview configuration validation. + +.DESCRIPTION + A dedicated least-privilege profile rather than a reuse of the deployment + connection. The deployment profile requests write scopes so it can create + labels, policies, and directory settings. Validation only reads, so asking + for those scopes would ask an operator to consent to permissions the run + cannot use. + + Requested delegated Microsoft Graph scopes: + + * Organization.Read.All read /organization for tenant identity + * GroupSettings.Read.All read the Group.Unified directory setting + + The narrower Graph scopes are the least-privileged permissions Microsoft + documents for those reads. See the Permissions section in + ../docs/Configuration-Validation.md for the public connection boundary. + + Exchange Online, Security & Compliance, and SharePoint Online do not offer + per-operation delegated scopes. The operator's directory role governs what + those sessions can read. Service-role mappings still need pilot + verification; the public validation guide records that limitation. + + This script connects and verifies. It never writes. + +.PARAMETER TenantAdminUpn + UPN used for sign-in. + +.PARAMETER DelegatedOrganization + Customer tenant primary domain when a partner signs in through GDAP. + +.PARAMETER SharePointAdminUrl + Explicit SharePoint admin URL. Required for multi-geo, renamed tenants, or + vanity-domain admin UPNs where the URL cannot be derived. + +.PARAMETER ClientId + Optional approved tenant-local public-client application ID. Use an + isolated client whose delegated Microsoft Graph grant contains only the + validator's two resource scopes. The script does not create or consent an + application. + +.PARAMETER UseDeviceAuthentication + Use Microsoft Graph device authentication instead of the default browser + flow. Useful in terminals that cannot provide a WAM parent window. + +.PARAMETER NeedsSharePoint + Connect SharePoint Online. Only required when the plan contains an action + whose adapter reads Get-SPOTenant. + +.PARAMETER NeedsGraph + Connect Microsoft Graph. Required for tenant identity, entitlement, and the + container-label directory setting. + +.PARAMETER AutoInstallModules + Install missing modules for the current user without prompting. + +.PARAMETER NonInteractive + Fail instead of prompting. + +.OUTPUTS + PSCustomObject describing which sessions were established and the resolved + SharePoint admin URL. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $TenantAdminUpn, + + [Parameter()] + [string] $DelegatedOrganization, + + [Parameter()] + [string] $SharePointAdminUrl, + + [Parameter()] + [ValidatePattern('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')] + [string] $ClientId, + + [Parameter()] + [switch] $UseDeviceAuthentication, + + [Parameter()] + [switch] $NeedsSharePoint, + + [Parameter()] + [switch] $NeedsGraph, + + [Parameter()] + [switch] $AutoInstallModules, + + [Parameter()] + [switch] $NonInteractive +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Connecting is a precondition, not a change. Some auth flows respect +# $WhatIfPreference internally and silently skip work, which surfaces later as +# a confusing "no valid authentication session" error. +$WhatIfPreference = $false +$ConfirmPreference = 'None' + +$_runLogPath = Join-Path $PSScriptRoot 'PurviewRunLog.ps1' +if (Test-Path -LiteralPath $_runLogPath) { . $_runLogPath } +$_tenantIdentityPath = Join-Path $PSScriptRoot 'PurviewTenantIdentity.ps1' +if (Test-Path -LiteralPath $_tenantIdentityPath) { . $_tenantIdentityPath } + +# Least-privilege delegated scopes for the three Graph reads this tool makes. +$script:PurviewValidationGraphScopes = @( + 'Organization.Read.All' + 'GroupSettings.Read.All' +) + +function Add-ValidationConnectLog { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Service, + [Parameter(Mandatory)] + [ValidateSet('Info', 'Started', 'Succeeded', 'Skipped', 'Failed')] + [string] $Status, + [Parameter()] [string] $Detail = '' + ) + + if (-not (Get-Command -Name 'Add-RunLogEntry' -ErrorAction SilentlyContinue)) { return } + try { + Add-RunLogEntry -Module 'Connect-PurviewValidationServices' ` + -Action "Connect:$Service" -Status $Status -Detail $Detail + } catch { + Write-Verbose "Validation connect log failed: $($_.Exception.Message)" + } +} + +function Assert-ValidationModule { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Name, + [Parameter(Mandatory)] [string] $RequiredCmdlet + ) + + if (Get-Command -Name $RequiredCmdlet -ErrorAction SilentlyContinue) { return } + if (Get-Module -ListAvailable -Name $Name) { + Import-Module -Name $Name -ErrorAction Stop + return + } + + if (-not $AutoInstallModules) { + if ($NonInteractive) { + throw "Module '$Name' is required for validation reads and is not installed. Rerun with -AutoInstallModules or install it first." + } + $answer = Read-Host "Module '$Name' is required for validation reads. Install it for the current user now? (y/n)" + if ($answer -notmatch '^(y|yes)$') { + throw "Module '$Name' is required for validation reads." + } + } + + Install-Module -Name $Name -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop + Import-Module -Name $Name -ErrorAction Stop +} + +$targetTenantDomain = if ($DelegatedOrganization) { + $DelegatedOrganization +} else { + ($TenantAdminUpn -split '@')[-1] +} + +$graphConnected = $false +$sharePointConnected = $false + +# Graph first. Microsoft Graph and Exchange Online ship different versions of +# the MSAL client library, and whichever loads first wins the process. Graph's +# credential types fail on Exchange Online's newer MSAL, so Graph must connect +# before Exchange Online does. +if ($NeedsGraph) { + Assert-ValidationModule -Name 'Microsoft.Graph.Authentication' -RequiredCmdlet 'Connect-MgGraph' + + $reuse = $false + $cachedScopeError = $null + try { + $context = Get-MgContext -ErrorAction Stop + if ($context) { + $contextCheck = Test-PurviewValidationGraphContext -Context $context ` + -ExpectedAccount $TenantAdminUpn ` + -RequiredScopes $script:PurviewValidationGraphScopes + $reuse = $contextCheck.Matched + if (-not $reuse) { + $reason = if (-not $contextCheck.AccountMatched) { + 'Cached account does not match the requested validation account.' + } elseif ($contextCheck.MissingScopes.Count -gt 0) { + 'Cached context is missing required validation scopes.' + } else { + 'Cached context contains Graph resource permissions outside the validation allowlist.' + } + Add-ValidationConnectLog -Service 'Graph' -Status 'Info' -Detail $reason + try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch { } + if ($contextCheck.UnexpectedScopes.Count -gt 0) { + $cachedScopeError = ( + 'The existing Microsoft Graph context contains resource permissions outside ' + + "the validator allowlist: $($contextCheck.UnexpectedScopes -join ', '). " + + 'Close this PowerShell process, open a fresh pwsh -NoProfile session, and rerun ' + + 'the validator so it can request a new read-only token. If the fresh token is ' + + 'still broader, use an approved tenant-local public client with -ClientId.') + } + } + } + } catch { + $reuse = $false + } + if ($cachedScopeError) { throw $cachedScopeError } + + if ($reuse) { + Add-ValidationConnectLog -Service 'Graph' -Status 'Info' -Detail 'Existing read-only session reused.' + Write-Host 'Microsoft Graph: existing session reused.' -ForegroundColor DarkGray + } else { + Add-ValidationConnectLog -Service 'Graph' -Status 'Started' ` + -Detail ('Requesting read-only scopes: {0}' -f ($script:PurviewValidationGraphScopes -join ', ')) + Write-Host ('Connecting to Microsoft Graph with read-only scopes: {0}' -f ` + ($script:PurviewValidationGraphScopes -join ', ')) -ForegroundColor Cyan + $graphArgs = @{ + TenantId = $targetTenantDomain + Scopes = $script:PurviewValidationGraphScopes + ContextScope = 'Process' + NoWelcome = $true + ErrorAction = 'Stop' + } + if ($ClientId) { $graphArgs['ClientId'] = $ClientId } + if ($UseDeviceAuthentication) { $graphArgs['UseDeviceAuthentication'] = $true } + Connect-MgGraph @graphArgs + Add-ValidationConnectLog -Service 'Graph' -Status 'Succeeded' + } + + $effectiveContext = Get-MgContext -ErrorAction Stop + $effectiveCheck = Test-PurviewValidationGraphContext -Context $effectiveContext ` + -ExpectedAccount $TenantAdminUpn ` + -RequiredScopes $script:PurviewValidationGraphScopes + if (-not $effectiveCheck.Matched) { + try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch { } + if ($effectiveCheck.UnexpectedScopes.Count -gt 0) { + throw ( + 'The newly authenticated Microsoft Graph context contains resource permissions outside ' + + "the validator allowlist: $($effectiveCheck.UnexpectedScopes -join ', '). " + + 'The shared client or broker did not issue a read-only token. Use an approved ' + + 'tenant-local public client with only Organization.Read.All and ' + + 'GroupSettings.Read.All, then pass its application ID with -ClientId.') + } + if (-not $effectiveCheck.AccountMatched) { + throw 'The effective Microsoft Graph account does not match -TenantAdminUpn.' + } + throw "The effective Microsoft Graph context is missing required scopes: $($effectiveCheck.MissingScopes -join ', ')." + } + $graphConnected = $true + + # The Group.Unified directory setting is read through the Graph beta + # directory-setting commands, matching the surface the deployment writes. + # Its absence is not fatal: the container-label action resolves to + # 'Not evaluated' instead. + try { + Assert-ValidationModule -Name 'Microsoft.Graph.Beta.Identity.DirectoryManagement' ` + -RequiredCmdlet 'Get-MgBetaDirectorySetting' + } catch { + Add-ValidationConnectLog -Service 'GraphBeta' -Status 'Skipped' -Detail $_.Exception.Message + Write-Warning "Container-label directory settings cannot be read: $($_.Exception.Message)" + } +} + +Assert-ValidationModule -Name 'ExchangeOnlineManagement' -RequiredCmdlet 'Connect-ExchangeOnline' + +$exoArgs = @{ + UserPrincipalName = $TenantAdminUpn + ShowBanner = $false + ErrorAction = 'Stop' +} +if ($DelegatedOrganization) { $exoArgs['DelegatedOrganization'] = $DelegatedOrganization } +if ($graphConnected) { $exoArgs['DisableWAM'] = $true } + +Add-ValidationConnectLog -Service 'ExchangeOnline' -Status 'Started' +Write-Host 'Connecting to Exchange Online...' -ForegroundColor Cyan +Connect-ExchangeOnline @exoArgs +Add-ValidationConnectLog -Service 'ExchangeOnline' -Status 'Succeeded' + +$ippsArgs = @{ + UserPrincipalName = $TenantAdminUpn + ErrorAction = 'Stop' +} +if ($DelegatedOrganization) { $ippsArgs['DelegatedOrganization'] = $DelegatedOrganization } +if ($graphConnected) { $ippsArgs['DisableWAM'] = $true } + +Add-ValidationConnectLog -Service 'SecurityCompliance' -Status 'Started' +Write-Host 'Connecting to Security and Compliance PowerShell...' -ForegroundColor Cyan +Connect-IPPSSession @ippsArgs +Add-ValidationConnectLog -Service 'SecurityCompliance' -Status 'Succeeded' + +if ($NeedsSharePoint) { + try { + Assert-ValidationModule -Name 'Microsoft.Online.SharePoint.PowerShell' ` + -RequiredCmdlet 'Connect-SPOService' + + if (-not $SharePointAdminUrl) { + $initial = Get-AcceptedDomain -ErrorAction Stop | + Where-Object { $_.InitialDomain } | + Select-Object -First 1 + if (-not $initial) { + throw 'No initial onmicrosoft.com domain was returned, so the SharePoint admin URL could not be derived.' + } + $prefix = ([string]$initial.DomainName -split '\.')[0] + $SharePointAdminUrl = "https://$prefix-admin.sharepoint.com" + } + + Add-ValidationConnectLog -Service 'SharePointOnline' -Status 'Started' + Write-Host 'Connecting to SharePoint Online...' -ForegroundColor Cyan + Connect-SPOService -Url $SharePointAdminUrl -ErrorAction Stop + Add-ValidationConnectLog -Service 'SharePointOnline' -Status 'Succeeded' + $sharePointConnected = $true + } catch { + # SharePoint reads back two tenant settings. Losing them should degrade + # those two actions to 'Not evaluated', not abort a run that can still + # assess the other sixteen. + Add-ValidationConnectLog -Service 'SharePointOnline' -Status 'Failed' -Detail $_.Exception.Message + Write-Warning "SharePoint Online could not be connected: $($_.Exception.Message)" + Write-Warning 'Actions that read Get-SPOTenant will be reported as Not evaluated.' + } +} + +Write-Host 'Read-only validation sessions established.' -ForegroundColor Green + +[pscustomobject]@{ + GraphConnected = $graphConnected + ExchangeOnlineConnected = $true + SecurityComplianceConnected = $true + SharePointConnected = $sharePointConnected + SharePointAdminUrl = if ($sharePointConnected) { $SharePointAdminUrl } else { $null } + RequestedGraphScopes = @($script:PurviewValidationGraphScopes) +} diff --git a/Products/Purview/Modules/Invoke-WithTransientRetry.ps1 b/Products/Purview/Modules/Invoke-WithTransientRetry.ps1 index 8c2978e..0b89b6b 100644 --- a/Products/Purview/Modules/Invoke-WithTransientRetry.ps1 +++ b/Products/Purview/Modules/Invoke-WithTransientRetry.ps1 @@ -1,296 +1,299 @@ -#requires -Version 7.0 -<# -.SYNOPSIS - Shared transient-error retry helper for the Purview Best Practice toolkit. - -.DESCRIPTION - Microsoft 365 backends (IPPS / Security & Compliance, EXO, SPO, Graph - Beta directory settings) regularly return short-lived 5xx and throttling - responses during bulk policy/label/rule operations: - - * 429 throttling under burst writes (label priority reorders, rule - creation loops) - * 502 / 504 gateway responses while a long-running provision is still - in flight server-side - * 500 / "server side error" on freshly-hydrated tenants or during - Microsoft maintenance windows - * 503 "Service Unavailable" during regional service incidents - - Without retry, a single transient hiccup mid-deploy leaves a tenant in - a half-configured state (e.g. policy created but rule missing). With - retry, the same hiccup is silently absorbed after a short backoff. - - EXPORTS (via dot-source): - * Test-TransientServerError - signature detector - * Invoke-WithTransientRetry - retry wrapper - - Auto-imports theyes Tier-2 run log helper (PurviewRunLog.ps1) when - present, so the wrapper can emit Started / Retried / Succeeded / - Failed entries for every retry-wrapped destructive op without the - caller doing anything. Setup-* modules that dot-source this file - therefore get the Add-RunLogEntry function automatically as well. - -.NOTES - Origin: extracted from Setup-TenantSettings.ps1 where this pattern was - first proven, and promoted into a shared module so Labels, DLP, - Retention, and AI Governance can use the same backoff and signature - set. (Jim's PR4 feedback.) - - Usage: - . (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') - - Invoke-WithTransientRetry -Description 'New-DlpComplianceRule X' -Action { - New-DlpComplianceRule -Name 'X' -ErrorAction Stop | Out-Null - } -#> - -# Auto-import the Tier-2 run log helper so callers (Setup-* modules) -# get Add-RunLogEntry in their scope. Idempotent: if PurviewRunLog.ps1 -# is missing (e.g. unit-test rigs that mock it out), we silently skip -# and Invoke-WithTransientRetry falls back to no-op logging via -# Get-Command detection inside the retry loop. -$_purviewRunLogPath = Join-Path $PSScriptRoot 'PurviewRunLog.ps1' -if (Test-Path $_purviewRunLogPath) { - . $_purviewRunLogPath -} - -function Test-TransientServerError { - <# - Returns $true when the error blob carries any of the well-known - transient signatures emitted by IPPS/EXO/SPO/Graph. The check is - substring-based across the full error record (message, details, - category, FQEID, exception type name) because each service tends - to surface the same condition through slightly different fields. - - Important: HTTP 500 + "Internal Server Error" alone is NOT enough - to classify as transient -- IPPS wraps several deterministic - semantic errors (e.g. InvalidSubLabelPriorityException) in a - generic 500 envelope. We check a non-transient denylist FIRST and - bail out before the transient pattern match if any matches. - #> - [CmdletBinding()] - [OutputType([bool])] - param([Parameter(Mandatory)] $ErrorRecord) - - $blob = @( - $ErrorRecord.Exception.Message, - $ErrorRecord.ErrorDetails.Message, - $ErrorRecord.CategoryInfo.Reason, - $ErrorRecord.FullyQualifiedErrorId, - $ErrorRecord.Exception.GetType().FullName - ) -join ' ' - - if ([string]::IsNullOrWhiteSpace($blob)) { return $false } - - # Non-transient denylist: well-known semantic errors that IPPS sometimes - # wraps in a 500/InternalServerError envelope. Retrying these is wasted - # time and noise -- they're deterministic. - $nonTransientPatterns = @( - 'InvalidSubLabelPriorityException', - 'InvalidPriorityException', - 'Duplicate display name', - 'ComplianceRuleAlreadyExistsInScenarioException', - 'LabelAlreadyPublishedException', - 'Enable-OrganizationCustomization', - 'has been deleted and cannot be modified', - 'ErrorCommonComplianceRuleIsDeletedException' - ) - foreach ($p in $nonTransientPatterns) { - if ($blob -match $p) { return $false } - } - - $patterns = @( - '\b(429|500|502|503|504)\b', - 'Service Unavailable', - 'Gateway Timeout', - 'Internal Server Error', - 'server[- ]side error', - 'could not be completed', - 'try again', - 'temporarily unavailable', - 'throttl', - 'timeout', - 'EnableSpoAipMigrationIsDisabledException', - 'A task was canceled' - ) - foreach ($p in $patterns) { - if ($blob -match $p) { return $true } - } - return $false -} - -function Test-AlreadyExistsError { - <# - Detects "object already exists" style errors. Used by the retry - wrapper when -AlreadyExistsIsSuccess is set: if a New-* cmdlet - succeeded on the server but failed on the response handshake, a - retry will see the object already there and would normally throw - — we treat that case as success (because it IS success, just - invisible to the client on the first attempt). - - Caveat: on attempt #1 a pre-existing object is a real condition - the caller should know about, so the wrapper only suppresses - already-exists from attempt #2 onward. - #> - [CmdletBinding()] - [OutputType([bool])] - param([Parameter(Mandatory)] $ErrorRecord) - - $blob = @( - $ErrorRecord.Exception.Message, - $ErrorRecord.ErrorDetails.Message, - $ErrorRecord.FullyQualifiedErrorId - ) -join ' ' - - if ([string]::IsNullOrWhiteSpace($blob)) { return $false } - - $patterns = @( - 'already exist', - 'DSAlreadyExist', - 'ConfigurationItemId.*already', - 'is already in use', - 'duplicate key', - 'A label with the same name already exists', - 'A policy with the same name already exists' - ) - foreach ($p in $patterns) { - if ($blob -match $p) { return $true } - } - return $false -} - -function Invoke-WithTransientRetry { - <# - Execute -Action; on a transient error, sleep and retry up to - -MaxAttempts times. Non-transient errors throw immediately. - - Backoff schedule defaults to 5s -> 15s -> 30s -> 45s -> 60s. - Total ceiling at default settings: ~155 seconds before giving up. - - Returns whatever -Action returns (so Get-* calls can be wrapped - without their output being clobbered). On giving up (transient - errors that outlast MaxAttempts, OR a non-transient error), - re-throws so the caller's catch fires normally. - - -AlreadyExistsIsSuccess: opt-in flag for idempotent New-* paths. - If retry sees an "already exists" error on attempt #2+, treat - as success (the object was created by a prior failed attempt - whose response we lost). On attempt #1, "already exists" is a - real condition and propagates normally. - - -OnRetry: optional scriptblock invoked between sleeps. Used by - callers that need to refresh stale state (e.g. re-read a - label's Priority before the next Set-Label attempt). - - -Module / -Target: optional run-log enrichment. When the Tier-2 - PurviewRunLog is loaded (Add-RunLogEntry available), the - helper auto-emits Started / Retried / Succeeded / Failed - entries so every retry-wrapped destructive op shows up in the - end-of-run HTML report without the caller having to do - anything. Logging is silently skipped when Add-RunLogEntry - isn't available (e.g. in standalone unit tests). - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] [scriptblock] $Action, - [Parameter(Mandatory)] [string] $Description, - [int] $MaxAttempts = 6, - [int[]] $BackoffSeconds = @(5, 15, 30, 45, 60), - [switch] $AlreadyExistsIsSuccess, - [scriptblock] $OnRetry, - [string] $Module, - [string] $Target - ) - - # Tier-2: detect whether the run-log helper is loaded in the current - # session. Cache the result for the duration of this invocation. - $logEnabled = [bool](Get-Command 'Add-RunLogEntry' -ErrorAction SilentlyContinue) - - # Resolve the run-log Module bucket: - # 1. Explicit -Module wins (caller knows best). - # 2. Otherwise auto-derive from the first call-stack frame that - # isn't this helper itself. For a retry call originating in - # Setup-TenantSettings.ps1 this yields 'Setup-TenantSettings', - # so every wrapped op shows up under the correct module bucket - # in the HTML/JSON report without any callsite change. - # 3. Deep fallback to 'Retry' only when no script context exists - # (e.g. interactive REPL invocation). - # Background: prior to this auto-derive, every retry-wrapped op - # without -Module landed under a literal 'Retry' bucket -- 35 - # callsites across 5 Setup-* modules were all collapsed there, - # making per-module audit impossible. See skill file Decisions log. - if ($Module) { - $logModule = $Module - } else { - $caller = Get-PSCallStack | Where-Object { - $_.ScriptName -and ($_.ScriptName -notmatch 'Invoke-WithTransientRetry\.ps1$') - } | Select-Object -First 1 - if ($caller) { - $logModule = [System.IO.Path]::GetFileNameWithoutExtension($caller.ScriptName) - } else { - $logModule = 'Retry' - } - } - - $sw = [System.Diagnostics.Stopwatch]::StartNew() - - for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { - try { - & $Action - $sw.Stop() - if ($logEnabled) { - Add-RunLogEntry -Module $logModule -Action $Description -Target $Target ` - -Status 'Succeeded' -Attempt $attempt -ElapsedMs ([int]$sw.ElapsedMilliseconds) - } - return - } catch { - $err = $_ - - # "Already exists" branch -- only meaningful from attempt #2 onward - # because attempt #1 catching this means the object pre-existed - # (the caller's idempotency check should already have handled it, - # but if it didn't, that's a real condition the caller needs to - # know about and we should NOT swallow). - if ($AlreadyExistsIsSuccess -and $attempt -gt 1 -and (Test-AlreadyExistsError -ErrorRecord $err)) { - Write-Host (" ~ '{0}' reports object already exists on attempt {1}/{2} - treating as success (likely created by an earlier failed attempt whose response was lost)." -f $Description, $attempt, $MaxAttempts) -ForegroundColor DarkGray - $sw.Stop() - if ($logEnabled) { - Add-RunLogEntry -Module $logModule -Action $Description -Target $Target ` - -Status 'Adopted' -Attempt $attempt -ElapsedMs ([int]$sw.ElapsedMilliseconds) ` - -Detail 'Object reported as already existing on retry; treated as success.' - } - return - } - - $isTransient = Test-TransientServerError -ErrorRecord $err - $isLast = $attempt -ge $MaxAttempts - - if ($isTransient -and -not $isLast) { - $sleep = $BackoffSeconds[[Math]::Min($attempt - 1, $BackoffSeconds.Count - 1)] - $sleep += Get-Random -Minimum 0 -Maximum 3 - Write-Host (" ~ Transient error on '{0}' (attempt {1}/{2}). Sleeping {3}s and retrying..." -f $Description, $attempt, $MaxAttempts, $sleep) -ForegroundColor DarkYellow - Write-Host (" {0}" -f $err.Exception.Message) -ForegroundColor DarkGray - if ($logEnabled) { - Add-RunLogEntry -Module $logModule -Action $Description -Target $Target ` - -Status 'Retried' -Attempt $attempt ` - -Detail ("Transient: {0}. Sleeping {1}s." -f $err.Exception.Message, $sleep) - } - Start-Sleep -Seconds $sleep - - if ($OnRetry) { - try { & $OnRetry $attempt $err } catch { Write-Verbose "OnRetry callback failed: $($_.Exception.Message)" } - } - continue - } - - # Non-transient OR final attempt -- bubble up. - $sw.Stop() - if ($logEnabled) { - Add-RunLogEntry -Module $logModule -Action $Description -Target $Target ` - -Status 'Failed' -Attempt $attempt -ElapsedMs ([int]$sw.ElapsedMilliseconds) ` - -Detail $err.Exception.Message - } - throw - } - } -} +#requires -Version 7.0 +<# +.SYNOPSIS + Shared transient-error retry helper for the Purview Best Practice toolkit. + +.DESCRIPTION + Microsoft 365 backends (IPPS / Security & Compliance, EXO, SPO, Graph + Beta directory settings) regularly return short-lived 5xx and throttling + responses during bulk policy/label/rule operations: + + * 429 throttling under burst writes (label priority reorders, rule + creation loops) + * 502 / 504 gateway responses while a long-running provision is still + in flight server-side + * 500 / "server side error" on freshly-hydrated tenants or during + Microsoft maintenance windows + * 503 "Service Unavailable" during regional service incidents + + Without retry, a single transient hiccup mid-deploy leaves a tenant in + a half-configured state (e.g. policy created but rule missing). With + retry, the same hiccup is silently absorbed after a short backoff. + + EXPORTS (via dot-source): + * Test-TransientServerError - signature detector + * Invoke-WithTransientRetry - retry wrapper + + Auto-imports theyes Tier-2 run log helper (PurviewRunLog.ps1) when + present, so the wrapper can emit Started / Retried / Succeeded / + Failed entries for every retry-wrapped destructive op without the + caller doing anything. Setup-* modules that dot-source this file + therefore get the Add-RunLogEntry function automatically as well. + +.NOTES + Origin: extracted from Setup-TenantSettings.ps1 where this pattern was + first proven, and promoted into a shared module so Labels, DLP, + Retention, and AI Governance can use the same backoff and signature + set. + + Usage: + . (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') + + Invoke-WithTransientRetry -Description 'New-DlpComplianceRule X' -Action { + New-DlpComplianceRule -Name 'X' -ErrorAction Stop | Out-Null + } +#> + +# Auto-import the Tier-2 run log helper so callers (Setup-* modules) +# get Add-RunLogEntry in their scope. Idempotent: if PurviewRunLog.ps1 +# is missing (e.g. unit-test rigs that mock it out), we silently skip +# and Invoke-WithTransientRetry falls back to no-op logging via +# Get-Command detection inside the retry loop. +$_purviewRunLogPath = Join-Path $PSScriptRoot 'PurviewRunLog.ps1' +if (Test-Path $_purviewRunLogPath) { + . $_purviewRunLogPath +} + +function Test-TransientServerError { + <# + Returns $true when the error blob carries any of the well-known + transient signatures emitted by IPPS/EXO/SPO/Graph. The check is + substring-based across the full error record (message, details, + category, FQEID, exception type name) because each service tends + to surface the same condition through slightly different fields. + + Important: HTTP 500 + "Internal Server Error" alone is NOT enough + to classify as transient -- IPPS wraps several deterministic + semantic errors (e.g. InvalidSubLabelPriorityException) in a + generic 500 envelope. We check a non-transient denylist FIRST and + bail out before the transient pattern match if any matches. + #> + [CmdletBinding()] + [OutputType([bool])] + param([Parameter(Mandatory)] $ErrorRecord) + + # Property access is guarded because ErrorRecord fields are frequently + # null, and a caller running under Set-StrictMode would otherwise fail + # inside the retry handler rather than surfacing the original error. + $blob = @( + $ErrorRecord.Exception.Message, + $(if ($ErrorRecord.ErrorDetails) { $ErrorRecord.ErrorDetails.Message }), + $(if ($ErrorRecord.CategoryInfo) { $ErrorRecord.CategoryInfo.Reason }), + $ErrorRecord.FullyQualifiedErrorId, + $(if ($ErrorRecord.Exception) { $ErrorRecord.Exception.GetType().FullName }) + ) -join ' ' + + if ([string]::IsNullOrWhiteSpace($blob)) { return $false } + + # Non-transient denylist: well-known semantic errors that IPPS sometimes + # wraps in a 500/InternalServerError envelope. Retrying these is wasted + # time and noise -- they're deterministic. + $nonTransientPatterns = @( + 'InvalidSubLabelPriorityException', + 'InvalidPriorityException', + 'Duplicate display name', + 'ComplianceRuleAlreadyExistsInScenarioException', + 'LabelAlreadyPublishedException', + 'Enable-OrganizationCustomization', + 'has been deleted and cannot be modified', + 'ErrorCommonComplianceRuleIsDeletedException' + ) + foreach ($p in $nonTransientPatterns) { + if ($blob -match $p) { return $false } + } + + $patterns = @( + '\b(429|500|502|503|504)\b', + 'Service Unavailable', + 'Gateway Timeout', + 'Internal Server Error', + 'server[- ]side error', + 'could not be completed', + 'try again', + 'temporarily unavailable', + 'throttl', + 'timeout', + 'EnableSpoAipMigrationIsDisabledException', + 'A task was canceled' + ) + foreach ($p in $patterns) { + if ($blob -match $p) { return $true } + } + return $false +} + +function Test-AlreadyExistsError { + <# + Detects "object already exists" style errors. Used by the retry + wrapper when -AlreadyExistsIsSuccess is set: if a New-* cmdlet + succeeded on the server but failed on the response handshake, a + retry will see the object already there and would normally throw + — we treat that case as success (because it IS success, just + invisible to the client on the first attempt). + + Caveat: on attempt #1 a pre-existing object is a real condition + the caller should know about, so the wrapper only suppresses + already-exists from attempt #2 onward. + #> + [CmdletBinding()] + [OutputType([bool])] + param([Parameter(Mandatory)] $ErrorRecord) + + $blob = @( + $ErrorRecord.Exception.Message, + $(if ($ErrorRecord.ErrorDetails) { $ErrorRecord.ErrorDetails.Message }), + $ErrorRecord.FullyQualifiedErrorId + ) -join ' ' + + if ([string]::IsNullOrWhiteSpace($blob)) { return $false } + + $patterns = @( + 'already exist', + 'DSAlreadyExist', + 'ConfigurationItemId.*already', + 'is already in use', + 'duplicate key', + 'A label with the same name already exists', + 'A policy with the same name already exists' + ) + foreach ($p in $patterns) { + if ($blob -match $p) { return $true } + } + return $false +} + +function Invoke-WithTransientRetry { + <# + Execute -Action; on a transient error, sleep and retry up to + -MaxAttempts times. Non-transient errors throw immediately. + + Backoff schedule defaults to 5s -> 15s -> 30s -> 45s -> 60s. + Total ceiling at default settings: ~155 seconds before giving up. + + Returns whatever -Action returns (so Get-* calls can be wrapped + without their output being clobbered). On giving up (transient + errors that outlast MaxAttempts, OR a non-transient error), + re-throws so the caller's catch fires normally. + + -AlreadyExistsIsSuccess: opt-in flag for idempotent New-* paths. + If retry sees an "already exists" error on attempt #2+, treat + as success (the object was created by a prior failed attempt + whose response we lost). On attempt #1, "already exists" is a + real condition and propagates normally. + + -OnRetry: optional scriptblock invoked between sleeps. Used by + callers that need to refresh stale state (e.g. re-read a + label's Priority before the next Set-Label attempt). + + -Module / -Target: optional run-log enrichment. When the Tier-2 + PurviewRunLog is loaded (Add-RunLogEntry available), the + helper auto-emits Started / Retried / Succeeded / Failed + entries so every retry-wrapped destructive op shows up in the + end-of-run HTML report without the caller having to do + anything. Logging is silently skipped when Add-RunLogEntry + isn't available (e.g. in standalone unit tests). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [scriptblock] $Action, + [Parameter(Mandatory)] [string] $Description, + [int] $MaxAttempts = 6, + [int[]] $BackoffSeconds = @(5, 15, 30, 45, 60), + [switch] $AlreadyExistsIsSuccess, + [scriptblock] $OnRetry, + [string] $Module, + [string] $Target + ) + + # Tier-2: detect whether the run-log helper is loaded in the current + # session. Cache the result for the duration of this invocation. + $logEnabled = [bool](Get-Command 'Add-RunLogEntry' -ErrorAction SilentlyContinue) + + # Resolve the run-log Module bucket: + # 1. Explicit -Module wins (caller knows best). + # 2. Otherwise auto-derive from the first call-stack frame that + # isn't this helper itself. For a retry call originating in + # Setup-TenantSettings.ps1 this yields 'Setup-TenantSettings', + # so every wrapped op shows up under the correct module bucket + # in the HTML/JSON report without any callsite change. + # 3. Deep fallback to 'Retry' only when no script context exists + # (e.g. interactive REPL invocation). + # Background: prior to this auto-derive, every retry-wrapped op + # without -Module landed under a literal 'Retry' bucket -- 35 + # callsites across 5 Setup-* modules were all collapsed there, + # making per-module audit impossible. See skill file Decisions log. + if ($Module) { + $logModule = $Module + } else { + $caller = Get-PSCallStack | Where-Object { + $_.ScriptName -and ($_.ScriptName -notmatch 'Invoke-WithTransientRetry\.ps1$') + } | Select-Object -First 1 + if ($caller) { + $logModule = [System.IO.Path]::GetFileNameWithoutExtension($caller.ScriptName) + } else { + $logModule = 'Retry' + } + } + + $sw = [System.Diagnostics.Stopwatch]::StartNew() + + for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + try { + & $Action + $sw.Stop() + if ($logEnabled) { + Add-RunLogEntry -Module $logModule -Action $Description -Target $Target ` + -Status 'Succeeded' -Attempt $attempt -ElapsedMs ([int]$sw.ElapsedMilliseconds) + } + return + } catch { + $err = $_ + + # "Already exists" branch -- only meaningful from attempt #2 onward + # because attempt #1 catching this means the object pre-existed + # (the caller's idempotency check should already have handled it, + # but if it didn't, that's a real condition the caller needs to + # know about and we should NOT swallow). + if ($AlreadyExistsIsSuccess -and $attempt -gt 1 -and (Test-AlreadyExistsError -ErrorRecord $err)) { + Write-Host (" ~ '{0}' reports object already exists on attempt {1}/{2} - treating as success (likely created by an earlier failed attempt whose response was lost)." -f $Description, $attempt, $MaxAttempts) -ForegroundColor DarkGray + $sw.Stop() + if ($logEnabled) { + Add-RunLogEntry -Module $logModule -Action $Description -Target $Target ` + -Status 'Adopted' -Attempt $attempt -ElapsedMs ([int]$sw.ElapsedMilliseconds) ` + -Detail 'Object reported as already existing on retry; treated as success.' + } + return + } + + $isTransient = Test-TransientServerError -ErrorRecord $err + $isLast = $attempt -ge $MaxAttempts + + if ($isTransient -and -not $isLast) { + $sleep = $BackoffSeconds[[Math]::Min($attempt - 1, $BackoffSeconds.Count - 1)] + $sleep += Get-Random -Minimum 0 -Maximum 3 + Write-Host (" ~ Transient error on '{0}' (attempt {1}/{2}). Sleeping {3}s and retrying..." -f $Description, $attempt, $MaxAttempts, $sleep) -ForegroundColor DarkYellow + Write-Host (" {0}" -f $err.Exception.Message) -ForegroundColor DarkGray + if ($logEnabled) { + Add-RunLogEntry -Module $logModule -Action $Description -Target $Target ` + -Status 'Retried' -Attempt $attempt ` + -Detail ("Transient: {0}. Sleeping {1}s." -f $err.Exception.Message, $sleep) + } + Start-Sleep -Seconds $sleep + + if ($OnRetry) { + try { & $OnRetry $attempt $err } catch { Write-Verbose "OnRetry callback failed: $($_.Exception.Message)" } + } + continue + } + + # Non-transient OR final attempt -- bubble up. + $sw.Stop() + if ($logEnabled) { + Add-RunLogEntry -Module $logModule -Action $Description -Target $Target ` + -Status 'Failed' -Attempt $attempt -ElapsedMs ([int]$sw.ElapsedMilliseconds) ` + -Detail $err.Exception.Message + } + throw + } + } +} diff --git a/Products/Purview/Modules/PurviewConfigurationContract.ps1 b/Products/Purview/Modules/PurviewConfigurationContract.ps1 new file mode 100644 index 0000000..a9886b0 --- /dev/null +++ b/Products/Purview/Modules/PurviewConfigurationContract.ps1 @@ -0,0 +1,231 @@ +#requires -Version 7.0 +# smb-quality-gate: read-only +<# +.SYNOPSIS + Validates and normalizes local Purview configuration values. + +.DESCRIPTION + This helper is local-only. It imports no service module and performs no + authentication, tenant read, or tenant write. +#> + +$script:PurviewBuiltInLabelPattern = + '^defa4170-0d19-0005-[0-9a-fA-F]{4}-bc88714345d2$' +$script:PurviewMicrosoft365CopilotLocationGuid = + '470f2276-e011-4e9d-a6ec-20768be3a4b0' +$script:PurviewMicrosoft365CopilotLocationToken = 'Microsoft365Copilot' +$script:PurviewSupportedRetentionLocations = @( + 'Exchange', + 'OneDrive', + 'SharePoint' +) +$script:PurviewKnownRetentionLocationTokens = @( + 'DefenderForCloudApps', + 'Exchange', + 'OneDrive', + 'OnPremisesScanner', + 'PowerBI', + 'SharePoint', + 'Teams' +) + +function Test-PurviewBuiltInLabelName { + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter()] + [AllowNull()] + [AllowEmptyString()] + [string] $Value + ) + + return [string]::IsNullOrWhiteSpace($Value) -or + $Value -match '^defa4170-0d19-0005-[0-9a-fA-F]{4}-bc88714345d2$' +} + +function Assert-PurviewLabelIdentityConfiguration { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [System.Collections.IDictionary] $Config + ) + + $labels = @( + if ($Config.Contains('Labels')) { @($Config['Labels']) } + ) + for ($rootIndex = 0; $rootIndex -lt $labels.Count; $rootIndex++) { + $root = $labels[$rootIndex] + if (-not $root) { continue } + + $rootBuiltInName = if ($root -is [System.Collections.IDictionary] -and + $root.Contains('BuiltInName')) { + [string]$root['BuiltInName'] + } else { '' } + if (-not (Test-PurviewBuiltInLabelName -Value $rootBuiltInName)) { + throw ( + "Configuration Labels[$rootIndex].BuiltInName is invalid. " + + "Use a Microsoft built-in signature matching " + + "'defa4170-0d19-0005-NNNN-bc88714345d2' or leave it empty for a custom label." + ) + } + + $children = @( + if ($root -is [System.Collections.IDictionary] -and + $root.Contains('SubLabels')) { + @($root['SubLabels']) + } + ) + for ($childIndex = 0; $childIndex -lt $children.Count; $childIndex++) { + $child = $children[$childIndex] + if (-not $child) { continue } + + $childBuiltInName = if ($child -is [System.Collections.IDictionary] -and + $child.Contains('BuiltInName')) { + [string]$child['BuiltInName'] + } else { '' } + if (-not (Test-PurviewBuiltInLabelName -Value $childBuiltInName)) { + throw ( + "Configuration Labels[$rootIndex].SubLabels[$childIndex].BuiltInName is invalid. " + + "Use a Microsoft built-in signature matching " + + "'defa4170-0d19-0005-NNNN-bc88714345d2' or leave it empty for a custom label." + ) + } + } + } +} + +function ConvertTo-PurviewCanonicalPublicLocation { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter()] + [AllowNull()] + [AllowEmptyString()] + [string] $Value + ) + + $candidate = $Value.Trim() + if ($candidate -ieq $script:PurviewMicrosoft365CopilotLocationGuid -or + $candidate -ieq $script:PurviewMicrosoft365CopilotLocationToken) { + return $script:PurviewMicrosoft365CopilotLocationToken + } + return $candidate +} + +function Get-PurviewLocationIdentity { + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [AllowEmptyString()] + [string] $Value + ) + + $candidate = $Value.Trim() + $canonical = ConvertTo-PurviewCanonicalPublicLocation -Value $candidate + if ($canonical -eq $script:PurviewMicrosoft365CopilotLocationToken) { + return [pscustomobject][ordered]@{ + Value = $canonical + Class = 'PublicProductLocation' + Digest = '' + } + } + if (-not $candidate) { + return [pscustomobject][ordered]@{ + Value = '' + Class = 'Empty' + Digest = '' + } + } + + $bytes = [Text.Encoding]::UTF8.GetBytes($candidate.ToLowerInvariant()) + $hash = [Security.Cryptography.SHA256]::HashData($bytes) + $protectedValue = Protect-PurviewConfigurationEvidenceText -Text $candidate + if ($protectedValue -eq $candidate) { + $protectedValue = '[REDACTED-LOCATION]' + } + return [pscustomobject][ordered]@{ + Value = $protectedValue + Class = 'CustomLocation' + Digest = 'sha256:' + ( + [Convert]::ToHexString($hash).ToLowerInvariant() + ).Substring(0, 24) + } +} + +function Protect-PurviewConfigurationEvidenceText { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter()] + [AllowNull()] + [AllowEmptyString()] + [string] $Text + ) + + if ([string]::IsNullOrWhiteSpace($Text)) { return '' } + $protected = $Text -replace '(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b', '[REDACTED-UPN]' + $protected = $protected -replace '(?i)\b[A-Z0-9-]+\.onmicrosoft\.com\b', '[REDACTED-DOMAIN]' + $protected = $protected -replace '(?i)https?://\S+', '[REDACTED-URL]' + $protected = $protected -replace '(?i)\b(?:[A-Z0-9-]+\.)+[A-Z]{2,}\b', '[REDACTED-DOMAIN]' + $protected = $protected -replace '(?i)\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b', '[REDACTED-GUID]' + if ($protected -match '(?i)(?:\b[A-Z]:\\|\\\\[^\\\s]+\\|/(?:home|Users)/)') { + return '[REDACTED-LOCAL-PATH]' + } + return $protected +} + +function Get-PurviewRetentionLocationClassification { + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Value + ) + + $values = if ($Value -is [string]) { + @($Value -split ',') + } else { + @($Value) + } + $supported = [Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase + ) + $unsupported = [Collections.Generic.List[string]]::new() + $unsupportedEvidence = [Collections.Generic.List[string]]::new() + + foreach ($item in $values) { + $candidate = ([string]$item).Trim() + if (-not $candidate) { continue } + + $canonical = @( + $script:PurviewSupportedRetentionLocations | + Where-Object { $_ -ieq $candidate } + ) | Select-Object -First 1 + if ($canonical) { + $null = $supported.Add([string]$canonical) + continue + } + + $unsupported.Add($candidate) + $knownToken = @( + $script:PurviewKnownRetentionLocationTokens | + Where-Object { $_ -ieq $candidate } + ) | Select-Object -First 1 + $evidence = if ($knownToken) { + [string]$knownToken + } else { + Protect-PurviewConfigurationEvidenceText -Text $candidate + } + if ($evidence -eq $candidate -and -not $knownToken) { + $evidence = '[REDACTED-LOCATION]' + } + $unsupportedEvidence.Add($evidence) + } + + return [pscustomobject][ordered]@{ + Supported = @($supported | Sort-Object) + Unsupported = @($unsupported | Sort-Object -Unique) + UnsupportedEvidence = @($unsupportedEvidence | Sort-Object -Unique) + } +} diff --git a/Products/Purview/Modules/PurviewRunLog.ps1 b/Products/Purview/Modules/PurviewRunLog.ps1 index 9df6a9a..4dd8d0c 100644 --- a/Products/Purview/Modules/PurviewRunLog.ps1 +++ b/Products/Purview/Modules/PurviewRunLog.ps1 @@ -1,184 +1,188 @@ -#requires -Version 7.0 -<# -.SYNOPSIS - Tier-2 per-action run log for the Purview Best Practice toolkit. - -.DESCRIPTION - Provides a singleton collection of structured "decision point" entries - that the orchestrator and individual Setup-* modules can append to - during a run. The end-of-run HTML report renders these entries grouped - by Module so an operator (or auditor) can see exactly what was - Created / Updated / Skipped / Adopted / Failed and why. - - DESIGN -- WHY $global: - - The orchestrator calls each Setup-* module via the call operator - (& $tenantScript @taskArgs), which executes the child script in its - OWN script scope. A $script:-scoped collection in this file would - therefore be invisible across modules. We use $global:PurviewRunLog - as the shared store: it's a single, well-namespaced variable, scoped - to the PowerShell session, and cleared by Initialize-PurviewRunLog - at the start of every orchestrator run. - - Setup-* modules dot-source this file and call Add-RunLogEntry; they - do NOT need to know about the storage mechanism. - - ENTRY SHAPE (hashtable, all fields optional except Module + Action): - - @{ - Timestamp = [datetime] ([datetime]::UtcNow auto-stamped) - Module = 'Setup-DLP' - Action = 'New-DlpCompliancePolicy' # what was attempted - Target = 'Block external sharing -- AllEmployees' # the - # object the action - # operated on - Status = 'Created' | 'Updated' | 'Adopted' | 'Skipped' - | 'Failed' | 'Retried' | 'Started' | 'Succeeded' - Detail = '...' # free-text context - Attempt = 1 # retry attempt # - ElapsedMs = 1234 # wall-clock if known - } - - EXPORTS (via dot-source): - * Initialize-PurviewRunLog - * Add-RunLogEntry - * Get-PurviewRunLog - * Clear-PurviewRunLog - * Save-PurviewRunLogJson -#> - -function Initialize-PurviewRunLog { - <# - Clear and re-initialise the global run log. Called once by the - orchestrator at the start of every deploy run. - #> - [CmdletBinding()] - param() - $global:PurviewRunLog = [System.Collections.Generic.List[hashtable]]::new() -} - -function Add-RunLogEntry { - <# - Append a single decision-point entry. Safe to call before - Initialize-PurviewRunLog (lazy-inits the list). Designed to NEVER - throw -- a logging bug must not crash the deploy. - - Common usage: - - Add-RunLogEntry -Module 'Setup-DLP' -Action 'New-DlpCompliancePolicy' ` - -Target $polName -Status 'Created' - - Add-RunLogEntry -Module 'Setup-SensitivityLabels' ` - -Action 'New-Label' -Target $disp ` - -Status 'Adopted' -Detail 'Duplicate display name; reusing existing GUID.' - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] [string] $Module, - [Parameter(Mandatory)] [string] $Action, - [string] $Target, - [ValidateSet('Started','Succeeded','Created','Updated','Adopted','Skipped','Failed','Retried','Info')] - [string] $Status = 'Info', - [string] $Detail, - [int] $Attempt, - [int] $ElapsedMs - ) - - try { - if (-not $global:PurviewRunLog) { - $global:PurviewRunLog = [System.Collections.Generic.List[hashtable]]::new() - } - - $entry = @{ - Timestamp = [datetime]::UtcNow - Module = $Module - Action = $Action - Target = $Target - Status = $Status - Detail = $Detail - } - if ($PSBoundParameters.ContainsKey('Attempt')) { $entry.Attempt = $Attempt } - if ($PSBoundParameters.ContainsKey('ElapsedMs')) { $entry.ElapsedMs = $ElapsedMs } - - $global:PurviewRunLog.Add($entry) - } catch { - # Logging must never break the deploy. Swallow. - Write-Verbose "Add-RunLogEntry failed: $($_.Exception.Message)" - } -} - -function Get-PurviewRunLog { - <# - Return the current run log as an array of hashtables (a copy -- - callers can iterate without worrying about concurrent mutation). - Returns an empty array when no entries have been recorded. - #> - [CmdletBinding()] - [OutputType([hashtable[]])] - param() - - if (-not $global:PurviewRunLog) { return @() } - # Force array output even when the list has 0 or 1 entries. - return ,@($global:PurviewRunLog.ToArray()) -} - -function Clear-PurviewRunLog { - <# - Drop the global run log. Called by the orchestrator after the - report has been written so the variable doesn't leak between - runs in interactive PowerShell sessions. - #> - [CmdletBinding()] - param() - Remove-Variable -Name 'PurviewRunLog' -Scope Global -ErrorAction SilentlyContinue -} - -function Save-PurviewRunLogJson { - <# - Dump the run log as JSON to -Path. Used by the orchestrator to - emit a machine-readable sidecar next to the HTML report. - Includes the run-level metadata (RunId, StartTime, EndTime, - ScriptVersion) so the JSON is self-contained. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] [string] $Path, - [Parameter(Mandatory)] [guid] $RunId, - [Parameter(Mandatory)] [datetime] $StartTime, - [Parameter(Mandatory)] [datetime] $EndTime, - [string] $ScriptVersion, - [string] $TenantId, - [string] $TenantAdminUpn, - $Summary - ) - - $entries = Get-PurviewRunLog - $payload = [ordered]@{ - runId = $RunId.ToString() - scriptVersion = $ScriptVersion - startTime = $StartTime.ToString('o') - endTime = $EndTime.ToString('o') - durationSec = [int](($EndTime - $StartTime).TotalSeconds) - tenantId = $TenantId - tenantAdmin = $TenantAdminUpn - summary = if ($Summary) { $Summary } else { @{} } - entryCount = $entries.Count - entries = $entries | ForEach-Object { - $e = $_ - [ordered]@{ - timestamp = $e.Timestamp.ToString('o') - module = $e.Module - action = $e.Action - target = $e.Target - status = $e.Status - detail = $e.Detail - attempt = if ($e.ContainsKey('Attempt')) { $e.Attempt } else { $null } - elapsedMs = if ($e.ContainsKey('ElapsedMs')) { $e.ElapsedMs } else { $null } - } - } - } - - $json = $payload | ConvertTo-Json -Depth 8 - Set-Content -Path $Path -Value $json -Encoding UTF8 - return $Path -} +#requires -Version 7.0 +<# +.SYNOPSIS + Tier-2 per-action run log for the Purview Best Practice toolkit. + +.DESCRIPTION + Provides a singleton collection of structured "decision point" entries + that the orchestrator and individual Setup-* modules can append to + during a run. The end-of-run HTML report renders these entries grouped + by Module so an operator (or auditor) can see exactly what was + Created / Updated / Skipped / Adopted / Failed and why. + + DESIGN -- WHY $global: + + The orchestrator calls each Setup-* module via the call operator + (& $tenantScript @taskArgs), which executes the child script in its + OWN script scope. A $script:-scoped collection in this file would + therefore be invisible across modules. We use $global:PurviewRunLog + as the shared store: it's a single, well-namespaced variable, scoped + to the PowerShell session, and cleared by Initialize-PurviewRunLog + at the start of every orchestrator run. + + Setup-* modules dot-source this file and call Add-RunLogEntry; they + do NOT need to know about the storage mechanism. + + ENTRY SHAPE (hashtable, all fields optional except Module + Action): + + @{ + Timestamp = [datetime] ([datetime]::UtcNow auto-stamped) + Module = 'Setup-DLP' + Action = 'New-DlpCompliancePolicy' # what was attempted + Target = 'Block external sharing -- AllEmployees' # the + # object the action + # operated on + Status = 'Created' | 'Updated' | 'Adopted' | 'Skipped' + | 'Failed' | 'Retried' | 'Started' | 'Succeeded' + Detail = '...' # free-text context + Attempt = 1 # retry attempt # + ElapsedMs = 1234 # wall-clock if known + } + + EXPORTS (via dot-source): + * Initialize-PurviewRunLog + * Add-RunLogEntry + * Get-PurviewRunLog + * Clear-PurviewRunLog + * Save-PurviewRunLogJson +#> + +function Initialize-PurviewRunLog { + <# + Clear and re-initialise the global run log. Called once by the + orchestrator at the start of every deploy run. + #> + [CmdletBinding()] + param() + $global:PurviewRunLog = [System.Collections.Generic.List[hashtable]]::new() +} + +function Add-RunLogEntry { + <# + Append a single decision-point entry. Safe to call before + Initialize-PurviewRunLog (lazy-inits the list). Designed to NEVER + throw -- a logging bug must not crash the deploy. + + Common usage: + + Add-RunLogEntry -Module 'Setup-DLP' -Action 'New-DlpCompliancePolicy' ` + -Target $polName -Status 'Created' + + Add-RunLogEntry -Module 'Setup-SensitivityLabels' ` + -Action 'New-Label' -Target $disp ` + -Status 'Adopted' -Detail 'Duplicate display name; reusing existing GUID.' + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Module, + [Parameter(Mandatory)] [string] $Action, + [string] $Target, + [ValidateSet('Started','Succeeded','Created','Updated','Adopted','Skipped','Failed','Retried','Info')] + [string] $Status = 'Info', + [string] $Detail, + [int] $Attempt, + [int] $ElapsedMs + ) + + try { + if (-not $global:PurviewRunLog) { + $global:PurviewRunLog = [System.Collections.Generic.List[hashtable]]::new() + } + + $entry = @{ + Timestamp = [datetime]::UtcNow + Module = $Module + Action = $Action + Target = $Target + Status = $Status + Detail = $Detail + } + if ($PSBoundParameters.ContainsKey('Attempt')) { $entry.Attempt = $Attempt } + if ($PSBoundParameters.ContainsKey('ElapsedMs')) { $entry.ElapsedMs = $ElapsedMs } + + $global:PurviewRunLog.Add($entry) + } catch { + # Logging must never break the deploy. Swallow. + Write-Verbose "Add-RunLogEntry failed: $($_.Exception.Message)" + } +} + +function Get-PurviewRunLog { + <# + Return the current run log as an array of hashtables (a copy -- + callers can iterate without worrying about concurrent mutation). + Returns an empty array when no entries have been recorded. + #> + [CmdletBinding()] + [OutputType([hashtable[]])] + param() + + if (-not $global:PurviewRunLog) { return @() } + # Force array output even when the list has 0 or 1 entries. + return ,@($global:PurviewRunLog.ToArray()) +} + +function Clear-PurviewRunLog { + <# + Drop the global run log. Called by the orchestrator after the + report has been written so the variable doesn't leak between + runs in interactive PowerShell sessions. + #> + [CmdletBinding()] + param() + Remove-Variable -Name 'PurviewRunLog' -Scope Global -ErrorAction SilentlyContinue +} + +function Save-PurviewRunLogJson { + <# + Dump the run log as JSON to -Path. Used by the orchestrator to + emit a machine-readable sidecar next to the HTML report. + Includes the run-level metadata (RunId, StartTime, EndTime, + ScriptVersion) so the JSON is self-contained. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Path, + [Parameter(Mandatory)] [guid] $RunId, + [Parameter(Mandatory)] [datetime] $StartTime, + [Parameter(Mandatory)] [datetime] $EndTime, + [string] $ScriptVersion, + [string] $TenantId, + [string] $TenantAdminUpn, + $Summary + ) + + $entries = Get-PurviewRunLog + $payload = [ordered]@{ + runId = $RunId.ToString() + scriptVersion = $ScriptVersion + startTime = $StartTime.ToString('o') + endTime = $EndTime.ToString('o') + durationSec = [int](($EndTime - $StartTime).TotalSeconds) + tenantId = $TenantId + tenantAdmin = $TenantAdminUpn + summary = if ($Summary) { $Summary } else { @{} } + entryCount = $entries.Count + entries = $entries | ForEach-Object { + $e = $_ + [ordered]@{ + timestamp = $e.Timestamp.ToString('o') + module = $e.Module + action = $e.Action + target = $e.Target + status = $e.Status + detail = $e.Detail + attempt = if ($e.ContainsKey('Attempt')) { $e.Attempt } else { $null } + elapsedMs = if ($e.ContainsKey('ElapsedMs')) { $e.ElapsedMs } else { $null } + } + } + } + + $json = $payload | ConvertTo-Json -Depth 8 + # -WhatIf:$false because this is local evidence output, not a tenant change. + # Without it the write inherits $WhatIfPreference from the orchestrator and a + # -WhatIf run silently produces no JSON sidecar, while the caller still + # reports a path that was never written. + Set-Content -Path $Path -Value $json -Encoding UTF8 -WhatIf:$false + return $Path +} diff --git a/Products/Purview/Modules/PurviewTenantIdentity.ps1 b/Products/Purview/Modules/PurviewTenantIdentity.ps1 new file mode 100644 index 0000000..9148947 --- /dev/null +++ b/Products/Purview/Modules/PurviewTenantIdentity.ps1 @@ -0,0 +1,146 @@ +#requires -Version 7.0 + +function Get-PurviewTenantIdentity { + [CmdletBinding()] + param() + + $identity = [pscustomobject]@{ + DisplayName = $null + TenantId = $null + DefaultDomain = $null + InitialDomain = $null + AllDomains = @() + Source = $null + } + + if (Get-Command Invoke-MgGraphRequest -ErrorAction SilentlyContinue) { + try { + $org = Invoke-MgGraphRequest -Method GET ` + -Uri 'https://graph.microsoft.com/v1.0/organization?$select=id,displayName,verifiedDomains' ` + -ErrorAction Stop + if ($org -and $org.value -and @($org.value).Count -gt 0) { + $item = @($org.value)[0] + $identity.DisplayName = $item.displayName + $identity.TenantId = $item.id + $identity.AllDomains = @($item.verifiedDomains | ForEach-Object { $_.name }) + $defaultDomain = @($item.verifiedDomains | Where-Object { $_.isDefault }) + $initialDomain = @($item.verifiedDomains | Where-Object { $_.isInitial }) + if ($defaultDomain.Count -gt 0) { $identity.DefaultDomain = $defaultDomain[0].name } + if ($initialDomain.Count -gt 0) { $identity.InitialDomain = $initialDomain[0].name } + $identity.Source = 'Microsoft Graph (/organization)' + return $identity + } + } catch { + Write-Verbose "Graph identity lookup failed, falling back to Exchange Online: $($_.Exception.Message)" + } + } + + try { + $organization = Get-OrganizationConfig -ErrorAction Stop + if ($organization) { + $identity.DisplayName = if ($organization.DisplayName) { + $organization.DisplayName + } else { + $organization.Name + } + if ($organization.PSObject.Properties['Guid'] -and $organization.Guid) { + $identity.TenantId = [string]$organization.Guid + } + } + $domains = @(Get-AcceptedDomain -ErrorAction Stop) + $identity.AllDomains = @($domains | ForEach-Object { $_.DomainName }) + $defaultDomain = @($domains | Where-Object { $_.Default }) + $initialDomain = @($domains | Where-Object { $_.InitialDomain }) + if ($defaultDomain.Count -gt 0) { $identity.DefaultDomain = $defaultDomain[0].DomainName } + if ($initialDomain.Count -gt 0) { $identity.InitialDomain = $initialDomain[0].DomainName } + $identity.Source = 'Exchange Online (Get-OrganizationConfig + Get-AcceptedDomain)' + return $identity + } catch { + throw "Could not resolve tenant identity from Graph or Exchange Online. Connection may have failed silently. Error: $($_.Exception.Message)" + } +} + +function Test-PurviewExpectedTenantMatch { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Identity, + [string] $TenantAdminUpn, + [string] $DelegatedOrganization + ) + + $expected = if ($DelegatedOrganization) { + $DelegatedOrganization.ToLowerInvariant() + } elseif ($TenantAdminUpn -match '@(.+)$') { + $Matches[1].ToLowerInvariant() + } else { + $null + } + $source = if ($DelegatedOrganization) { '-DelegatedOrganization' } else { 'admin UPN suffix' } + + if (-not $expected) { + return [pscustomobject]@{ + Match = $false + Matched = $false + Source = '(could not derive expected tenant from inputs)' + Expected = $null + Reason = 'Cannot validate tenant identity: no delegated organization was supplied and the admin UPN has no domain suffix.' + } + } + + $domains = @( + $Identity.AllDomains | + Where-Object { $_ } | + ForEach-Object { ([string]$_).ToLowerInvariant() } + ) + $match = $domains -contains $expected + return [pscustomobject]@{ + Match = $match + Matched = $match + Source = $source + Expected = $expected + Reason = if ($match) { + "Expected domain '$expected' from $source is verified on the connected tenant." + } else { + "Expected domain '$expected' from $source is NOT a verified domain on the connected tenant. The signed-in session appears to be authenticated against a DIFFERENT tenant than intended." + } + } +} + +function Test-PurviewValidationGraphContext { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Context, + [Parameter(Mandatory)] [string] $ExpectedAccount, + [Parameter(Mandatory)] [string[]] $RequiredScopes, + [string[]] $AllowedIdentityScopes = @('openid', 'profile', 'email', 'offline_access') + ) + + $actualScopes = @( + $Context.Scopes | + Where-Object { $_ } | + ForEach-Object { [string]$_ } | + Select-Object -Unique + ) + $allowedScopes = @($RequiredScopes + $AllowedIdentityScopes | Select-Object -Unique) + $missingScopes = @( + $RequiredScopes | + Where-Object { $actualScopes -notcontains $_ } | + Select-Object -Unique + ) + $unexpectedScopes = @( + $actualScopes | + Where-Object { $allowedScopes -notcontains $_ } | + Select-Object -Unique + ) + $accountMatched = [bool]$Context.Account -and + ([string]$Context.Account -ieq $ExpectedAccount) + + [pscustomobject]@{ + Matched = ($accountMatched -and + $missingScopes.Count -eq 0 -and + $unexpectedScopes.Count -eq 0) + AccountMatched = $accountMatched + MissingScopes = $missingScopes + UnexpectedScopes = $unexpectedScopes + } +} diff --git a/Products/Purview/Modules/PurviewValidationCollectors.ps1 b/Products/Purview/Modules/PurviewValidationCollectors.ps1 new file mode 100644 index 0000000..33ce135 --- /dev/null +++ b/Products/Purview/Modules/PurviewValidationCollectors.ps1 @@ -0,0 +1,1863 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Allowlisted read-only collectors for Purview configuration validation. + +.DESCRIPTION + Every collector reads tenant state and returns normalized managed fields + plus optional unscored differences. No collector writes, and no collector + executes a command name, property path, or script supplied by the + Deployment Plan. + + Dispatch is a hardcoded switch keyed by the adapter identifiers in + References/ValidationAdapters.psd1. The validation model derives an + allowlisted adapter from schema 1.2 intended state; a plan cannot introduce + one. Assert-PurviewValidationAdapterCoverage fails the run if the switch + and the allowlist ever diverge. + + Transient read failures go through the shared retry boundary + (Invoke-WithTransientRetry). A mismatch that survives the retries is drift, + never a retry candidate. + + EXPORTS (via dot-source): + * Assert-PurviewValidationAdapterCoverage + * New-PurviewValidationContext + * Get-PurviewValidationEntitlement + * Invoke-PurviewValidationCollection +#> + +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot 'PurviewValidationModel.ps1') +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') + +# Reads are retried far less aggressively than writes. A validation run is +# interactive and a half-read report is recoverable, so a long backoff buys +# nothing that a rerun does not. +$script:PurviewValidationReadAttempts = 3 +$script:PurviewValidationReadBackoff = @(2, 5) + +# Microsoft built-in sensitivity labels are created with an internal Name of +# 'defa4170-0d19-0005-NNNN-bc88714345d2'. The modern label scheme appends +# 'Group' to that Name for label groups. Both forms are the same identity. +$script:PurviewBuiltInLabelPattern = '^defa4170-0d19-0005-[0-9a-f]{4}-bc88714345d2$' + +# The Microsoft Group.Unified directory-setting template, which is where +# EnableMIPLabels lives. +$script:PurviewGroupUnifiedTemplateId = '62375ab9-6b52-47ed-826b-58e47e0e304b' + +# SKU part numbers recognized as entitlement evidence. These mirror +# Get-TenantPurviewLicenseTier in Deploy-PurviewBestPractice.ps1; the fixture +# Test-TenantConfigurationValidation.ps1 fails when the two lists drift apart. +$script:PurviewValidationE5Skus = @( + 'SPE_E5', 'SPE_E5_NOPSTNCONF', 'SPE_E5_CALLINGMINUTES', + 'SPE_E5_USGOV_GCCHIGH', + 'Microsoft_365_E5_(no_Teams)', 'Microsoft_365_E5_no_Teams', 'SPE_E5_NOPSTNCONF_no_Teams', + 'Microsoft_365_E5_EEA_(no_Teams)_with_Calling_Minutes', + 'Microsoft_365_E5_EEA_(no_Teams)_without_Audio_Conferencing', + 'ENTERPRISEPREMIUM', 'ENTERPRISEPREMIUM_NOPSTNCONF', + 'INFORMATION_PROTECTION_COMPLIANCE', + 'IDENTITY_THREAT_PROTECTION', + 'M365_E5_SUITE_COMPONENTS', + 'Microsoft_Purview_Suite', + 'INFORMATION_PROTECTION_AND_GOVERNANCE', + 'PURVIEW_SUITE_FOR_BUSINESS_PREMIUM', + 'PURVIEW_SUITE_FOR_BUSINESS_PREMIUM_NEW', + 'DEFENDER_AND_PURVIEW_SUITES_FOR_BUSINESS_PREMIUM', + 'DEFENDER_AND_PURVIEW_SUITES_FOR_BUSINESS_PREMIUM_NEW', + 'M365EDU_A5_FACULTY', 'M365EDU_A5_STUDENT', 'M365EDU_A5_STUUSEBNFT' +) +$script:PurviewValidationBpSkus = @( + 'SPB', 'BUSINESS_PREMIUM', + 'Microsoft_365_ Business_ Premium_(no Teams)', + 'Office_365_w/o_Teams_Bundle_Business_Premium', + 'Microsoft_365_Business_Premium_Donation_(Non_Profit_Pricing)' +) +$script:PurviewMicrosoft365CopilotLocationGuid = + '470f2276-e011-4e9d-a6ec-20768be3a4b0' +$script:PurviewMicrosoft365CopilotLocationToken = 'Microsoft365Copilot' + +function Get-PurviewValidationAdapterId { + <# + The adapter identifiers this file implements. Kept as a literal list so + coverage can be asserted against the allowlist rather than inferred. + #> + [CmdletBinding()] + [OutputType([string[]])] + param() + + return @( + 'purview.tenant.audit-standard' + 'purview.tenant.spo-labels' + 'purview.tenant.pdf-labels' + 'purview.tenant.container-directory-setting' + 'purview.tenant.label-coauthoring' + 'purview.tenant.audit-premium' + 'purview.labels.taxonomy' + 'purview.labels.priority' + 'purview.labels.publish' + 'purview.labels.attachment-inheritance' + 'purview.labels.content-marking' + 'purview.labels.encryption' + 'purview.labels.container-scope' + 'purview.dlp.workload' + 'purview.retention.exchange' + 'purview.ai.copilot-dlp' + ) +} + +function Get-PurviewValidationAdapterCapability { + <# + Session capabilities an adapter needs regardless of what the plan + declares as its prerequisite. + + The container-label adapter, for example, declares a license + prerequisite in the plan because entitlement is the operator-visible + gate. It also needs the Graph beta directory-setting commands. Without + this map a missing module would surface as a read failure rather than + the 'Not evaluated' the contract requires. + #> + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)] [string] $AdapterId) + + switch ($AdapterId) { + 'purview.tenant.spo-labels' { return 'SharePointOnlineSession' } + 'purview.tenant.pdf-labels' { return 'SharePointOnlineSession' } + 'purview.tenant.container-directory-setting' { return 'GraphDirectorySettings' } + default { return '' } + } +} + +function Assert-PurviewValidationAdapterCoverage { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [System.Collections.IDictionary] $Allowlist + ) + + $declared = @(@($Allowlist['Adapters']) | ForEach-Object { [string]$_['Id'] } | Sort-Object) + $implemented = @(Get-PurviewValidationAdapterId | Sort-Object) + + $difference = @(Compare-Object -ReferenceObject $declared -DifferenceObject $implemented) + if ($difference.Count -gt 0) { + $detail = ($difference | ForEach-Object { + if ($_.SideIndicator -eq '<=') { "allowlisted but not implemented: $($_.InputObject)" } + else { "implemented but not allowlisted: $($_.InputObject)" } + }) -join '; ' + throw "Validation adapter coverage mismatch. $detail" + } +} + +function ConvertTo-PurviewValidationBoolean { + [CmdletBinding()] + param([Parameter()] [AllowNull()] [object] $Value) + + if ($Value -is [bool]) { return [bool]$Value } + if ($Value -is [string]) { + $parsed = $false + if ([bool]::TryParse($Value.Trim(), [ref]$parsed)) { return $parsed } + return '__invalid_boolean__' + } + if ($null -eq $Value) { return $false } + return [bool]$Value +} + +function Get-PurviewValidationLiveLabelSignature { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter()] [AllowNull()] [object] $Label, + [Parameter()] [AllowNull()] [object[]] $IntendedLabels = @(), + [Parameter()] [AllowNull()] [hashtable] $LabelsById = $null + ) + + if (-not $Label) { return '' } + $name = [string]$Label.Name + if ([string]::IsNullOrWhiteSpace($name)) { return '' } + + $candidate = $name.Trim() + if ($candidate -match '(?i)^(defa4170-0d19-0005-[0-9a-f]{4}-bc88714345d2)Group$') { + $candidate = $Matches[1] + } + if ($candidate -match "(?i)$script:PurviewBuiltInLabelPattern") { + return 'builtin:' + $candidate.ToLowerInvariant() + } + + $parentKey = '' + $parentHashName = '' + if ($Label.PSObject.Properties['ParentId'] -and $Label.ParentId -and $LabelsById) { + $parentId = [string]$Label.ParentId + if ($LabelsById.ContainsKey($parentId)) { + $parentLabel = $LabelsById[$parentId] + $parentHashName = ([string]$parentLabel.Name).Trim() + $parentKey = Get-PurviewValidationLiveLabelSignature ` + -Label $parentLabel ` + -IntendedLabels $IntendedLabels ` + -LabelsById $LabelsById + $intendedParent = @($IntendedLabels | Where-Object { + [string]$_.Key -eq $parentKey + }) | Select-Object -First 1 + if ($intendedParent -and + [string]$intendedParent.Name -and + [string]$intendedParent.Name -notmatch '^\[REDACTED-') { + $parentHashName = [string]$intendedParent.Name + } + } + } + + foreach ($intended in @($IntendedLabels)) { + if ($parentKey) { + if ([string]$intended.ParentKey -eq $parentKey -and + [string]$intended.Name -eq $candidate) { + return [string]$intended.Key + } + } elseif (-not [string]$intended.ParentKey -and + [string]$intended.Name -eq $candidate) { + return [string]$intended.Key + } + } + + $hashInput = if ($parentHashName) { + "$($parentHashName.Trim().ToLowerInvariant())/$($candidate.Trim().ToLowerInvariant())" + } else { + $candidate.Trim().ToLowerInvariant() + } + $digest = Get-PurviewValidationOpaqueDigest -Value $hashInput + return 'custom:' + ($digest -replace '^sha256:', '') +} + +function Test-PurviewValidationLabelUsable { + <# + Soft-deleted labels stay in Get-Label output with Mode='PendingDeletion' + for about 30 days. Counting them would report a taxonomy as present + when it is in fact a tombstone. + #> + [CmdletBinding()] + param( + [Parameter()] [AllowNull()] [object] $Label + ) + + if (-not $Label) { return $false } + if ($Label.PSObject.Properties['Mode'] -and [string]$Label.Mode -eq 'PendingDeletion') { return $false } + if ($Label.PSObject.Properties['Disabled'] -and [bool]$Label.Disabled) { return $false } + return $true +} + +function Test-PurviewValidationManaged { + [CmdletBinding()] + param( + [Parameter()] [AllowNull()] [object] $Object, + [Parameter()] [string] $Tag + ) + + if (-not $Object) { return $false } + if ([string]::IsNullOrWhiteSpace($Tag)) { return $true } + if (-not $Object.PSObject.Properties['Comment']) { return $false } + return ([string]$Object.Comment).Contains($Tag, [StringComparison]::OrdinalIgnoreCase) +} + +function Get-PurviewValidationManagedLabel { + <# + Splits a tenant's labels into the ones this toolkit manages and the + ones it does not. + + Scoring across every label in the tenant was the single largest source + of false drift: a customer who had labels before onboarding, or who + adds "Board Confidential" afterwards, would fail taxonomy, priority, + encryption, content marking, and container scope on the next run. The + toolkit stamps its own labels' Comment with the managed tag, so + ownership is decidable. + + Soft-deleted labels stay in Get-Label output for about 30 days with + Mode 'PendingDeletion'. Counting a tombstone as present would report a + deleted taxonomy as healthy. + #> + [CmdletBinding()] + param( + [Parameter()] [AllowNull()] [object] $Labels, + [Parameter()] [string] $ManagedByTag = '' + ) + + $usable = @($Labels | Where-Object { Test-PurviewValidationLabelUsable -Label $_ }) + $tombstoned = @($Labels | Where-Object { $_ -and -not (Test-PurviewValidationLabelUsable -Label $_) }) + $managed = @($usable | Where-Object { Test-PurviewValidationManaged -Object $_ -Tag $ManagedByTag }) + $unmanaged = @($usable | Where-Object { -not (Test-PurviewValidationManaged -Object $_ -Tag $ManagedByTag) }) + + return [pscustomobject]@{ + Managed = $managed + Unmanaged = $unmanaged + Tombstoned = $tombstoned + } +} + +function Get-PurviewValidationLabelById { + [CmdletBinding()] + param([Parameter()] [AllowNull()] [object] $Labels) + + $labelsById = @{} + foreach ($label in @($Labels)) { + if ($label -and $label.PSObject.Properties['Guid']) { + $labelsById[[string]$label.Guid] = $label + } + } + return $labelsById +} + +function New-PurviewValidationLabelContextNote { + <# + Turns the labels outside the toolkit's ownership into unscored context + so an operator can see them without the report failing them. + #> + [CmdletBinding()] + param([Parameter(Mandatory)] [psobject] $Partition) + + $notes = [Collections.Generic.List[object]]::new() + if (@($Partition.Unmanaged).Count -gt 0) { + $notes.Add([pscustomobject]@{ + Field = 'UnmanagedLabels' + Expected = 'not scored' + Observed = "$(@($Partition.Unmanaged).Count) label(s) not managed by the toolkit" + }) + } + if (@($Partition.Tombstoned).Count -gt 0) { + $notes.Add([pscustomobject]@{ + Field = 'SoftDeletedLabels' + Expected = 'none' + Observed = "$(@($Partition.Tombstoned).Count) label(s) pending deletion" + }) + } + return $notes.ToArray() +} + +function New-PurviewValidationContext { + <# + Holds per-run caches, resolved entitlement, and session capability + state so a shared read such as Get-Label happens once for all seven + label adapters. + + Capabilities come from the connection result, not from command + availability. The SharePoint module is imported before the connection + is attempted, so a failed Connect-SPOService still leaves Get-SPOTenant + defined. Trusting command presence would report a read failure where + the contract requires 'Not evaluated'. + #> + [CmdletBinding()] + param( + [Parameter()] [string] $ManagedByTag = '', + [Parameter()] [AllowNull()] [psobject] $Entitlement = $null, + [Parameter()] [System.Collections.IDictionary] $Capabilities = @{}, + [Parameter()] [object[]] $IntendedLabels = @() + ) + + return [pscustomobject]@{ + ManagedByTag = $ManagedByTag + Entitlement = $Entitlement + Capabilities = $Capabilities + IntendedLabels = @($IntendedLabels) + Cache = @{} + } +} + +function Invoke-PurviewValidationRead { + <# + Runs one tenant read through the shared retry boundary and reports how + many attempts it took, so the report can distinguish a clean read from + one that only succeeded after a transient failure. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Description, + [Parameter(Mandatory)] [scriptblock] $Read, + [Parameter()] [string] $Module = 'Test-PurviewTenantConfiguration' + ) + + $counter = [pscustomobject]@{ Attempts = 0 } + $resultState = [pscustomobject]@{ Value = $null } + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + $action = { + $counter.Attempts++ + $attemptValue = @(& $Read) + $resultState.Value = if ($attemptValue.Count -eq 0) { + $null + } elseif ($attemptValue.Count -eq 1) { + $attemptValue[0] + } else { + $attemptValue + } + }.GetNewClosure() + + try { + $null = Invoke-WithTransientRetry ` + -Description $Description ` + -Module $Module ` + -MaxAttempts $script:PurviewValidationReadAttempts ` + -BackoffSeconds $script:PurviewValidationReadBackoff ` + -Action $action + } finally { + $stopwatch.Stop() + } + + return [pscustomobject]@{ + Value = $resultState.Value + Attempts = [int]$counter.Attempts + ElapsedMs = [int]$stopwatch.ElapsedMilliseconds + } +} + +function Get-PurviewValidationCachedRead { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [psobject] $Context, + [Parameter(Mandatory)] [string] $Key, + [Parameter(Mandatory)] [string] $Description, + [Parameter(Mandatory)] [scriptblock] $Read + ) + + if ($Context.Cache.ContainsKey($Key)) { + $cached = $Context.Cache[$Key] + if ($cached.Failure) { throw $cached.Failure } + return $cached + } + + try { + $result = Invoke-PurviewValidationRead -Description $Description -Read $Read + $entry = [pscustomobject]@{ + Value = $result.Value + Attempts = $result.Attempts + ElapsedMs = $result.ElapsedMs + Failure = $null + } + $Context.Cache[$Key] = $entry + return $entry + } catch { + $Context.Cache[$Key] = [pscustomobject]@{ + Value = $null + Attempts = $script:PurviewValidationReadAttempts + ElapsedMs = 0 + Failure = $_.Exception.Message + } + throw + } +} + +function Resolve-PurviewValidationCommand { + <# + Both Exchange Online and Security & Compliance PowerShell expose + Get-AdminAuditLogConfig, but only Exchange Online reports the real + UnifiedAuditLogIngestionEnabled value: the Security & Compliance copy + always returns False. Because the toolkit connects Security & + Compliance after Exchange Online, a bare call can bind to the wrong + session and report drift on a correctly configured tenant. + + Reference: Get-AdminAuditLogConfig, Exchange PowerShell (verified + 2026-08-14). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Name, + [Parameter()] [string] $SiblingWriteCommand = '' + ) + + if ($SiblingWriteCommand) { + $writeCommand = Get-Command -Name $SiblingWriteCommand -ErrorAction SilentlyContinue + if ($writeCommand) { + $matched = Get-Command -Name $Name -All -ErrorAction SilentlyContinue | + Where-Object { $_.Source -eq $writeCommand.Source } | + Select-Object -First 1 + if ($matched) { return $matched } + } + return $null + } + + return Get-Command -Name $Name -ErrorAction SilentlyContinue +} + +function Get-PurviewValidationEntitlement { + <# + Reads /subscribedSkus once and classifies the tenant. + + A tenant that cannot be classified is reported as 'Unknown'. The + workload collector is still allowed to read because some services can + prove support without license inventory. A tenant that is classified + as not entitled is the only license state that blocks scoring. + #> + [CmdletBinding()] + param() + + $result = [pscustomobject]@{ + Tier = 'Unknown' + Reason = '' + Available = $false + } + + if (-not (Get-Command -Name 'Invoke-MgGraphRequest' -ErrorAction SilentlyContinue)) { + $result.Reason = 'Microsoft Graph session is not available.' + return $result + } + + try { + $read = Invoke-PurviewValidationRead -Description 'GET /subscribedSkus' -Read { + Invoke-MgGraphRequest -Method GET ` + -Uri 'https://graph.microsoft.com/v1.0/subscribedSkus' -ErrorAction Stop + } + $response = $read.Value + } catch { + $result.Reason = ConvertTo-PurviewValidationRedactedText -Text ( + 'Subscribed SKU query failed: ' + $_.Exception.Message) + return $result + } + + if (-not $response -or -not $response.value) { + $result.Reason = 'Subscribed SKU response was empty.' + return $result + } + + $partNumbers = @( + $response.value | + Where-Object { $_.capabilityStatus -ne 'Suspended' -and $_.capabilityStatus -ne 'Deleted' } | + ForEach-Object { [string]$_.skuPartNumber } | + Where-Object { $_ } + ) + + $result.Available = $true + if (@($partNumbers | Where-Object { $_ -in $script:PurviewValidationE5Skus }).Count -gt 0) { + $result.Tier = 'E5OrPurviewSuite' + $result.Reason = 'A Microsoft 365 E5 or Purview Suite SKU is present.' + return $result + } + if (@($partNumbers | Where-Object { $_ -in $script:PurviewValidationBpSkus }).Count -gt 0) { + $result.Tier = 'BusinessPremium' + $result.Reason = 'A Microsoft 365 Business Premium SKU is present.' + return $result + } + + $result.Tier = 'Other' + $result.Reason = 'No recognized Business Premium, E5, or Purview Suite SKU is present.' + return $result +} + +function Test-PurviewValidationPrerequisite { + <# + Resolves a declared prerequisite to Satisfied, Unsatisfied, or Unknown. + + 'Unsatisfied' produces 'Not evaluated'. 'Unknown' license inventory + does not block collection, because a workload read can prove support + without requiring license-assignment scope. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [psobject] $Context, + [Parameter(Mandatory)] [string] $Kind, + [Parameter()] [string] $Id = '' + ) + + if ($Kind -eq 'None') { + return [pscustomobject]@{ State = 'Satisfied'; Detail = 'No prerequisite declared.' } + } + + if ($Kind -eq 'Capability') { + # The connection result is authoritative. Command availability is only + # a fallback for direct callers that supplied no capability state. + $capabilities = if ($Context.PSObject.Properties['Capabilities'] -and $Context.Capabilities) { + $Context.Capabilities + } else { + @{} + } + if ($capabilities.Contains($Id)) { + if ([bool]$capabilities[$Id]) { + return [pscustomobject]@{ State = 'Satisfied'; Detail = "The '$Id' session is available." } + } + return [pscustomobject]@{ + State = 'Unsatisfied' + Detail = "The '$Id' session was not established, so this setting could not be read." + } + } + + switch ($Id) { + 'SharePointOnlineSession' { + if (Get-Command -Name 'Get-SPOTenant' -ErrorAction SilentlyContinue) { + return [pscustomobject]@{ State = 'Satisfied'; Detail = 'SharePoint Online session is available.' } + } + return [pscustomobject]@{ + State = 'Unsatisfied' + Detail = 'No SharePoint Online session is available, so the tenant setting could not be read.' + } + } + 'GraphDirectorySettings' { + if (Get-Command -Name 'Get-MgBetaDirectorySetting' -ErrorAction SilentlyContinue) { + return [pscustomobject]@{ State = 'Satisfied'; Detail = 'Graph directory-setting commands are available.' } + } + return [pscustomobject]@{ + State = 'Unsatisfied' + Detail = 'Graph directory-setting commands are not available in this session.' + } + } + default { + return [pscustomobject]@{ State = 'Unknown'; Detail = "Unrecognized capability prerequisite '$Id'." } + } + } + } + + $entitlement = $Context.Entitlement + $entitlementAvailable = $entitlement -and + $entitlement.PSObject.Properties['Available'] -and + [bool]$entitlement.Available + if (-not $entitlementAvailable) { + $detail = if ($entitlement -and $entitlement.Reason) { $entitlement.Reason } else { 'Entitlement could not be read.' } + return [pscustomobject]@{ + State = 'Satisfied' + Detail = "$detail Continuing with workload read because license inventory is unavailable." + } + } + + $tier = [string]$entitlement.Tier + $satisfied = switch ($Id) { + 'BusinessPremiumOrHigher' { $tier -in @('BusinessPremium', 'E5OrPurviewSuite') } + 'E5OrPurviewSuite' { $tier -eq 'E5OrPurviewSuite' } + 'AuditPremium' { $tier -eq 'E5OrPurviewSuite' } + default { $false } + } + + if ($satisfied) { + return [pscustomobject]@{ State = 'Satisfied'; Detail = $entitlement.Reason } + } + + return [pscustomobject]@{ + State = 'Unsatisfied' + Detail = "The '$Id' prerequisite is not satisfied. $($entitlement.Reason)" + } +} + +function Get-PurviewValidationLabelSet { + [CmdletBinding()] + param([Parameter(Mandatory)] [psobject] $Context) + + return Get-PurviewValidationCachedRead -Context $Context -Key 'labels' ` + -Description 'Get-Label' -Read { Get-Label -ErrorAction Stop } +} + +function Get-PurviewValidationLabelPolicySet { + [CmdletBinding()] + param([Parameter(Mandatory)] [psobject] $Context) + + return Get-PurviewValidationCachedRead -Context $Context -Key 'label-policies' ` + -Description 'Get-LabelPolicy' -Read { Get-LabelPolicy -ErrorAction Stop } +} + +function Get-PurviewValidationDlpPolicySet { + [CmdletBinding()] + param([Parameter(Mandatory)] [psobject] $Context) + + return Get-PurviewValidationCachedRead -Context $Context -Key 'dlp-policies' ` + -Description 'Get-DlpCompliancePolicy' -Read { Get-DlpCompliancePolicy -ErrorAction Stop } +} + +function Get-PurviewValidationDlpRuleSet { + [CmdletBinding()] + param([Parameter(Mandatory)] [psobject] $Context) + + return Get-PurviewValidationCachedRead -Context $Context -Key 'dlp-rules' ` + -Description 'Get-DlpComplianceRule' -Read { Get-DlpComplianceRule -ErrorAction Stop } +} + +function Get-PurviewValidationLabelAdvancedSetting { + [CmdletBinding()] + param( + [Parameter()] [AllowNull()] [object] $Object, + [Parameter(Mandatory)] [string] $Name + ) + + if (-not $Object -or -not $Object.PSObject.Properties['Settings']) { return '' } + if ($Object.Settings -is [System.Collections.IDictionary]) { + foreach ($key in @($Object.Settings.Keys)) { + if ([string]$key -ieq $Name) { return [string]$Object.Settings[$key] } + } + } + foreach ($setting in @($Object.Settings)) { + if (-not $setting) { continue } + if ($setting -is [System.Collections.IDictionary]) { + $key = if ($setting.Contains('key')) { [string]$setting['key'] } elseif ($setting.Contains('Key')) { [string]$setting['Key'] } elseif ($setting.Contains('Name')) { [string]$setting['Name'] } else { '' } + $value = if ($setting.Contains('value')) { [string]$setting['value'] } elseif ($setting.Contains('Value')) { [string]$setting['Value'] } else { '' } + if ($key -ieq $Name) { return $value } + continue + } + if ($setting.PSObject.Properties['Key'] -and ([string]$setting.Key) -ieq $Name) { + return [string]$setting.Value + } + if ($setting.PSObject.Properties['Name'] -and ([string]$setting.Name) -ieq $Name) { + return [string]$setting.Value + } + $rendered = [string]$setting + if ($rendered -match '^\s*\[\s*([^,\]]+?)\s*,\s*(.*?)\s*\]\s*$') { + if ([string]$Matches[1] -ieq $Name) { return [string]$Matches[2] } + continue + } + $parts = @($rendered -split '\s*[:=]\s*', 2) + if ($parts.Count -eq 2 -and [string]$parts[0] -ieq $Name) { + return [string]$parts[1] + } + } + return '' +} + +function Measure-PurviewValidationRuleLabel { + <# + Counts sensitivity-label operands on a persisted DLP rule. + + Non-endpoint rules store them under ContentContainsSensitiveInformation; + endpoint rules store the same operands inside AdvancedRule JSON. A + persisted operand legitimately shows name and id as the same GUID. + #> + [CmdletBinding()] + [OutputType([int])] + param( + [Parameter()] [AllowNull()] [object] $Rule + ) + + if (-not $Rule) { return 0 } + + $count = 0 + if ($Rule.PSObject.Properties['ContentContainsSensitiveInformation'] -and + $Rule.ContentContainsSensitiveInformation) { + foreach ($group in @($Rule.ContentContainsSensitiveInformation.groups)) { + if (-not $group) { continue } + $count += @($group.labels).Count + } + } + + if ($count -eq 0 -and $Rule.PSObject.Properties['AdvancedRule'] -and $Rule.AdvancedRule) { + try { + $advanced = [string]$Rule.AdvancedRule | ConvertFrom-Json -Depth 20 + foreach ($subCondition in @($advanced.Condition.SubConditions)) { + foreach ($value in @($subCondition.Value)) { + foreach ($group in @($value.Groups)) { + $count += @($group.Labels).Count + } + } + } + } catch { + Write-Verbose "AdvancedRule could not be parsed: $($_.Exception.Message)" + } + } + + return [int]$count +} + +function Get-PurviewValidationRuleLabelSignature { + [CmdletBinding()] + param( + [Parameter()] [AllowNull()] [object] $Rule, + [Parameter()] [AllowNull()] [hashtable] $LabelsById = $null, + [Parameter()] [object[]] $IntendedLabels = @() + ) + + if (-not $Rule) { return @() } + + $ids = [Collections.Generic.List[string]]::new() + if ($Rule.PSObject.Properties['ContentContainsSensitiveInformation'] -and + $Rule.ContentContainsSensitiveInformation) { + foreach ($group in @($Rule.ContentContainsSensitiveInformation.groups)) { + if (-not $group) { continue } + foreach ($label in @($group.labels)) { + if (-not $label) { continue } + $value = if ($label.PSObject.Properties['name']) { + [string]$label.name + } elseif ($label.PSObject.Properties['id']) { + [string]$label.id + } else { [string]$label } + if ($value) { $ids.Add($value) } + } + } + } + if ($Rule.PSObject.Properties['AdvancedRule'] -and $Rule.AdvancedRule) { + try { + $advanced = [string]$Rule.AdvancedRule | ConvertFrom-Json -Depth 20 + foreach ($subCondition in @($advanced.Condition.SubConditions)) { + foreach ($value in @($subCondition.Value)) { + foreach ($group in @($value.Groups)) { + foreach ($label in @($group.Labels)) { + $value = if ($label.PSObject.Properties['name']) { + [string]$label.name + } elseif ($label.PSObject.Properties['id']) { + [string]$label.id + } else { [string]$label } + if ($value) { $ids.Add($value) } + } + } + } + } + } catch { + Write-Verbose "AdvancedRule could not be parsed: $($_.Exception.Message)" + } + } + + return @( + foreach ($id in @($ids | Sort-Object -Unique)) { + if ($LabelsById -and $LabelsById.ContainsKey($id)) { + Get-PurviewValidationLiveLabelSignature ` + -Label $LabelsById[$id] ` + -IntendedLabels $IntendedLabels ` + -LabelsById $LabelsById + } else { + 'unknown:' + ((Get-PurviewValidationOpaqueDigest -Value $id) -replace '^sha256:', '') + } + } + ) +} + +function Select-PurviewValidationDlpPolicy { + [CmdletBinding()] + param( + [Parameter()] [AllowNull()] [object] $Policies, + [Parameter(Mandatory)] [string] $Selector, + [Parameter()] [string] $ManagedByTag = '' + ) + + $workloadPattern = switch ($Selector) { + 'Exchange' { '(?i)exchange' } + 'SharePointOneDrive' { '(?i)sharepoint|onedrive' } + 'Endpoint' { '(?i)endpoint|devices' } + default { '(?i)' + [regex]::Escape($Selector) } + } + + $candidates = @( + foreach ($policy in @($Policies)) { + if (-not $policy) { continue } + if (-not (Test-PurviewValidationManaged -Object $policy -Tag $ManagedByTag)) { continue } + $workload = if ($policy.PSObject.Properties['Workload']) { [string]$policy.Workload } else { '' } + if ($workload -match $workloadPattern) { $policy } + } + ) + + foreach ($candidate in $candidates) { $candidate } +} + +function Get-PurviewValidationOpaqueDigest { + [CmdletBinding()] + param([Parameter(Mandatory)][AllowEmptyString()][string] $Value) + + $bytes = [Text.Encoding]::UTF8.GetBytes($Value.Trim().ToLowerInvariant()) + $hash = [Security.Cryptography.SHA256]::HashData($bytes) + return 'sha256:' + ([Convert]::ToHexString($hash).ToLowerInvariant()).Substring(0, 24) +} + +function Get-PurviewValidationIdentityClass { + [CmdletBinding()] + param([Parameter()][AllowEmptyString()][string] $Identity) + + if ([string]::IsNullOrWhiteSpace($Identity)) { return 'Empty' } + if ($Identity -eq 'All') { return 'All' } + return 'CustomPrincipal' +} + +function ConvertTo-PurviewValidationPublicLocation { + [CmdletBinding()] + param([Parameter()][AllowEmptyString()][string] $Value) + + if ($Value -ieq $script:PurviewMicrosoft365CopilotLocationGuid) { + return $script:PurviewMicrosoft365CopilotLocationToken + } + return $Value +} + +function Get-PurviewValidationLocationIdentity { + [CmdletBinding()] + param([Parameter()][AllowEmptyString()][string] $Value) + + $candidate = $Value.Trim() + $canonical = ConvertTo-PurviewValidationPublicLocation -Value $candidate + if ($canonical -eq $script:PurviewMicrosoft365CopilotLocationToken) { + return [pscustomobject]@{ + Value = $canonical + Class = 'PublicProductLocation' + Digest = '' + } + } + if (-not $candidate) { + return [pscustomobject]@{ + Value = '' + Class = 'Empty' + Digest = '' + } + } + return [pscustomobject]@{ + Value = '' + Class = 'CustomLocation' + Digest = Get-PurviewValidationOpaqueDigest -Value $candidate + } +} + +function Get-PurviewValidationAiScopeSet { + [CmdletBinding()] + param([Parameter()][object[]] $Policies) + + $scopeItems = @( + foreach ($policy in @($Policies)) { + foreach ($location in @($policy.Locations)) { + $workload = if ($location -is [System.Collections.IDictionary]) { + [string]$location['Workload'] + } elseif ($location.PSObject.Properties['Workload']) { + [string]$location.Workload + } else { '' } + $target = if ($location -is [System.Collections.IDictionary]) { + [string]$location['Location'] + } elseif ($location.PSObject.Properties['Location']) { + [string]$location.Location + } else { '' } + $locationClass = if ($location -is [System.Collections.IDictionary]) { + [string]$location['LocationClass'] + } elseif ($location.PSObject.Properties['LocationClass']) { + [string]$location.LocationClass + } else { '' } + $locationDigest = if ($location -is [System.Collections.IDictionary]) { + [string]$location['LocationDigest'] + } elseif ($location.PSObject.Properties['LocationDigest']) { + [string]$location.LocationDigest + } else { '' } + if (-not $locationClass) { + $identity = Get-PurviewValidationLocationIdentity -Value $target + $locationClass = [string]$identity.Class + $locationDigest = [string]$identity.Digest + $target = [string]$identity.Value + } elseif ($locationClass -ne 'PublicProductLocation') { + $target = '' + } + $locationKey = "{0}|{1}|{2}|{3}" -f $workload.ToLowerInvariant(), $locationClass.ToLowerInvariant(), $locationDigest.ToLowerInvariant(), $target.ToLowerInvariant() + $entryCount = 0 + foreach ($kind in @('Inclusions', 'Exclusions')) { + $scopeKind = if ($kind -eq 'Inclusions') { 'include' } else { 'exclude' } + $items = if ($location -is [System.Collections.IDictionary]) { + @($location[$kind]) + } elseif ($location.PSObject.Properties[$kind]) { + @($location.$kind) + } else { @() } + $entryCount += @($items).Count + foreach ($item in @($items)) { + $type = if ($item -is [System.Collections.IDictionary]) { + [string]$item['Type'] + } elseif ($item.PSObject.Properties['Type']) { + [string]$item.Type + } else { '' } + $identity = if ($item -is [System.Collections.IDictionary]) { + [string]$item['Identity'] + } elseif ($item.PSObject.Properties['Identity']) { + [string]$item.Identity + } else { '' } + $class = Get-PurviewValidationIdentityClass -Identity $identity + $digest = if ($class -eq 'All' -or $class -eq 'Empty') { + '' + } else { + Get-PurviewValidationOpaqueDigest -Value $identity + } + "{0}|{1}|{2}|{3}|{4}" -f $scopeKind, $locationKey, $type, $class, $digest + } + } + if ($entryCount -eq 0) { + "scope|$locationKey" + } + } + } + ) + return @( + $scopeItems | + ForEach-Object { ([string]$_).ToLowerInvariant() } | + Sort-Object -Unique + ) +} + +function Invoke-PurviewValidationAdapter { + <# + The only dispatch point. The switch is hardcoded and exhaustive; the + plan selects a branch by allowlisted identifier and nothing more. + + Every branch returns: + Observed hashtable of managed field name to normalized value + Unscored redacted differences that are recorded but not scored + Query the read attribution shown in the report + Source the PowerShell module that answered the read + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [psobject] $Context, + [Parameter(Mandatory)] [string] $AdapterId, + [Parameter()] [string] $Selector = '' + ) + + $tag = [string]$Context.ManagedByTag + $intendedLabels = @($Context.IntendedLabels) + $unscored = [Collections.Generic.List[object]]::new() + + switch ($AdapterId) { + 'purview.tenant.audit-standard' { + $command = Resolve-PurviewValidationCommand -Name 'Get-AdminAuditLogConfig' ` + -SiblingWriteCommand 'Set-AdminAuditLogConfig' + if (-not $command) { throw 'Get-AdminAuditLogConfig is not available in this session.' } + $read = Invoke-PurviewValidationRead -Description 'Get-AdminAuditLogConfig' -Read { + & $command -ErrorAction Stop + }.GetNewClosure() + $config = $read.Value + return [pscustomobject]@{ + Observed = @{ + UnifiedAuditLogIngestionEnabled = ConvertTo-PurviewValidationBoolean $config.UnifiedAuditLogIngestionEnabled + } + Unscored = @() + Query = 'Get-AdminAuditLogConfig' + Source = [string]$command.Source + Attempts = $read.Attempts + ElapsedMs = $read.ElapsedMs + } + } + + 'purview.tenant.spo-labels' { + $read = Get-PurviewValidationCachedRead -Context $Context -Key 'spo-tenant' ` + -Description 'Get-SPOTenant' -Read { Get-SPOTenant -ErrorAction Stop } + $tenant = $read.Value + return [pscustomobject]@{ + Observed = @{ EnableAIPIntegration = ConvertTo-PurviewValidationBoolean $tenant.EnableAIPIntegration } + Unscored = @() + Query = 'Get-SPOTenant' + Source = 'Microsoft.Online.SharePoint.PowerShell' + Attempts = $read.Attempts + ElapsedMs = $read.ElapsedMs + } + } + + 'purview.tenant.pdf-labels' { + $read = Get-PurviewValidationCachedRead -Context $Context -Key 'spo-tenant' ` + -Description 'Get-SPOTenant' -Read { Get-SPOTenant -ErrorAction Stop } + $tenant = $read.Value + # Newer SharePoint Online builds removed the parameter because PDF + # labelling became built in. An absent property is reported as + # unscored context rather than silently treated as disabled. + if (-not $tenant.PSObject.Properties['EnableSensitivityLabelforPDF']) { + # Newer SharePoint Online builds removed the parameter because + # PDF labelling became built in, but an absent property could + # also mean something unexpected about this tenant or module + # version. Reporting it as satisfied would be a guess, so the + # action is left unevaluated with the reason recorded. + return [pscustomobject]@{ + Observed = @{} + Unscored = @([pscustomobject]@{ + Field = 'EnableSensitivityLabelforPDF' + Expected = 'property present' + Observed = 'property absent from Get-SPOTenant' + }) + NotEvaluated = $true + NotEvaluatedReason = 'Get-SPOTenant did not return EnableSensitivityLabelforPDF. Newer SharePoint Online builds removed the parameter because PDF labelling is built in; confirm the module version before treating this as drift.' + Query = 'Get-SPOTenant' + Source = 'Microsoft.Online.SharePoint.PowerShell' + Attempts = $read.Attempts + ElapsedMs = $read.ElapsedMs + } + } + return [pscustomobject]@{ + Observed = @{ + EnableSensitivityLabelForPDF = ConvertTo-PurviewValidationBoolean $tenant.EnableSensitivityLabelforPDF + } + Unscored = @() + Query = 'Get-SPOTenant' + Source = 'Microsoft.Online.SharePoint.PowerShell' + Attempts = $read.Attempts + ElapsedMs = $read.ElapsedMs + } + } + + 'purview.tenant.container-directory-setting' { + $read = Get-PurviewValidationCachedRead -Context $Context -Key 'directory-settings' ` + -Description 'Get-MgBetaDirectorySetting' -Read { + Get-MgBetaDirectorySetting -ErrorAction Stop + } + $settings = @($read.Value | Where-Object { + $_ -and $_.Values -and (@($_.Values | ForEach-Object { [string]$_.Name }) -contains 'EnableMIPLabels') + }) + # Prefer the documented Group.Unified template when it is present, + # so an unrelated directory-setting object that happens to expose + # the same value name cannot be read instead. + $setting = $settings | + Where-Object { [string]$_.TemplateId -eq $script:PurviewGroupUnifiedTemplateId } | + Select-Object -First 1 + if (-not $setting) { $setting = $settings | Select-Object -First 1 } + if (@($settings).Count -gt 1) { + $unscored.Add([pscustomobject]@{ + Field = 'DirectorySettings' + Expected = 'one setting exposing EnableMIPLabels' + Observed = "$(@($settings).Count) directory settings expose it" + }) + } + $value = if ($setting) { + [string]($setting.Values | Where-Object { [string]$_.Name -eq 'EnableMIPLabels' } | Select-Object -First 1 -ExpandProperty Value) + } else { + '' + } + return [pscustomobject]@{ + Observed = @{ EnableMIPLabels = $value } + Unscored = @($unscored) + Query = 'Get-MgBetaDirectorySetting' + Source = 'Microsoft.Graph.Beta.Identity.DirectoryManagement' + Attempts = $read.Attempts + ElapsedMs = $read.ElapsedMs + } + } + + 'purview.tenant.label-coauthoring' { + $read = Get-PurviewValidationCachedRead -Context $Context -Key 'policy-config' ` + -Description 'Get-PolicyConfig' -Read { Get-PolicyConfig -ErrorAction Stop } + $policyConfig = $read.Value + return [pscustomobject]@{ + Observed = @{ EnableLabelCoauth = ConvertTo-PurviewValidationBoolean $policyConfig.EnableLabelCoauth } + Unscored = @() + Query = 'Get-PolicyConfig' + Source = 'ExchangeOnlineManagement (Security and Compliance)' + Attempts = $read.Attempts + ElapsedMs = $read.ElapsedMs + } + } + + 'purview.tenant.audit-premium' { + # User mailboxes only. System, arbitration, discovery, room, and + # shared mailboxes do not carry SearchQueryInitiated by default, + # and an unfiltered sample of the first 25 recipients routinely + # includes them, which would report drift on a correctly + # configured tenant. + $read = Get-PurviewValidationCachedRead -Context $Context -Key 'mailbox-audit' ` + -Description 'Get-Mailbox (user mailbox audit sample)' -Read { + Get-Mailbox -RecipientTypeDetails UserMailbox -ResultSize 25 -ErrorAction Stop + } + $mailboxes = @($read.Value) + $withEvent = @( + foreach ($mailbox in $mailboxes) { + if (@($mailbox.AuditOwner) -contains 'SearchQueryInitiated') { $mailbox } + } + ) + $unscored.Add([pscustomobject]@{ + Field = 'MailboxSample' + Expected = 'not scored' + Observed = "$($withEvent.Count) of $($mailboxes.Count) sampled user mailboxes include the event" + }) + return [pscustomobject]@{ + Observed = @{ + AuditOwnerIncludesSearchQueryInitiated = ($mailboxes.Count -gt 0 -and $withEvent.Count -eq $mailboxes.Count) + } + Unscored = @($unscored) + Query = 'Get-Mailbox -RecipientTypeDetails UserMailbox -ResultSize 25' + Source = 'ExchangeOnlineManagement' + Attempts = $read.Attempts + ElapsedMs = $read.ElapsedMs + } + } + + 'purview.labels.taxonomy' { + $read = Get-PurviewValidationLabelSet -Context $Context + $partition = Get-PurviewValidationManagedLabel -Labels $read.Value -ManagedByTag $tag + $labels = @($partition.Managed) + $labelsById = Get-PurviewValidationLabelById -Labels $read.Value + $roots = @($labels | Where-Object { -not $_.ParentId }) + $subLabels = @($labels | Where-Object { $_.ParentId }) + return [pscustomobject]@{ + Observed = @{ + RootLabelCount = [int]$roots.Count + SubLabelCount = [int]$subLabels.Count + LabelSignatures = @($labels | ForEach-Object { + Get-PurviewValidationLiveLabelSignature -Label $_ -IntendedLabels $intendedLabels -LabelsById $labelsById + }) + } + Unscored = @(New-PurviewValidationLabelContextNote -Partition $partition) + Query = 'Get-Label' + Source = 'ExchangeOnlineManagement (Security and Compliance)' + Attempts = $read.Attempts + ElapsedMs = $read.ElapsedMs + } + } + + 'purview.labels.priority' { + $read = Get-PurviewValidationLabelSet -Context $Context + $partition = Get-PurviewValidationManagedLabel -Labels $read.Value -ManagedByTag $tag + $labels = @($partition.Managed) + $labelsById = Get-PurviewValidationLabelById -Labels $read.Value + # Purview orders labels by ascending priority within a hierarchy. + # The plan expects roots in configured order, each followed by its + # own sub-labels, which is the order the toolkit applies. Only + # toolkit-managed labels take part, so a customer label inserted + # between two of them does not reorder the comparison. + $ordered = [Collections.Generic.List[object]]::new() + foreach ($root in @($labels | Where-Object { -not $_.ParentId } | Sort-Object Priority)) { + $ordered.Add($root) + foreach ($child in @($labels | Where-Object { $_.ParentId -eq $root.Guid } | Sort-Object Priority)) { + $ordered.Add($child) + } + } + return [pscustomobject]@{ + Observed = @{ + PriorityOrder = @($ordered | ForEach-Object { + Get-PurviewValidationLiveLabelSignature -Label $_ -IntendedLabels $intendedLabels -LabelsById $labelsById + }) + } + Unscored = @(New-PurviewValidationLabelContextNote -Partition $partition) + Query = 'Get-Label' + Source = 'ExchangeOnlineManagement (Security and Compliance)' + Attempts = $read.Attempts + ElapsedMs = $read.ElapsedMs + } + } + + 'purview.labels.publish' { + $policyRead = Get-PurviewValidationLabelPolicySet -Context $Context + $labelRead = Get-PurviewValidationLabelSet -Context $Context + $policy = $policyRead.Value | Where-Object { + Test-PurviewValidationManaged -Object $_ -Tag $tag + } | Select-Object -First 1 + + $labelsById = @{} + foreach ($label in @($labelRead.Value)) { + if ($label -and $label.Guid) { $labelsById[[string]$label.Guid] = $label } + } + + $publishedSignatures = @() + $defaultSignature = '' + $defaultEmailSignature = '' + $mandatory = $false + $downgrade = $false + + if ($policy) { + $publishedSignatures = @( + foreach ($labelId in @($policy.Labels)) { + $key = [string]$labelId + if ($labelsById.ContainsKey($key)) { + Get-PurviewValidationLiveLabelSignature -Label $labelsById[$key] -IntendedLabels $intendedLabels -LabelsById $labelsById + } else { + '' + } + } + ) + $defaultLabelId = Get-PurviewValidationLabelAdvancedSetting -Object $policy -Name 'defaultlabelid' + if ($defaultLabelId -and $labelsById.ContainsKey($defaultLabelId)) { + $defaultSignature = Get-PurviewValidationLiveLabelSignature -Label $labelsById[$defaultLabelId] -IntendedLabels $intendedLabels -LabelsById $labelsById + } + $outlookDefault = Get-PurviewValidationLabelAdvancedSetting -Object $policy -Name 'outlookdefaultlabel' + if ($outlookDefault -and $labelsById.ContainsKey($outlookDefault)) { + $defaultEmailSignature = Get-PurviewValidationLiveLabelSignature -Label $labelsById[$outlookDefault] -IntendedLabels $intendedLabels -LabelsById $labelsById + } + $mandatory = (Get-PurviewValidationLabelAdvancedSetting -Object $policy -Name 'mandatory') -ieq 'true' + $downgrade = (Get-PurviewValidationLabelAdvancedSetting -Object $policy -Name 'requiredowngradejustification') -ieq 'true' + + $foreignPolicies = @($policyRead.Value | Where-Object { + -not (Test-PurviewValidationManaged -Object $_ -Tag $tag) + }) + if ($foreignPolicies.Count -gt 0) { + $unscored.Add([pscustomobject]@{ + Field = 'OtherLabelPolicies' + Expected = 'not scored' + Observed = "$($foreignPolicies.Count) label policy or policies not managed by the toolkit" + }) + } + $managedPolicies = @($policyRead.Value | Where-Object { + Test-PurviewValidationManaged -Object $_ -Tag $tag + }) + if ($managedPolicies.Count -gt 1) { + $unscored.Add([pscustomobject]@{ + Field = 'ManagedLabelPolicies' + Expected = 'one toolkit-managed label policy' + Observed = "$($managedPolicies.Count) carry the managed tag; only the first is scored" + }) + } + } + + return [pscustomobject]@{ + Observed = @{ + PolicyPresent = [bool]$policy + PublishedLabelSignatures = @($publishedSignatures) + DefaultLabelSignature = $defaultSignature + DefaultLabelForEmailSignature = $defaultEmailSignature + MandatoryLabelling = $mandatory + DowngradeJustification = $downgrade + } + Unscored = @($unscored) + Query = 'Get-LabelPolicy, Get-Label' + Source = 'ExchangeOnlineManagement (Security and Compliance)' + Attempts = [Math]::Max($policyRead.Attempts, $labelRead.Attempts) + ElapsedMs = $policyRead.ElapsedMs + $labelRead.ElapsedMs + } + } + + 'purview.labels.attachment-inheritance' { + $policyRead = Get-PurviewValidationLabelPolicySet -Context $Context + $policy = $policyRead.Value | Where-Object { + Test-PurviewValidationManaged -Object $_ -Tag $tag + } | Select-Object -First 1 + $attachmentAction = if ($policy) { + Get-PurviewValidationLabelAdvancedSetting -Object $policy -Name 'attachmentaction' + } else { + '' + } + return [pscustomobject]@{ + Observed = @{ AttachmentAction = $attachmentAction } + Unscored = @() + Query = 'Get-LabelPolicy' + Source = 'ExchangeOnlineManagement (Security and Compliance)' + Attempts = $policyRead.Attempts + ElapsedMs = $policyRead.ElapsedMs + } + } + + 'purview.labels.content-marking' { + $read = Get-PurviewValidationLabelSet -Context $Context + $partition = Get-PurviewValidationManagedLabel -Labels $read.Value -ManagedByTag $tag + $labelsById = Get-PurviewValidationLabelById -Labels $read.Value + $marked = @( + foreach ($label in @($partition.Managed)) { + $header = Get-PurviewValidationLabelAdvancedSetting -Object $label -Name 'applycontentmarkingheaderenabled' + $footer = Get-PurviewValidationLabelAdvancedSetting -Object $label -Name 'applycontentmarkingfooterenabled' + $watermark = Get-PurviewValidationLabelAdvancedSetting -Object $label -Name 'applywatermarkingenabled' + $applyMarking = $false + if ($label.PSObject.Properties['ApplyContentMarkingFooterEnabled'] -and + [bool]$label.ApplyContentMarkingFooterEnabled) { $applyMarking = $true } + if ($label.PSObject.Properties['ApplyContentMarkingHeaderEnabled'] -and + [bool]$label.ApplyContentMarkingHeaderEnabled) { $applyMarking = $true } + if ($label.PSObject.Properties['ApplyWaterMarkingEnabled'] -and + [bool]$label.ApplyWaterMarkingEnabled) { $applyMarking = $true } + if ($header -ieq 'true' -or $footer -ieq 'true' -or $watermark -ieq 'true') { $applyMarking = $true } + if ($applyMarking) { + Get-PurviewValidationLiveLabelSignature -Label $label -IntendedLabels $intendedLabels -LabelsById $labelsById + } + } + ) + return [pscustomobject]@{ + Observed = @{ ContentMarkedLabelSignatures = @($marked) } + Unscored = @(New-PurviewValidationLabelContextNote -Partition $partition) + Query = 'Get-Label' + Source = 'ExchangeOnlineManagement (Security and Compliance)' + Attempts = $read.Attempts + ElapsedMs = $read.ElapsedMs + } + } + + 'purview.labels.encryption' { + $read = Get-PurviewValidationLabelSet -Context $Context + $partition = Get-PurviewValidationManagedLabel -Labels $read.Value -ManagedByTag $tag + $labelsById = Get-PurviewValidationLabelById -Labels $read.Value + $encrypted = @() + $offlineValues = [Collections.Generic.List[int]]::new() + $expirationValues = [Collections.Generic.List[string]]::new() + foreach ($label in @($partition.Managed)) { + $enabled = $false + if ($label.PSObject.Properties['EncryptionEnabled'] -and [bool]$label.EncryptionEnabled) { $enabled = $true } + if ((Get-PurviewValidationLabelAdvancedSetting -Object $label -Name 'encryptionenabled') -ieq 'true') { $enabled = $true } + if (-not $enabled) { continue } + + $encrypted += Get-PurviewValidationLiveLabelSignature -Label $label -IntendedLabels $intendedLabels -LabelsById $labelsById + if ($label.PSObject.Properties['EncryptionOfflineAccessDays'] -and + $null -ne $label.EncryptionOfflineAccessDays) { + $offlineValues.Add([int]$label.EncryptionOfflineAccessDays) + } + if ($label.PSObject.Properties['EncryptionContentExpiredOnDateInDaysOrNever'] -and + $label.EncryptionContentExpiredOnDateInDaysOrNever) { + $expirationValues.Add([string]$label.EncryptionContentExpiredOnDateInDaysOrNever) + } + } + + # The plan carries one tenant-wide expectation for these two + # settings. Taking whichever label the enumeration happened to + # visit last would make the answer depend on Get-Label ordering, + # so disagreement between labels is reported rather than resolved. + $unscoredEncryption = [Collections.Generic.List[object]]::new() + foreach ($note in @(New-PurviewValidationLabelContextNote -Partition $partition)) { + $unscoredEncryption.Add($note) + } + $distinctOffline = @($offlineValues | Sort-Object -Unique) + $offlineDays = if ($distinctOffline.Count -eq 1) { [int]$distinctOffline[0] } else { -1 } + if ($distinctOffline.Count -gt 1) { + $unscoredEncryption.Add([pscustomobject]@{ + Field = 'EncryptionOfflineAccessDays' + Expected = 'one value across managed encrypted labels' + Observed = "$($distinctOffline.Count) different values" + }) + } + $distinctExpiration = @($expirationValues | Sort-Object -Unique) + $expiration = if ($distinctExpiration.Count -eq 1) { [string]$distinctExpiration[0] } else { '' } + if ($distinctExpiration.Count -gt 1) { + $unscoredEncryption.Add([pscustomobject]@{ + Field = 'EncryptionContentExpiration' + Expected = 'one value across managed encrypted labels' + Observed = "$($distinctExpiration.Count) different values" + }) + } + + return [pscustomobject]@{ + Observed = @{ + EncryptedLabelSignatures = @($encrypted) + EncryptionOfflineAccessDays = $offlineDays + EncryptionContentExpiration = $expiration + } + Unscored = @($unscoredEncryption.ToArray()) + Query = 'Get-Label' + Source = 'ExchangeOnlineManagement (Security and Compliance)' + Attempts = $read.Attempts + ElapsedMs = $read.ElapsedMs + } + } + + 'purview.labels.container-scope' { + $read = Get-PurviewValidationLabelSet -Context $Context + $partition = Get-PurviewValidationManagedLabel -Labels $read.Value -ManagedByTag $tag + $labelsById = Get-PurviewValidationLabelById -Labels $read.Value + $scoped = @( + foreach ($label in @($partition.Managed)) { + $contentType = if ($label.PSObject.Properties['ContentType']) { [string]$label.ContentType } else { '' } + if ($contentType -match '(?i)\b(Site|UnifiedGroup)\b') { + Get-PurviewValidationLiveLabelSignature -Label $label -IntendedLabels $intendedLabels -LabelsById $labelsById + } + } + ) + return [pscustomobject]@{ + Observed = @{ ContainerScopedLabelSignatures = @($scoped) } + Unscored = @(New-PurviewValidationLabelContextNote -Partition $partition) + Query = 'Get-Label' + Source = 'ExchangeOnlineManagement (Security and Compliance)' + Attempts = $read.Attempts + ElapsedMs = $read.ElapsedMs + } + } + + 'purview.dlp.workload' { + if (-not $Selector) { throw 'The DLP adapter requires a workload selector.' } + $policyRead = Get-PurviewValidationDlpPolicySet -Context $Context + $ruleRead = Get-PurviewValidationDlpRuleSet -Context $Context + $labelRead = Get-PurviewValidationLabelSet -Context $Context + $labelsById = Get-PurviewValidationLabelById -Labels $labelRead.Value + $policies = @(Select-PurviewValidationDlpPolicy -Policies $policyRead.Value ` + -Selector $Selector -ManagedByTag $tag + ) + + $rules = [Collections.Generic.List[object]]::new() + $policySignatures = [Collections.Generic.List[string]]::new() + foreach ($policy in $policies) { + # A real DLP policy commonly carries several rules. Only the + # toolkit's own rule is scored; the rest are counted as + # unscored context so a customer rule neither fails the check + # nor hides behind a first-match. + $policyRules = @($ruleRead.Value | Where-Object { + $_ -and $_.PSObject.Properties['ParentPolicyName'] -and + ([string]$_.ParentPolicyName -eq [string]$policy.Name) + }) + $rule = $policyRules | + Where-Object { Test-PurviewValidationManaged -Object $_ -Tag $tag } | + Select-Object -First 1 + if ($rule) { $rules.Add($rule) } + $ruleLabelSignatures = @( + Get-PurviewValidationRuleLabelSignature ` + -Rule $rule ` + -LabelsById $labelsById ` + -IntendedLabels $intendedLabels | + ForEach-Object { ([string]$_).ToLowerInvariant() } | + Sort-Object -Unique + ) + $ruleBlock = $rule -and + $rule.PSObject.Properties['BlockAccess'] -and + [bool]$rule.BlockAccess + $policySignatures.Add(( + 'mode={0}|block={1}|labels={2}' -f ( + [string]$policy.Mode + ).ToLowerInvariant(), ( + [bool]$ruleBlock + ).ToString().ToLowerInvariant(), ( + $ruleLabelSignatures -join ',' + ) + )) + $otherRules = @($policyRules | Where-Object { + -not (Test-PurviewValidationManaged -Object $_ -Tag $tag) + }) + if ($otherRules.Count -gt 0) { + $unscored.Add([pscustomobject]@{ + Field = 'OtherPolicyRules' + Expected = 'not scored' + Observed = "$($otherRules.Count) rule(s) on this policy not managed by the toolkit" + }) + } + if ($rule -and $rule.PSObject.Properties['Disabled'] -and [bool]$rule.Disabled) { + $unscored.Add([pscustomobject]@{ + Field = 'RuleDisabled' + Expected = 'not scored' + Observed = 'the matched rule is disabled' + }) + } + if ($rule) { + foreach ($fieldName in @('NotifyUser', 'GenerateIncidentReport')) { + if (-not $rule.PSObject.Properties[$fieldName]) { continue } + $count = @($rule.$fieldName).Count + if ($count -le 0) { continue } + $unscored.Add([pscustomobject]@{ + Field = $fieldName + Expected = 'not scored' + Observed = "$count privacy-safe principal value(s) configured" + }) + } + } + } + $policyModes = @( + $policies | + ForEach-Object { [string]$_.Mode } | + Where-Object { $_ } | + Sort-Object -Unique + ) + $labelPathCount = [int]@( + $rules | + ForEach-Object { Measure-PurviewValidationRuleLabel -Rule $_ } | + Measure-Object -Sum + ).Sum + $labelSignatures = @( + $rules | + ForEach-Object { + Get-PurviewValidationRuleLabelSignature ` + -Rule $_ ` + -LabelsById $labelsById ` + -IntendedLabels $intendedLabels + } | + Sort-Object -Unique + ) + + return [pscustomobject]@{ + Observed = @{ + PolicyPresent = $policies.Count -gt 0 + PolicyMode = @($policyModes) + RulePresent = $policies.Count -gt 0 -and $rules.Count -eq $policies.Count + BlockAccess = $rules.Count -gt 0 -and @( + $rules | + Where-Object { + -not ($_.PSObject.Properties['BlockAccess'] -and [bool]$_.BlockAccess) + } + ).Count -eq 0 + LabelPathCount = $labelPathCount + LabelSignatures = @($labelSignatures) + PolicySignatures = @($policySignatures | Sort-Object -Unique) + } + Unscored = @($unscored) + Query = 'Get-DlpCompliancePolicy, Get-DlpComplianceRule' + Source = 'ExchangeOnlineManagement (Security and Compliance)' + Attempts = [Math]::Max($policyRead.Attempts, $ruleRead.Attempts) + ElapsedMs = $policyRead.ElapsedMs + $ruleRead.ElapsedMs + } + } + + 'purview.retention.exchange' { + $policyRead = Get-PurviewValidationCachedRead -Context $Context -Key 'retention-policies' ` + -Description 'Get-RetentionCompliancePolicy' -Read { + Get-RetentionCompliancePolicy -DistributionDetail -ErrorAction Stop + } + $ruleRead = Get-PurviewValidationCachedRead -Context $Context -Key 'retention-rules' ` + -Description 'Get-RetentionComplianceRule' -Read { + Get-RetentionComplianceRule -ErrorAction Stop + } + $policy = $policyRead.Value | Where-Object { + Test-PurviewValidationManaged -Object $_ -Tag $tag + } | Select-Object -First 1 + $rule = $null + if ($policy) { + $rule = $ruleRead.Value | Where-Object { + $_ -and $_.PSObject.Properties['Policy'] -and + ([string]$_.Policy -eq [string]$policy.Guid -or [string]$_.Policy -eq [string]$policy.Name) -and + (Test-PurviewValidationManaged -Object $_ -Tag $tag) + } | Select-Object -First 1 + } + + $durationDays = -1 + if ($rule -and $rule.PSObject.Properties['RetentionDuration'] -and + $null -ne $rule.RetentionDuration) { + $parsed = 0 + if ([int]::TryParse([string]$rule.RetentionDuration, [ref]$parsed)) { $durationDays = $parsed } + } + + $locations = @() + if ($policy -and $policy.PSObject.Properties['ExchangeLocation'] -and + @($policy.ExchangeLocation).Count -gt 0) { $locations += 'exchange' } + if ($policy -and $policy.PSObject.Properties['SharePointLocation'] -and + @($policy.SharePointLocation).Count -gt 0) { $locations += 'sharepoint' } + if ($policy -and $policy.PSObject.Properties['OneDriveLocation'] -and + @($policy.OneDriveLocation).Count -gt 0) { $locations += 'onedrive' } + + return [pscustomobject]@{ + Observed = @{ + PolicyPresent = [bool]$policy + RetentionDurationDays = $durationDays + RetentionAction = if ($rule -and $rule.PSObject.Properties['RetentionComplianceAction']) { + [string]$rule.RetentionComplianceAction + } else { '' } + ExpirationDateOption = if ($rule -and $rule.PSObject.Properties['ExpirationDateOption']) { + [string]$rule.ExpirationDateOption + } else { '' } + Locations = @($locations) + } + Unscored = @() + Query = 'Get-RetentionCompliancePolicy -DistributionDetail, Get-RetentionComplianceRule' + Source = 'ExchangeOnlineManagement (Security and Compliance)' + Attempts = [Math]::Max($policyRead.Attempts, $ruleRead.Attempts) + ElapsedMs = $policyRead.ElapsedMs + $ruleRead.ElapsedMs + } + } + + 'purview.ai.copilot-dlp' { + $policyRead = Get-PurviewValidationDlpPolicySet -Context $Context + $ruleRead = Get-PurviewValidationDlpRuleSet -Context $Context + $labelRead = Get-PurviewValidationLabelSet -Context $Context + $labelsById = Get-PurviewValidationLabelById -Labels $labelRead.Value + $policies = @( + foreach ($policy in @($policyRead.Value)) { + if (-not (Test-PurviewValidationManaged -Object $policy -Tag $tag)) { continue } + $planes = if ($policy.PSObject.Properties['EnforcementPlanes']) { + @($policy.EnforcementPlanes) + } else { @() } + $isCopilot = @($planes | Where-Object { [string]$_ -match '(?i)copilot|agent' }).Count -gt 0 + if (-not $isCopilot -and $policy.PSObject.Properties['Workload'] -and + [string]$policy.Workload -match '(?i)applications') { + $isCopilot = $true + } + if ($isCopilot) { $policy } + } + ) + + $planes = @( + foreach ($policy in $policies) { + if (-not $policy.PSObject.Properties['EnforcementPlanes']) { continue } + foreach ($plane in @($policy.EnforcementPlanes)) { ([string]$plane).ToLowerInvariant() } + } + ) | Select-Object -Unique + $policyModes = @( + $policies | + ForEach-Object { [string]$_.Mode } | + Where-Object { $_ } | + Sort-Object -Unique + ) + $scopePrincipals = @(Get-PurviewValidationAiScopeSet -Policies $policies) + + $settings = [Collections.Generic.List[string]]::new() + $policySignatures = [Collections.Generic.List[string]]::new() + $labelCount = 0 + foreach ($policy in $policies) { + $rules = @($ruleRead.Value | Where-Object { + $_ -and $_.PSObject.Properties['ParentPolicyName'] -and + ([string]$_.ParentPolicyName -eq [string]$policy.Name) -and + (Test-PurviewValidationManaged -Object $_ -Tag $tag) + }) + foreach ($rule in $rules) { + $labelCount += Measure-PurviewValidationRuleLabel -Rule $rule + if (-not $rule.PSObject.Properties['RestrictAccess']) { continue } + foreach ($restriction in @($rule.RestrictAccess)) { + if (-not $restriction) { continue } + $setting = if ($restriction -is [System.Collections.IDictionary]) { + [string]$restriction['setting'] + } elseif ($restriction.PSObject.Properties['setting']) { + [string]$restriction.setting + } else { '' } + $value = if ($restriction -is [System.Collections.IDictionary]) { + [string]$restriction['value'] + } elseif ($restriction.PSObject.Properties['value']) { + [string]$restriction.value + } else { '' } + $settings.Add(("{0}={1}" -f $setting, $value).ToLowerInvariant()) + } + } + $policyRuleLabelItems = @( + foreach ($rule in $rules) { + Get-PurviewValidationRuleLabelSignature ` + -Rule $rule ` + -LabelsById $labelsById ` + -IntendedLabels $intendedLabels + } + ) + $policyRuleLabelSignatures = @( + $policyRuleLabelItems | + ForEach-Object { ([string]$_).ToLowerInvariant() } | + Sort-Object -Unique + ) + $policyRestrictionItems = @( + foreach ($rule in $rules) { + if ($rule.PSObject.Properties['RestrictAccess']) { + @($rule.RestrictAccess) + } + } + ) + $policyRestrictions = @( + $policyRestrictionItems | + ForEach-Object { + if (-not $_) { return '' } + $setting = if ($_ -is [System.Collections.IDictionary]) { [string]$_['setting'] } elseif ($_.PSObject.Properties['setting']) { [string]$_.setting } else { '' } + $value = if ($_ -is [System.Collections.IDictionary]) { [string]$_['value'] } elseif ($_.PSObject.Properties['value']) { [string]$_.value } else { '' } + ("{0}={1}" -f $setting, $value).ToLowerInvariant() + } | + Where-Object { $_ } | + Sort-Object -Unique + ) + $policyPlaneItems = if ($policy.PSObject.Properties['EnforcementPlanes']) { + @($policy.EnforcementPlanes) + } else { @() } + $policyPlanes = @( + $policyPlaneItems | + ForEach-Object { ([string]$_).ToLowerInvariant() } | + Sort-Object -Unique + ) + $policyScope = @(Get-PurviewValidationAiScopeSet -Policies @($policy)) + $policySignatures.Add(( + 'mode={0}|planes={1}|restrictions={2}|scope={3}|labels={4}' -f ( + [string]$policy.Mode + ).ToLowerInvariant(), ( + $policyPlanes -join ',' + ), ( + $policyRestrictions -join ',' + ), ( + $policyScope -join ',' + ), ( + $policyRuleLabelSignatures -join ',' + ) + )) + } + + return [pscustomobject]@{ + Observed = @{ + PolicyCount = [int]$policies.Count + PolicyMode = @($policyModes) + EnforcementPlanes = @($planes) + RestrictAccessSettings = @($settings | Select-Object -Unique) + ScopePrincipalSignatures = @($scopePrincipals) + LabelPathCount = [int]$labelCount + PolicySignatures = @($policySignatures | Sort-Object -Unique) + } + Unscored = @() + Query = 'Get-DlpCompliancePolicy, Get-DlpComplianceRule' + Source = 'ExchangeOnlineManagement (Security and Compliance)' + Attempts = [Math]::Max($policyRead.Attempts, $ruleRead.Attempts) + ElapsedMs = $policyRead.ElapsedMs + $ruleRead.ElapsedMs + } + } + + default { + throw "Validation adapter '$AdapterId' has no collector implementation." + } + } +} + +function Invoke-PurviewValidationCollection { + <# + Assesses every action in the plan and returns one result per action. + + A failure in one collector never stops the run: the action becomes + 'Collection failed' and the next action is assessed, so the operator + still gets partial artifacts. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [psobject] $Plan, + [Parameter(Mandatory)] [psobject] $Context, + [Parameter()] [scriptblock] $Progress = $null + ) + + $results = [Collections.Generic.List[object]]::new() + if ($Plan.PSObject.Properties['IntendedState'] -and + $Plan.IntendedState -and + $Plan.IntendedState.PSObject.Properties['Labels']) { + $Context.IntendedLabels = @($Plan.IntendedState.Labels) + } + + foreach ($action in @($Plan.Actions)) { + if ($Progress) { & $Progress $action } + + $validation = $action.Validation + $adapterId = [string]$validation.AdapterId + $selector = if ($validation.PSObject.Properties['Selector']) { [string]$validation.Selector } else { '' } + + if (-not [bool]$validation.Scored) { + $results.Add((New-PurviewValidationResult -Action $action -Status 'Informational' ` + -Reason "The plan recorded this action as '$($action.Intent)', so tenant state is reported without scoring it." ` + -NextStep 'No action required unless the feature is intentionally brought into scope.' ` + -PrerequisiteDisposition 'Not applicable')) + continue + } + + $prerequisite = Test-PurviewValidationPrerequisite -Context $Context ` + -Kind ([string]$validation.Prerequisite.Kind) ` + -Id ([string]$validation.Prerequisite.Id) + + if ($prerequisite.State -ne 'Satisfied') { + $results.Add((New-PurviewValidationResult -Action $action -Status 'Not evaluated' ` + -Reason $prerequisite.Detail ` + -NextStep 'Confirm the prerequisite before treating this action as drift.' ` + -PrerequisiteDisposition $prerequisite.State)) + continue + } + + $requiredCapability = Get-PurviewValidationAdapterCapability -AdapterId $adapterId + if ($requiredCapability) { + $capability = Test-PurviewValidationPrerequisite -Context $Context ` + -Kind 'Capability' -Id $requiredCapability + if ($capability.State -ne 'Satisfied') { + $results.Add((New-PurviewValidationResult -Action $action -Status 'Not evaluated' ` + -Reason $capability.Detail ` + -NextStep 'Establish the required session and rerun validation for this action.' ` + -PrerequisiteDisposition $capability.State)) + continue + } + } + + try { + $observation = Invoke-PurviewValidationAdapter -Context $Context ` + -AdapterId $adapterId -Selector $selector + } catch { + $results.Add((New-PurviewValidationResult -Action $action -Status 'Collection failed' ` + -Reason 'The tenant read did not succeed after transient retries.' ` + -NextStep 'Check permissions, module availability, and service health, then rerun validation.' ` + -Query $adapterId ` + -Attempts $script:PurviewValidationReadAttempts ` + -ErrorText $_.Exception.Message ` + -PrerequisiteDisposition $prerequisite.State)) + continue + } + + if ($observation.PSObject.Properties['NotEvaluated'] -and [bool]$observation.NotEvaluated) { + $results.Add((New-PurviewValidationResult -Action $action -Status 'Not evaluated' ` + -Reason ([string]$observation.NotEvaluatedReason) ` + -NextStep 'Confirm the service or module capability before treating this action as drift.' ` + -UnscoredDifferences @($observation.Unscored) ` + -Query $observation.Query -Source $observation.Source ` + -Attempts $observation.Attempts -ElapsedMs $observation.ElapsedMs ` + -PrerequisiteDisposition 'Unknown')) + continue + } + + $comparisons = @( + foreach ($field in @($validation.Expected)) { + $fieldName = [string]$field.Field + $observedValue = if ($observation.Observed.ContainsKey($fieldName)) { + $observation.Observed[$fieldName] + } else { + $null + } + Compare-PurviewValidationField -Field $fieldName ` + -Comparator ([string]$field.Comparator) ` + -Expected $field.Value -Observed $observedValue + } + ) + + $mismatches = @($comparisons | Where-Object { -not $_.Matched }) + if ($mismatches.Count -gt 0) { + $fields = ($mismatches | ForEach-Object { $_.Field }) -join ', ' + $results.Add((New-PurviewValidationResult -Action $action -Status 'Drift' ` + -Reason "Managed field mismatch after transient retries: $fields." ` + -NextStep 'Review whether the deployment action failed or the setting changed afterward, then rerun the deployment for this action.' ` + -FieldComparisons $comparisons ` + -UnscoredDifferences @($observation.Unscored) ` + -Query $observation.Query -Source $observation.Source ` + -Attempts $observation.Attempts -ElapsedMs $observation.ElapsedMs ` + -PrerequisiteDisposition $prerequisite.State)) + continue + } + + $results.Add((New-PurviewValidationResult -Action $action -Status 'Matched' ` + -Reason 'Every managed field matched the plan.' ` + -NextStep 'No action required.' ` + -FieldComparisons $comparisons ` + -UnscoredDifferences @($observation.Unscored) ` + -Query $observation.Query -Source $observation.Source ` + -Attempts $observation.Attempts -ElapsedMs $observation.ElapsedMs ` + -PrerequisiteDisposition $prerequisite.State)) + } + + return $results.ToArray() +} diff --git a/Products/Purview/Modules/PurviewValidationModel.ps1 b/Products/Purview/Modules/PurviewValidationModel.ps1 new file mode 100644 index 0000000..9595815 --- /dev/null +++ b/Products/Purview/Modules/PurviewValidationModel.ps1 @@ -0,0 +1,1523 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Canonical model, comparison, redaction, and exit-code rules for Purview + configuration validation. + +.DESCRIPTION + This file is deliberately free of tenant calls. It parses and validates a + Deployment Plan, normalizes values, compares managed fields, applies the + documented result precedence, and computes the process exit code. + + Configuration validation is not a compliance assessment. It compares + observable tenant settings with one specific Deployment Plan and says + nothing about regulatory obligations, data-protection effectiveness, or + user adoption. + + Result vocabulary defined by the configuration-validation contract: + + Matched managed fields equal + Drift managed fields differ after transient retries + Not evaluated a documented prerequisite was unavailable + Informational the plan excluded or did not configure the action + Collection failed the read failed after transient retries + + Result precedence, highest first: + + 1. fatal plan, identity, or connection error (no results are produced) + 2. Collection failed + 3. Not evaluated + 4. Informational + 5. Drift + 6. Matched + + EXPORTS (via dot-source): + * Test-PurviewValidationPlan + * ConvertTo-PurviewValidationRedactedText + * ConvertTo-PurviewValidationComparable + * Compare-PurviewValidationField + * New-PurviewValidationResult + * New-PurviewValidationModel + * Get-PurviewValidationExitCode +#> + +Set-StrictMode -Version Latest + +$script:PurviewValidationStatuses = @( + 'Matched', 'Drift', 'Not evaluated', 'Informational', 'Collection failed' +) + +# Exit codes are a bitmask so a single run can report both drift and a +# collection gap without hiding either one behind the other. +$script:PurviewValidationExitClean = 0 +$script:PurviewValidationExitFatal = 1 +$script:PurviewValidationExitDrift = 2 +$script:PurviewValidationExitCollectionFailure = 4 + +$script:PurviewValidationRedactionTerms = @() + +function Set-PurviewValidationRedactionTerm { + <# + Registers additional literal terms to strip from free text, normally + the connected tenant's verified domains. + + The static patterns catch addresses, URLs, Microsoft-owned suffixes, + and GUIDs. They cannot catch a custom verified domain such as + contoso.com, because there is nothing in the string shape that + distinguishes it from any other host name. Those come from the + connected tenant at run start. + #> + [CmdletBinding()] + param( + [Parameter()] + [AllowEmptyCollection()] + [string[]] $Term = @() + ) + + $script:PurviewValidationRedactionTerms = @( + $Term | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + ForEach-Object { $_.Trim() } | + Select-Object -Unique | + Sort-Object -Property Length -Descending + ) +} + +function ConvertTo-PurviewValidationRedactedText { + <# + Removes tenant-identifying detail from free text before it reaches an + artifact. Applied to unscored object differences, collector errors, and + any other text a service returns. + + Managed expected and observed values are not routed through this + function because the collectors normalize them to booleans, counts, + enumeration strings, and Microsoft-global label signatures, none of + which identify a tenant. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter()] + [AllowNull()] + [AllowEmptyString()] + [string] $Text + ) + + if ([string]::IsNullOrEmpty($Text)) { return '' } + + $redacted = $Text + $redacted = [regex]::Replace($redacted, '(?i)https?://\S+', '[redacted-url]') + $redacted = [regex]::Replace($redacted, "(?i)[\w.\-+']+@[\w\-]+(\.[\w\-]+)+", '[redacted-address]') + foreach ($term in $script:PurviewValidationRedactionTerms) { + $redacted = [regex]::Replace($redacted, [regex]::Escape($term), '[redacted-domain]', + [Text.RegularExpressions.RegexOptions]::IgnoreCase) + } + $redacted = [regex]::Replace( + $redacted, + '(?i)\b[a-z0-9-]+\.(onmicrosoft\.com|sharepoint\.com|microsoftonline\.com)\b', + '[redacted-domain]') + $redacted = [regex]::Replace( + $redacted, + '(?i)\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b', + '[redacted-guid]') + + return $redacted +} + +function ConvertTo-PurviewValidationComparable { + <# + Normalizes a value into the shape the comparators expect: $null becomes + an empty string, booleans and integers keep their type, everything else + becomes a trimmed string, and collections become string arrays. + #> + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Value + ) + + if ($null -eq $Value) { return '' } + if ($Value -is [bool]) { return $Value } + if ($Value -is [int] -or $Value -is [long]) { return [int]$Value } + + if ($Value -is [string]) { return $Value.Trim() } + + if ($Value -is [System.Collections.IEnumerable]) { + return @( + foreach ($item in $Value) { + if ($null -eq $item) { continue } + if ($item -is [bool]) { ([string]$item).ToLowerInvariant(); continue } + ([string]$item).Trim() + } + ) + } + + return ([string]$Value).Trim() +} + +function Format-PurviewValidationValue { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter()] + [AllowNull()] + [object] $Value + ) + + $normalized = ConvertTo-PurviewValidationComparable -Value $Value + if ($normalized -is [bool]) { return ([string]$normalized).ToLowerInvariant() } + if ($normalized -is [array]) { + if ($normalized.Count -eq 0) { return '(empty)' } + return ($normalized -join ', ') + } + if ([string]::IsNullOrEmpty([string]$normalized)) { return '(not set)' } + return [string]$normalized +} + +function Compare-PurviewValidationField { + <# + Applies one allowlisted comparator to one managed field. + + Comparators are deliberately narrow. A permissive comparator would let + a real difference pass as a match, which is the failure mode this tool + exists to prevent. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Field, + [Parameter(Mandatory)] [string] $Comparator, + [Parameter()] [AllowNull()] [object] $Expected, + [Parameter()] [AllowNull()] [object] $Observed + ) + + $expectedValue = ConvertTo-PurviewValidationComparable -Value $Expected + $observedValue = ConvertTo-PurviewValidationComparable -Value $Observed + + $matched = switch ($Comparator) { + 'ExactBoolean' { + $expectedBoolean = $null + $observedBoolean = $null + $expectedParsed = if ($expectedValue -is [bool]) { + $expectedBoolean = [bool]$expectedValue + $true + } else { + [bool]::TryParse([string]$expectedValue, [ref]$expectedBoolean) + } + $observedParsed = if ($observedValue -is [bool]) { + $observedBoolean = [bool]$observedValue + $true + } else { + [bool]::TryParse([string]$observedValue, [ref]$observedBoolean) + } + $expectedParsed -and $observedParsed -and + $expectedBoolean -eq $observedBoolean + } + 'ExactString' { + [string]$expectedValue -ceq [string]$observedValue + } + 'CaseInsensitiveString' { + [string]$expectedValue -ieq [string]$observedValue + } + 'ExactInt' { + try { ([int]$expectedValue) -eq ([int]$observedValue) } catch { $false } + } + 'SetEquality' { + $expectedSet = @(@($expectedValue) | ForEach-Object { ([string]$_).ToLowerInvariant() } | Sort-Object -Unique) + $observedSet = @(@($observedValue) | ForEach-Object { ([string]$_).ToLowerInvariant() } | Sort-Object -Unique) + ($expectedSet -join "`u{001f}") -ceq ($observedSet -join "`u{001f}") + } + 'OrderedSequence' { + $expectedSequence = @(@($expectedValue) | ForEach-Object { ([string]$_).ToLowerInvariant() }) + $observedSequence = @(@($observedValue) | ForEach-Object { ([string]$_).ToLowerInvariant() }) + ($expectedSequence -join "`u{001f}") -ceq ($observedSequence -join "`u{001f}") + } + 'Presence' { + $present = if ($null -eq $Observed) { + $false + } elseif ($Observed -is [bool]) { + [bool]$Observed + } elseif ($Observed -is [string]) { + -not [string]::IsNullOrWhiteSpace($Observed) + } elseif ($Observed -is [System.Collections.IEnumerable]) { + @($Observed).Count -gt 0 + } elseif ($Observed -is [byte] -or $Observed -is [sbyte] -or + $Observed -is [int16] -or $Observed -is [uint16] -or + $Observed -is [int32] -or $Observed -is [uint32] -or + $Observed -is [int64] -or $Observed -is [uint64] -or + $Observed -is [single] -or $Observed -is [double] -or + $Observed -is [decimal]) { + [decimal]$Observed -gt 0 + } else { + $true + } + ([bool]$expectedValue) -eq $present + } + default { + throw "Unsupported comparator '$Comparator' for field '$Field'." + } + } + + return [pscustomobject][ordered]@{ + Field = $Field + Comparator = $Comparator + Matched = [bool]$matched + ExpectedText = Format-PurviewValidationValue -Value $expectedValue + ObservedText = Format-PurviewValidationValue -Value $observedValue + } +} + +function Test-PurviewValidationExpectedType { + <# + Rejects an expected value whose type does not suit its comparator. + + PowerShell casts are permissive in exactly the direction that hides a + defect: [bool]'false' is $true and [int]'1.5' is 2. A plan that carried + the string "false" for an ExactBoolean field would therefore report a + confident, wrong 'Matched'. Type is checked here, before anything + connects, rather than being absorbed by a cast at comparison time. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $ActionId, + [Parameter(Mandatory)] [string] $Field, + [Parameter(Mandatory)] [string] $Comparator, + [Parameter()] [AllowNull()] [object] $Value + ) + + $describe = "Deployment Plan action '$ActionId' field '$Field'" + + switch ($Comparator) { + { $_ -in @('ExactBoolean', 'Presence') } { + if ($Value -isnot [bool]) { + throw "$describe uses comparator '$Comparator' and must carry a boolean value." + } + } + 'ExactInt' { + if ($Value -isnot [int] -and $Value -isnot [long]) { + throw "$describe uses comparator 'ExactInt' and must carry an integer value." + } + } + { $_ -in @('ExactString', 'CaseInsensitiveString') } { + if ($null -ne $Value -and $Value -isnot [string]) { + throw "$describe uses comparator '$Comparator' and must carry a string value." + } + } + { $_ -in @('SetEquality', 'OrderedSequence') } { + foreach ($item in @($Value)) { + if ($null -eq $item) { continue } + if ($item -isnot [string]) { + throw "$describe uses comparator '$Comparator' and must carry string members." + } + } + } + } +} + +function Get-PurviewValidationSha256 { + [CmdletBinding()] + param([Parameter(Mandatory)][AllowEmptyString()][string] $Text) + + $bytes = [Text.Encoding]::UTF8.GetBytes($Text) + return [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData($bytes) + ).ToLowerInvariant() +} + +function Test-PurviewValidationJsonShape { + [CmdletBinding()] + param([Parameter(Mandatory)][string] $Raw) + + $document = [Text.Json.JsonDocument]::Parse($Raw) + try { + $root = $document.RootElement + function RequireObject([Text.Json.JsonElement] $Element, [string] $Name) { + if ($Element.ValueKind -ne [Text.Json.JsonValueKind]::Object) { + throw "Deployment Plan JSON shape '$Name' must be an object." + } + } + function RequireArray([Text.Json.JsonElement] $Element, [string] $Name) { + if ($Element.ValueKind -ne [Text.Json.JsonValueKind]::Array) { + throw "Deployment Plan JSON shape '$Name' must be an array." + } + } + function GetRequiredProperty([Text.Json.JsonElement] $Element, [string] $Name, [string] $Path) { + $value = [Text.Json.JsonElement]::new() + if (-not $Element.TryGetProperty($Name, [ref]$value)) { + throw "Deployment Plan JSON shape is missing required property '$Path'." + } + return $value + } + RequireObject $root 'root' + $state = GetRequiredProperty $root 'IntendedState' 'IntendedState' + RequireObject $state 'IntendedState' + foreach ($name in @('TenantSettings', 'Labels', 'DlpPolicies', 'AIGovernance')) { + RequireArray (GetRequiredProperty $state $name "IntendedState.$name") "IntendedState.$name" + } + $labelPolicy = GetRequiredProperty $state 'LabelPolicy' 'IntendedState.LabelPolicy' + RequireObject $labelPolicy 'IntendedState.LabelPolicy' + foreach ($name in @('PublishedLabelKeys', 'TenantDependentParentKeys')) { + RequireArray (GetRequiredProperty $labelPolicy $name "IntendedState.LabelPolicy.$name") "IntendedState.LabelPolicy.$name" + } + $encryption = GetRequiredProperty $state 'Encryption' 'IntendedState.Encryption' + RequireObject $encryption 'IntendedState.Encryption' + $null = GetRequiredProperty $encryption 'OfflineAccessDays' 'IntendedState.Encryption.OfflineAccessDays' + $null = GetRequiredProperty $encryption 'ContentExpiration' 'IntendedState.Encryption.ContentExpiration' + $retention = GetRequiredProperty $state 'Retention' 'IntendedState.Retention' + RequireObject $retention 'IntendedState.Retention' + foreach ($name in @('Locations', 'UnsupportedLocations')) { + RequireArray (GetRequiredProperty $retention $name "IntendedState.Retention.$name") "IntendedState.Retention.$name" + } + $null = GetRequiredProperty $retention 'LocationDisposition' 'IntendedState.Retention.LocationDisposition' + foreach ($label in (GetRequiredProperty $state 'Labels' 'IntendedState.Labels').EnumerateArray()) { + foreach ($name in @('ContentTypes')) { + RequireArray (GetRequiredProperty $label $name "IntendedState.Labels[].$name") "IntendedState.Labels[].$name" + } + if ($label.TryGetProperty('Rights', [ref]$encryption)) { + RequireArray (GetRequiredProperty $encryption 'Entries' 'IntendedState.Labels[].Rights.Entries') 'IntendedState.Labels[].Rights.Entries' + } + } + foreach ($dlp in (GetRequiredProperty $state 'DlpPolicies' 'IntendedState.DlpPolicies').EnumerateArray()) { + foreach ($name in @('LabelKeys', 'NotifyUser', 'GenerateIncidentReport')) { + RequireArray (GetRequiredProperty $dlp $name "IntendedState.DlpPolicies[].$name") "IntendedState.DlpPolicies[].$name" + } + } + foreach ($ai in (GetRequiredProperty $state 'AIGovernance' 'IntendedState.AIGovernance').EnumerateArray()) { + foreach ($name in @('EnforcementPlanes', 'Locations', 'LabelKeys', 'Restrictions')) { + RequireArray (GetRequiredProperty $ai $name "IntendedState.AIGovernance[].$name") "IntendedState.AIGovernance[].$name" + } + foreach ($location in (GetRequiredProperty $ai 'Locations' 'IntendedState.AIGovernance[].Locations').EnumerateArray()) { + $null = GetRequiredProperty $location 'LocationClass' 'IntendedState.AIGovernance[].Locations[].LocationClass' + $null = GetRequiredProperty $location 'LocationDigest' 'IntendedState.AIGovernance[].Locations[].LocationDigest' + } + } + } finally { + $document.Dispose() + } +} + +function Set-PurviewValidationFromIntendedState { + [CmdletBinding()] + param([Parameter(Mandatory)][psobject] $Plan) + + $state = $Plan.IntendedState + $labels = @($state.Labels) + $tenant = @($state.TenantSettings) + function GetRequiredStateItem([object[]] $Items, [string] $Property, [string] $Value, [string] $ActionId) { + $matches = @($Items | Where-Object { [string]$_.$Property -eq $Value }) + if ($matches.Count -ne 1) { + throw "Deployment Plan intended state for '$ActionId' must contain exactly one $Property '$Value'." + } + return $matches[0] + } + function GetStateItemsByKey([object[]] $Items, [string[]] $Keys, [string] $Prefix, [string] $ActionId, [bool] $Required) { + $allKeys = @( + @($Keys) | + ForEach-Object { [string]$_ } | + Where-Object { $_ } + ) + $requested = @( + $allKeys | + Where-Object { $_ -like "$Prefix*" } + ) + if ($Required -and $requested.Count -eq 0) { + throw "Deployment Plan action '$ActionId' has no intended-state keys with required prefix '$Prefix'." + } + $resolved = @( + foreach ($fullKey in $requested) { + $matches = @($Items | Where-Object { + $itemKey = [string]$_.Key + $canonicalKey = if ($itemKey -like "$Prefix*") { + $itemKey + } elseif ($Prefix -eq 'labels/') { + "$Prefix$itemKey" + } else { + $itemKey + } + $canonicalKey -eq $fullKey + }) + if ($matches.Count -ne 1) { + throw "Deployment Plan intended-state key '$fullKey' for '$ActionId' does not resolve to exactly one record." + } + $matches[0] + } + ) + return $resolved + } + function GetStringSet([object] $Value) { + return @( + @($Value) | + ForEach-Object { ([string]$_).Trim() } | + Where-Object { $_ } | + Sort-Object -Unique + ) + } + function TestKnownStateKeys([string[]] $Keys, [string] $ActionId) { + foreach ($key in @($Keys)) { + if ([string]::IsNullOrWhiteSpace($key)) { continue } + if ($key -like 'labels/*' -or + $key -like 'label-policy/*' -or + $key -like 'encryption/*' -or + $key -like 'dlp/*' -or + $key -like 'retention/*' -or + $key -like 'ai/*' -or + $key -like 'tenant.*') { + continue + } + throw "Deployment Plan action '$ActionId' has unsupported intended-state key '$key'." + } + } + function GetRequiredSingletonByKey([object[]] $Items, [string[]] $Keys, [string] $Prefix, [string] $ActionId) { + $matches = @(GetStateItemsByKey $Items $Keys $Prefix $ActionId $true) + if ($matches.Count -ne 1) { + throw "Deployment Plan action '$ActionId' must resolve exactly one intended-state key with prefix '$Prefix'." + } + return $matches[0] + } + function GetAiScopeSet([object[]] $Policies) { + $scopeItems = @( + foreach ($policy in @($Policies)) { + foreach ($location in @($policy.Locations)) { + $locationClass = [string]$location.LocationClass + $locationValue = if ($locationClass -eq 'PublicProductLocation') { + [string]$location.Location + } else { + '' + } + $locationKey = "{0}|{1}|{2}|{3}" -f ( + [string]$location.Workload + ).ToLowerInvariant(), $locationClass.ToLowerInvariant(), ( + [string]$location.LocationDigest + ).ToLowerInvariant(), $locationValue.ToLowerInvariant() + foreach ($principal in @($location.Inclusions)) { + "include|$locationKey|$([string]$principal.Type)|$([string]$principal.IdentityClass)|$([string]$principal.Digest)" + } + foreach ($principal in @($location.Exclusions)) { + "exclude|$locationKey|$([string]$principal.Type)|$([string]$principal.IdentityClass)|$([string]$principal.Digest)" + } + if (@($location.Inclusions).Count -eq 0 -and @($location.Exclusions).Count -eq 0) { + "scope|$locationKey" + } + } + } + ) + return @( + $scopeItems | + ForEach-Object { ([string]$_).ToLowerInvariant() } | + Sort-Object -Unique + ) + } + function GetAiExpectedLabelKeys([object] $Policy, [object[]] $AllLabels) { + $keys = [Collections.Generic.List[string]]::new() + foreach ($key in @($Policy.LabelKeys)) { + $children = @( + $AllLabels | + Where-Object { [string]$_.ParentKey -eq [string]$key } | + ForEach-Object { [string]$_.Key } + ) + if ([string]$Policy.LabelResolution -eq 'ExpandLabelGroupsAtRuntime' -and + $children.Count -gt 0) { + foreach ($child in $children) { $keys.Add($child) } + } else { + $keys.Add([string]$key) + } + } + return @($keys | Where-Object { $_ } | Sort-Object -Unique) + } + foreach ($action in @($Plan.Actions)) { + $actionId = [string]$action.ActionId + $stateKeys = @( + if ($action.PSObject.Properties['IntendedStateKeys']) { + @($action.IntendedStateKeys) | ForEach-Object { [string]$_ } + } + ) + TestKnownStateKeys $stateKeys $actionId + $expected = [Collections.Generic.List[object]]::new() + $adapterId = $actionId + $selector = '' + $prerequisite = [pscustomobject]@{ Kind = 'None'; Id = '' } + $requiresStateKeys = [string]$action.Intent -in @('Included', 'Conditional') + + function AddExpected([string] $Field, [string] $Comparator, [object] $Value) { + $expected.Add([pscustomobject]@{ + Field = $Field + Comparator = $Comparator + Value = $Value + }) + } + + switch ($actionId) { + 'purview.tenant.audit-standard' { + $item = GetRequiredStateItem $tenant 'Key' 'tenant.audit-standard' $actionId + AddExpected 'UnifiedAuditLogIngestionEnabled' 'ExactBoolean' ([bool]$item.DesiredValue) + } + 'purview.tenant.spo-labels' { + $item = GetRequiredStateItem $tenant 'Key' 'tenant.spo-labels' $actionId + AddExpected 'EnableAIPIntegration' 'ExactBoolean' ([bool]$item.DesiredValue) + $prerequisite = [pscustomobject]@{ Kind = 'Capability'; Id = 'SharePointOnlineSession' } + } + 'purview.tenant.pdf-labels' { + $item = GetRequiredStateItem $tenant 'Key' 'tenant.pdf-labels' $actionId + AddExpected 'EnableSensitivityLabelForPDF' 'ExactBoolean' ([bool]$item.DesiredValue) + $prerequisite = [pscustomobject]@{ Kind = 'Capability'; Id = 'SharePointOnlineSession' } + } + 'purview.tenant.container-directory-setting' { + $item = GetRequiredStateItem $tenant 'Key' 'tenant.container-labels' $actionId + AddExpected 'EnableMIPLabels' 'CaseInsensitiveString' ([string]$item.DesiredValue) + $prerequisite = [pscustomobject]@{ Kind = 'License'; Id = 'BusinessPremiumOrHigher' } + } + 'purview.tenant.label-coauthoring' { + $item = GetRequiredStateItem $tenant 'Key' 'tenant.label-coauthoring' $actionId + AddExpected 'EnableLabelCoauth' 'ExactBoolean' ([bool]$item.DesiredValue) + } + 'purview.tenant.audit-premium' { + AddExpected 'AuditOwnerIncludesSearchQueryInitiated' 'ExactBoolean' $true + $prerequisite = [pscustomobject]@{ Kind = 'License'; Id = 'AuditPremium' } + } + 'purview.labels.taxonomy' { + $adapterId = 'purview.labels.taxonomy' + $actionLabels = @(GetStateItemsByKey $labels $stateKeys 'labels/' $actionId $requiresStateKeys) + AddExpected 'RootLabelCount' 'ExactInt' ([int]@($actionLabels | Where-Object { -not $_.ParentKey }).Count) + AddExpected 'SubLabelCount' 'ExactInt' ([int]@($actionLabels | Where-Object ParentKey).Count) + AddExpected 'LabelSignatures' 'SetEquality' @($actionLabels | ForEach-Object { [string]$_.Key }) + } + 'purview.labels.priority' { + $adapterId = 'purview.labels.priority' + $actionLabels = @(GetStateItemsByKey $labels $stateKeys 'labels/' $actionId $requiresStateKeys) + AddExpected 'PriorityOrder' 'OrderedSequence' @( + $actionLabels | Sort-Object Priority | ForEach-Object { [string]$_.Key } + ) + } + 'purview.labels.publish' { + $policy = GetRequiredSingletonByKey @($state.LabelPolicy) $stateKeys 'label-policy/' $actionId + AddExpected 'PolicyPresent' 'ExactBoolean' $true + AddExpected 'PublishedLabelSignatures' 'SetEquality' @($policy.PublishedLabelKeys) + AddExpected 'DefaultLabelSignature' 'ExactString' ([string]$policy.DefaultLabelKey) + AddExpected 'DefaultLabelForEmailSignature' 'ExactString' ([string]$policy.EmailDefaultLabelKey) + AddExpected 'MandatoryLabelling' 'ExactBoolean' ([bool]$policy.Mandatory) + AddExpected 'DowngradeJustification' 'ExactBoolean' ([bool]$policy.RequireDowngradeJustification) + } + 'purview.labels.attachment-inheritance' { + $adapterId = 'purview.labels.attachment-inheritance' + $policy = GetRequiredSingletonByKey @($state.LabelPolicy) $stateKeys 'label-policy/' $actionId + AddExpected 'AttachmentAction' 'CaseInsensitiveString' ([string]$policy.AttachmentAction) + } + 'purview.labels.content-marking' { + $adapterId = 'purview.labels.content-marking' + $actionLabels = @(GetStateItemsByKey $labels $stateKeys 'labels/' $actionId $requiresStateKeys) + AddExpected 'ContentMarkedLabelSignatures' 'SetEquality' @( + $actionLabels | Where-Object ContentMark | ForEach-Object { [string]$_.Key } + ) + } + 'purview.labels.encryption' { + $adapterId = 'purview.labels.encryption' + $actionLabels = @(GetStateItemsByKey $labels $stateKeys 'labels/' $actionId $requiresStateKeys) + AddExpected 'EncryptedLabelSignatures' 'SetEquality' @( + $actionLabels | Where-Object Encrypt | ForEach-Object { [string]$_.Key } + ) + $encryption = GetRequiredSingletonByKey @($state.Encryption) $stateKeys 'encryption/' $actionId + AddExpected 'EncryptionOfflineAccessDays' 'ExactInt' ([int]$encryption.OfflineAccessDays) + AddExpected 'EncryptionContentExpiration' 'CaseInsensitiveString' ([string]$encryption.ContentExpiration) + } + 'purview.labels.container-scope' { + $adapterId = 'purview.labels.container-scope' + $actionLabels = @(GetStateItemsByKey $labels $stateKeys 'labels/' $actionId $requiresStateKeys) + AddExpected 'ContainerScopedLabelSignatures' 'SetEquality' @( + $actionLabels | + Where-Object { 'Site' -in @($_.ContentTypes) -or 'UnifiedGroup' -in @($_.ContentTypes) } | + ForEach-Object { [string]$_.Key } + ) + $prerequisite = [pscustomobject]@{ Kind = 'License'; Id = 'BusinessPremiumOrHigher' } + } + { $_ -in @('purview.dlp.exchange', 'purview.dlp.sharepoint-onedrive', 'purview.dlp.endpoint') } { + $adapterId = 'purview.dlp.workload' + $items = @(GetStateItemsByKey @($state.DlpPolicies) $stateKeys 'dlp/' $actionId $requiresStateKeys) + $selector = switch ($actionId) { + 'purview.dlp.exchange' { 'Exchange' } + 'purview.dlp.sharepoint-onedrive' { 'SharePointOneDrive' } + 'purview.dlp.endpoint' { 'Endpoint' } + } + $modes = @(GetStringSet ($items | ForEach-Object { $_.Mode })) + AddExpected 'PolicyPresent' 'ExactBoolean' ($items.Count -gt 0) + AddExpected 'PolicyMode' 'SetEquality' @($modes) + AddExpected 'RulePresent' 'ExactBoolean' ($items.Count -gt 0) + AddExpected 'BlockAccess' 'ExactBoolean' ($items.Count -gt 0 -and @($items | Where-Object { -not [bool]$_.BlockAccess }).Count -eq 0) + AddExpected 'LabelPathCount' 'ExactInt' ([int]@($items | ForEach-Object { @($_.LabelKeys).Count } | Measure-Object -Sum).Sum) + AddExpected 'LabelSignatures' 'SetEquality' @( + $items | ForEach-Object { @($_.LabelKeys) } | Sort-Object -Unique + ) + AddExpected 'PolicySignatures' 'SetEquality' @( + $items | + ForEach-Object { + $labelSet = ( + @($_.LabelKeys) | + ForEach-Object { ([string]$_).ToLowerInvariant() } | + Sort-Object -Unique + ) -join ',' + 'mode={0}|block={1}|labels={2}' -f ( + [string]$_.Mode + ).ToLowerInvariant(), ( + [bool]$_.BlockAccess + ).ToString().ToLowerInvariant(), $labelSet + } | + Sort-Object -Unique + ) + if ($actionId -eq 'purview.dlp.endpoint') { + $prerequisite = [pscustomobject]@{ Kind = 'License'; Id = 'E5OrPurviewSuite' } + } + } + 'purview.retention.exchange' { + $adapterId = 'purview.retention.exchange' + $retention = GetRequiredSingletonByKey @($state.Retention) $stateKeys 'retention/' $actionId + AddExpected 'PolicyPresent' 'ExactBoolean' $true + AddExpected 'RetentionDurationDays' 'ExactInt' ([int]$retention.DurationDays) + AddExpected 'RetentionAction' 'CaseInsensitiveString' ([string]$retention.Action) + AddExpected 'ExpirationDateOption' 'CaseInsensitiveString' ([string]$retention.ExpirationDateOption) + AddExpected 'Locations' 'SetEquality' @( + $retention.Locations | ForEach-Object { ([string]$_).ToLowerInvariant() } + ) + } + 'purview.ai.copilot-dlp' { + $adapterId = 'purview.ai.copilot-dlp' + $items = @(GetStateItemsByKey @($state.AIGovernance) $stateKeys 'ai/' $actionId $requiresStateKeys) + $modes = @(GetStringSet ($items | ForEach-Object { $_.Mode })) + AddExpected 'PolicyCount' 'ExactInt' ([int]$items.Count) + AddExpected 'PolicyMode' 'SetEquality' @($modes) + AddExpected 'EnforcementPlanes' 'SetEquality' @( + $items | ForEach-Object { @($_.EnforcementPlanes) } | ForEach-Object { ([string]$_).ToLowerInvariant() } | Sort-Object -Unique + ) + AddExpected 'RestrictAccessSettings' 'SetEquality' @( + $items | ForEach-Object { @($_.Restrictions) } | ForEach-Object { "$($_.Setting)=$($_.Value)".ToLowerInvariant() } | Sort-Object -Unique + ) + AddExpected 'ScopePrincipalSignatures' 'SetEquality' @(GetAiScopeSet $items) + AddExpected 'LabelPathCount' 'ExactInt' ([int]@($items | ForEach-Object { + @(GetAiExpectedLabelKeys $_ $labels).Count + } | Measure-Object -Sum).Sum) + AddExpected 'PolicySignatures' 'SetEquality' @( + $items | + ForEach-Object { + $planeSet = ( + @($_.EnforcementPlanes) | + ForEach-Object { ([string]$_).ToLowerInvariant() } | + Sort-Object -Unique + ) -join ',' + $restrictionSet = ( + @($_.Restrictions) | + ForEach-Object { "$($_.Setting)=$($_.Value)".ToLowerInvariant() } | + Sort-Object -Unique + ) -join ',' + $scopeSet = ( + GetAiScopeSet @($_) + ) -join ',' + $labelSet = ( + @(GetAiExpectedLabelKeys $_ $labels) | + ForEach-Object { ([string]$_).ToLowerInvariant() } | + Sort-Object -Unique + ) -join ',' + 'mode={0}|planes={1}|restrictions={2}|scope={3}|labels={4}' -f ( + [string]$_.Mode + ).ToLowerInvariant(), $planeSet, $restrictionSet, $scopeSet, $labelSet + } | + Sort-Object -Unique + ) + $prerequisite = [pscustomobject]@{ Kind = 'License'; Id = 'E5OrPurviewSuite' } + } + } + + $validation = [pscustomobject]@{ + AdapterId = $adapterId + Selector = $selector + Scored = ([string]$action.Intent -in @('Included', 'Conditional') -and $expected.Count -gt 0) + Prerequisite = $prerequisite + Expected = $expected.ToArray() + } + if ($action.PSObject.Properties['Validation']) { + $action.Validation = $validation + } else { + $action | Add-Member -NotePropertyName Validation -NotePropertyValue $validation + } + } +} + +function Test-PurviewValidationUniqueKey { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Items, + [Parameter(Mandatory)] [string] $Property, + [Parameter(Mandatory)] [string] $Name + ) + + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($item in @($Items)) { + $value = if ($item -and $item.PSObject.Properties[$Property]) { + [string]$item.$Property + } else { + '' + } + if ([string]::IsNullOrWhiteSpace($value)) { + throw "Deployment Plan intended state collection '$Name' contains a record without '$Property'." + } + if (-not $seen.Add($value)) { + throw "Deployment Plan intended state collection '$Name' contains duplicate key '$value'." + } + } +} + +function Test-PurviewValidationRequiredArray { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object] $InputObject, + [Parameter(Mandatory)] [string] $Property, + [Parameter(Mandatory)] [string] $Name + ) + + if (-not $InputObject.PSObject.Properties[$Property]) { + throw "Deployment Plan intended state is missing required collection '$Name'." + } + $value = $InputObject.PSObject.Properties[$Property].Value + if ($null -eq $value) { + throw "Deployment Plan intended state collection '$Name' is null." + } + if ($value -is [string] -or + $value -is [System.Collections.IDictionary] -or + $value -is [pscustomobject]) { + throw "Deployment Plan intended state collection '$Name' must be an array." + } + return @($value) +} + +function Test-PurviewValidationPlan { + <# + Validates an operator-supplied Deployment Plan before anything + authenticates. + + Schema 1.0 is a readable plan but not a valid validation baseline: it + carries intent without expected managed state, so accepting it would + mean inventing a baseline. It is rejected with an instruction to + regenerate rather than silently downgraded. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Path, + [Parameter(Mandatory)] [System.Collections.IDictionary] $Allowlist, + [Parameter()] [string] $SupportedSchemaVersion = '1.2' + ) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "Deployment Plan JSON not found: $Path" + } + + $raw = Get-Content -LiteralPath $Path -Raw + try { + $plan = $raw | ConvertFrom-Json -Depth 25 + } catch { + throw "Deployment Plan JSON could not be parsed: $($_.Exception.Message)" + } + Test-PurviewValidationJsonShape -Raw $raw + + foreach ($property in @( + 'SchemaVersion', 'ArtifactType', 'Product', 'PlanId', 'PlanReference', + 'ManagedByTag', 'PlanInputSha256', 'IntendedStateSha256', 'IntendedState', + 'Actions', 'Handoff' + )) { + if (-not $plan.PSObject.Properties[$property]) { + throw "Deployment Plan is missing required property '$property'." + } + } + + $planVersion = $null + if (-not [version]::TryParse([string]$plan.SchemaVersion, [ref]$planVersion)) { + throw "Deployment Plan schema '$($plan.SchemaVersion)' is not a version number." + } + $supported = [version]$SupportedSchemaVersion + # Same major, at least the supported minor. A newer minor is additive by + # contract, and anything it adds that this validator does not recognize is + # rejected by the adapter and comparator allowlists below. + if ($planVersion.Major -ne $supported.Major -or $planVersion.Minor -lt $supported.Minor) { + throw ( + "Deployment Plan schema '$($plan.SchemaVersion)' cannot be used as a " + + "validation baseline. Regenerate the plan with the current toolkit " + + "so it carries schema $SupportedSchemaVersion validation baselines, " + + 'then rerun validation.') + } + + if ([string]$plan.Product -ne 'Purview') { + throw "Deployment Plan product '$($plan.Product)' is not Purview." + } + if ([string]$plan.ArtifactType -ne 'PurviewDeploymentPlan') { + throw "Artifact type '$($plan.ArtifactType)' is not a Purview Deployment Plan." + } + if ([string]$plan.PlanReference -notmatch '^PUR-\d{8}-\d{6}-[0-9A-F]{8}$') { + throw 'Deployment Plan reference is malformed.' + } + + $requiredCapabilities = @( + 'IntendedState', + 'ManagedOwnership', + 'PublicationMode', + 'EncryptionSettings', + 'OpaquePrincipalDigests', + 'MultipleRecordsPerAction', + 'CanonicalPublicLocations', + 'RetentionLocationDisposition', + 'OpaqueLocationDigests' + ) + $capabilities = @( + if ($plan.Handoff.PSObject.Properties['Capabilities']) { + @($plan.Handoff.Capabilities) | ForEach-Object { [string]$_ } + } + ) + foreach ($capability in $requiredCapabilities) { + if ($capability -notin $capabilities) { + throw "Deployment Plan handoff is missing required capability '$capability'." + } + } + + # Ownership is decided by the managed tag. Without it every policy in the + # tenant would be treated as toolkit-managed, which turns a customer's own + # objects into drift or, worse, into a false match. + if (-not $plan.PSObject.Properties['ManagedByTag'] -or + [string]::IsNullOrWhiteSpace([string]$plan.ManagedByTag)) { + throw ( + 'Deployment Plan carries no managed-object tag, so toolkit-owned ' + + 'objects cannot be told apart from customer-owned ones. Regenerate ' + + 'the plan from a configuration that sets ManagedByTag.') + } + + $planGuid = [guid]::Empty + if (-not [guid]::TryParse([string]$plan.PlanId, [ref]$planGuid)) { + throw 'Deployment Plan PlanId is not a valid identifier.' + } + + if ([string]$plan.PlanInputSha256 -notmatch '^[0-9a-f]{64}$') { + throw 'Deployment Plan input fingerprint is not a SHA-256 value.' + } + if ([string]$plan.IntendedStateSha256 -notmatch '^[0-9a-f]{64}$') { + throw 'Deployment Plan intended-state fingerprint is not a SHA-256 value.' + } + $intendedState = $plan.IntendedState + $tenantState = @(Test-PurviewValidationRequiredArray $intendedState 'TenantSettings' 'TenantSettings') + $labelState = @(Test-PurviewValidationRequiredArray $intendedState 'Labels' 'Labels') + $dlpState = @(Test-PurviewValidationRequiredArray $intendedState 'DlpPolicies' 'DlpPolicies') + $aiState = @(Test-PurviewValidationRequiredArray $intendedState 'AIGovernance' 'AIGovernance') + foreach ($property in @('LabelPolicy', 'Encryption', 'Retention')) { + if (-not $intendedState.PSObject.Properties[$property] -or + $null -eq $intendedState.PSObject.Properties[$property].Value) { + throw "Deployment Plan intended state is missing required record '$property'." + } + } + Test-PurviewValidationUniqueKey -Items $tenantState -Property 'Key' -Name 'TenantSettings' + Test-PurviewValidationUniqueKey -Items $labelState -Property 'Key' -Name 'Labels' + Test-PurviewValidationUniqueKey -Items $dlpState -Property 'Key' -Name 'DlpPolicies' + Test-PurviewValidationUniqueKey -Items $aiState -Property 'Key' -Name 'AIGovernance' + foreach ($dlp in $dlpState) { + foreach ($property in @('NotifyUser', 'GenerateIncidentReport', 'LabelKeys')) { + if (-not $dlp.PSObject.Properties[$property]) { + throw "Deployment Plan DLP intended-state record '$($dlp.Key)' is missing '$property'." + } + $null = @($dlp.PSObject.Properties[$property].Value) + } + } + if ([string]::IsNullOrWhiteSpace([string]$intendedState.LabelPolicy.PublicationMode)) { + throw 'Deployment Plan label policy intended state is missing PublicationMode.' + } + foreach ($property in @('PublishedLabelKeys', 'TenantDependentParentKeys')) { + if (-not $intendedState.LabelPolicy.PSObject.Properties[$property]) { + throw "Deployment Plan label policy intended state is missing '$property'." + } + $null = @($intendedState.LabelPolicy.PSObject.Properties[$property].Value) + } + foreach ($property in @('OfflineAccessDays', 'ContentExpiration')) { + if (-not $intendedState.Encryption.PSObject.Properties[$property]) { + throw "Deployment Plan encryption intended state is missing '$property'." + } + } + foreach ($property in @('Locations', 'UnsupportedLocations')) { + if (-not $intendedState.Retention.PSObject.Properties[$property]) { + throw "Deployment Plan retention intended state is missing '$property'." + } + $value = $intendedState.Retention.PSObject.Properties[$property].Value + if ($value -is [string] -or + $value -is [System.Collections.IDictionary] -or + $value -is [pscustomobject]) { + throw "Deployment Plan retention intended-state '$property' must be an array." + } + } + if ([string]$intendedState.Retention.LocationDisposition -notin @( + 'Supported', + 'Partial', + 'UnsupportedOnly', + 'Empty' + )) { + throw "Deployment Plan retention LocationDisposition '$($intendedState.Retention.LocationDisposition)' is not supported." + } + $actualIntendedHash = Get-PurviewValidationSha256 -Text ( + $plan.IntendedState | ConvertTo-Json -Depth 20 -Compress + ) + if ($actualIntendedHash -cne [string]$plan.IntendedStateSha256) { + throw 'Deployment Plan intended-state fingerprint does not match its content.' + } + + $fingerprintInput = [ordered]@{ + Product = [string]$plan.Product + ManagedByTag = [string]$plan.ManagedByTag + EffectiveParameters = $plan.EffectiveParameters + IntendedState = $plan.IntendedState + Guide = [ordered]@{ + Id = [string]$plan.Guide.Id + Edition = [string]$plan.Guide.Edition + RevisionDate = [string]$plan.Guide.RevisionDate + SourceModifiedDate = [string]$plan.Guide.SourceModifiedDate + Publisher = [string]$plan.Guide.Publisher + SourceClassification = [string]$plan.Guide.SourceClassification + SourceFileName = [string]$plan.Guide.SourceFileName + Levels = @($plan.Guide.Levels) + } + SupportingGuides = @( + $plan.SupportingGuides | Sort-Object { $_.Guide.Id } | ForEach-Object { + [pscustomobject][ordered]@{ + Guide = [pscustomobject][ordered]@{ + Id = [string]$_.Guide.Id + Edition = [string]$_.Guide.Edition + RevisionDate = [string]$_.Guide.RevisionDate + Publisher = [string]$_.Guide.Publisher + SourceClassification = [string]$_.Guide.SourceClassification + SourceUri = [string]$_.Guide.SourceUri + SourceCommit = [string]$_.Guide.SourceCommit + } + GuideControls = @($_.GuideControls | Sort-Object ControlId) + } + } + ) + Modules = @($plan.Modules) + Actions = @($plan.Actions | Sort-Object ActionId) + GuideControls = @($plan.GuideControls | Sort-Object ControlId) + } + $actualPlanHash = Get-PurviewValidationSha256 -Text ( + $fingerprintInput | ConvertTo-Json -Depth 20 -Compress + ) + if ($actualPlanHash -cne [string]$plan.PlanInputSha256) { + throw 'Deployment Plan input fingerprint does not match its content.' + } + + $actions = @($plan.Actions) + if ($actions.Count -eq 0) { + throw 'Deployment Plan contains no actions to assess.' + } + + $supportedActionIds = @( + 'purview.tenant.audit-standard', 'purview.tenant.spo-labels', + 'purview.tenant.pdf-labels', 'purview.tenant.container-directory-setting', + 'purview.tenant.label-coauthoring', 'purview.tenant.audit-premium', + 'purview.labels.taxonomy', 'purview.labels.priority', + 'purview.labels.publish', 'purview.labels.attachment-inheritance', + 'purview.labels.content-marking', 'purview.labels.encryption', + 'purview.labels.container-scope', 'purview.dlp.exchange', + 'purview.dlp.sharepoint-onedrive', 'purview.dlp.endpoint', + 'purview.retention.exchange', 'purview.ai.copilot-dlp' + ) + + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($action in $actions) { + $actionId = [string]$action.ActionId + if ($actionId -notmatch '^purview\.[a-z0-9]+(\.[a-z0-9-]+)+$') { + throw "Deployment Plan action ID '$actionId' is malformed." + } + if (-not $seen.Add($actionId)) { + throw "Deployment Plan action ID '$actionId' is duplicated." + } + if ($actionId -notin $supportedActionIds -and + [string]$action.Intent -notin @('Excluded', 'NotConfigured')) { + throw ( + "Deployment Plan action '$actionId' is not supported by this validator. " + + "The plan uses schema $($plan.SchemaVersion); update the toolkit before validating it." + ) + } + if ([string]$action.Intent -notin @('Included', 'Excluded', 'Conditional', 'NotConfigured')) { + throw "Deployment Plan action '$actionId' has unsupported intent '$($action.Intent)'." + } + if ($action.PSObject.Properties['Validation'] -and $null -ne $action.Validation) { + throw "Deployment Plan action '$actionId' carries unsupported validation instructions. Regenerate the plan with schema 1.2 intended state only." + } + } + + Set-PurviewValidationFromIntendedState -Plan $plan + + $adapterIds = @(@($Allowlist['Adapters']) | ForEach-Object { [string]$_['Id'] }) + $comparators = @($Allowlist['Comparators']) + $prerequisiteKinds = @($Allowlist['PrerequisiteKinds']) + $licensePrerequisites = @($Allowlist['LicensePrerequisites']) + $capabilityPrerequisites = @($Allowlist['CapabilityPrerequisites']) + foreach ($action in @($plan.Actions)) { + $actionId = [string]$action.ActionId + $validation = $action.Validation + if (-not [bool]$validation.Scored -and $actionId -notin $supportedActionIds) { + continue + } + if ([string]$validation.AdapterId -notin $adapterIds) { + throw "Derived validation for action '$actionId' named unknown validation adapter '$($validation.AdapterId)'." + } + $selector = if ($validation.PSObject.Properties['Selector']) { [string]$validation.Selector } else { '' } + if ($selector -and $selector -notmatch '^[A-Za-z0-9._-]{1,64}$') { + throw "Derived validation for action '$actionId' has a malformed adapter selector." + } + + $prerequisiteKind = [string]$validation.Prerequisite.Kind + if ($prerequisiteKind -notin $prerequisiteKinds) { + throw "Derived validation for action '$actionId' names unknown prerequisite kind '$prerequisiteKind'." + } + $prerequisiteId = [string]$validation.Prerequisite.Id + if ($prerequisiteKind -eq 'License' -and $prerequisiteId -notin $licensePrerequisites) { + throw "Derived validation for action '$actionId' names unknown license prerequisite '$prerequisiteId'." + } + if ($prerequisiteKind -eq 'Capability' -and $prerequisiteId -notin $capabilityPrerequisites) { + throw "Derived validation for action '$actionId' names unknown capability prerequisite '$prerequisiteId'." + } + + $adapter = @(@($Allowlist['Adapters']) | Where-Object { [string]$_['Id'] -eq [string]$validation.AdapterId })[0] + $managedFields = @($adapter['ManagedFields']) + foreach ($field in @($validation.Expected)) { + if ([string]$field.Field -notin $managedFields) { + throw "Derived validation for action '$actionId' expects unmanaged field '$($field.Field)'." + } + $comparator = [string]$field.Comparator + if ($comparator -notin $comparators) { + throw "Derived validation for action '$actionId' names unknown comparator '$comparator'." + } + Test-PurviewValidationExpectedType -ActionId $actionId -Field ([string]$field.Field) ` + -Comparator $comparator -Value $field.Value + } + + if ([bool]$validation.Scored -and @($validation.Expected).Count -eq 0) { + throw "Derived validation for action '$actionId' is scored but declares no expected managed fields." + } + } + + return $plan +} + +function New-PurviewValidationResult { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [psobject] $Action, + [Parameter(Mandatory)] + [ValidateSet('Matched', 'Drift', 'Not evaluated', 'Informational', 'Collection failed')] + [string] $Status, + [Parameter(Mandatory)] [string] $Reason, + [Parameter()] [string] $NextStep = '', + [Parameter()] [object[]] $FieldComparisons = @(), + [Parameter()] [object[]] $UnscoredDifferences = @(), + [Parameter()] [string] $Query = '', + [Parameter()] [string] $Source = '', + [Parameter()] [string] $SourceVersion = '', + [Parameter()] [int] $Attempts = 0, + [Parameter()] [int] $ElapsedMs = 0, + [Parameter()] [string] $ErrorText = '', + [Parameter()] [string] $PrerequisiteDisposition = 'Not applicable' + ) + + $expectedText = ($FieldComparisons | ForEach-Object { '{0}={1}' -f $_.Field, $_.ExpectedText }) -join '; ' + $observedText = ($FieldComparisons | ForEach-Object { '{0}={1}' -f $_.Field, $_.ObservedText }) -join '; ' + + return [pscustomobject][ordered]@{ + ActionId = [string]$Action.ActionId + Module = [string]$Action.Module + Title = [string]$Action.Title + PlanIntent = [string]$Action.Intent + AdapterId = [string]$Action.Validation.AdapterId + Selector = if ($Action.Validation.PSObject.Properties['Selector']) { [string]$Action.Validation.Selector } else { '' } + Scored = [bool]$Action.Validation.Scored + Status = $Status + Reason = $Reason + NextStep = $NextStep + ExpectedSummary = if ($expectedText) { $expectedText } else { '(not scored)' } + ObservedSummary = if ($observedText) { $observedText } else { '(not collected)' } + Fields = @($FieldComparisons) + UnscoredDifferences = @($UnscoredDifferences) + Query = $Query + Source = $Source + SourceVersion = $SourceVersion + Attempts = $Attempts + ElapsedMs = $ElapsedMs + Error = ConvertTo-PurviewValidationRedactedText -Text $ErrorText + PrerequisiteDisposition = $PrerequisiteDisposition + } +} + +function New-PurviewGuideAssessmentPlan { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [System.Collections.IDictionary] $GuideMapping + ) + + $definitions = [ordered]@{ + 'purview.tenant.audit-standard' = [pscustomobject]@{ Adapter = 'purview.tenant.audit-standard'; Selector = ''; Fields = @( + [pscustomobject]@{ Field = 'UnifiedAuditLogIngestionEnabled'; Comparator = 'Presence'; Value = $true }) } + 'purview.tenant.spo-labels' = [pscustomobject]@{ Adapter = 'purview.tenant.spo-labels'; Selector = ''; Fields = @( + [pscustomobject]@{ Field = 'EnableAIPIntegration'; Comparator = 'Presence'; Value = $true }) } + 'purview.tenant.container-directory-setting' = [pscustomobject]@{ Adapter = 'purview.tenant.container-directory-setting'; Selector = ''; Fields = @( + [pscustomobject]@{ Field = 'EnableMIPLabels'; Comparator = 'Presence'; Value = $true }) } + 'purview.labels.taxonomy' = [pscustomobject]@{ Adapter = 'purview.labels.taxonomy'; Selector = ''; Fields = @( + [pscustomobject]@{ Field = 'RootLabelCount'; Comparator = 'Presence'; Value = $true } + [pscustomobject]@{ Field = 'SubLabelCount'; Comparator = 'Presence'; Value = $true }) } + 'purview.labels.priority' = [pscustomobject]@{ Adapter = 'purview.labels.priority'; Selector = ''; Fields = @( + [pscustomobject]@{ Field = 'PriorityOrder'; Comparator = 'Presence'; Value = $true }) } + 'purview.labels.publish' = [pscustomobject]@{ Adapter = 'purview.labels.publish'; Selector = ''; Fields = @( + [pscustomobject]@{ Field = 'PolicyPresent'; Comparator = 'ExactBoolean'; Value = $true }) } + 'purview.labels.encryption' = [pscustomobject]@{ Adapter = 'purview.labels.encryption'; Selector = ''; Fields = @( + [pscustomobject]@{ Field = 'EncryptedLabelSignatures'; Comparator = 'Presence'; Value = $true }) } + 'purview.labels.content-marking' = [pscustomobject]@{ Adapter = 'purview.labels.content-marking'; Selector = ''; Fields = @( + [pscustomobject]@{ Field = 'ContentMarkedLabelSignatures'; Comparator = 'Presence'; Value = $true }) } + 'purview.labels.attachment-inheritance' = [pscustomobject]@{ Adapter = 'purview.labels.attachment-inheritance'; Selector = ''; Fields = @( + [pscustomobject]@{ Field = 'AttachmentAction'; Comparator = 'Presence'; Value = $true }) } + 'purview.dlp.exchange' = [pscustomobject]@{ Adapter = 'purview.dlp.workload'; Selector = 'Exchange'; Fields = @( + [pscustomobject]@{ Field = 'PolicyPresent'; Comparator = 'ExactBoolean'; Value = $true } + [pscustomobject]@{ Field = 'RulePresent'; Comparator = 'ExactBoolean'; Value = $true }) } + 'purview.dlp.sharepoint-onedrive' = [pscustomobject]@{ Adapter = 'purview.dlp.workload'; Selector = 'SharePointOneDrive'; Fields = @( + [pscustomobject]@{ Field = 'PolicyPresent'; Comparator = 'ExactBoolean'; Value = $true } + [pscustomobject]@{ Field = 'RulePresent'; Comparator = 'ExactBoolean'; Value = $true }) } + 'purview.dlp.endpoint' = [pscustomobject]@{ Adapter = 'purview.dlp.workload'; Selector = 'Endpoint'; Fields = @( + [pscustomobject]@{ Field = 'PolicyPresent'; Comparator = 'ExactBoolean'; Value = $true } + [pscustomobject]@{ Field = 'RulePresent'; Comparator = 'ExactBoolean'; Value = $true }) } + } + $controlByAction = @{} + foreach ($control in @($GuideMapping.Controls)) { + foreach ($actionId in @($control.ActionIds)) { + if (-not $controlByAction.ContainsKey([string]$actionId)) { + $controlByAction[[string]$actionId] = [Collections.Generic.List[object]]::new() + } + $controlByAction[[string]$actionId].Add($control) + } + } + + $actions = foreach ($actionId in $controlByAction.Keys | Sort-Object) { + if (-not $definitions.Contains($actionId)) { continue } + $definition = $definitions[$actionId] + $fields = @($definition.Fields) + $prerequisite = if ($actionId -eq 'purview.dlp.endpoint') { + [pscustomobject]@{ Kind = 'License'; Id = 'E5OrPurviewSuite' } + } else { + [pscustomobject]@{ Kind = 'None'; Id = '' } + } + [pscustomobject]@{ + ActionId = $actionId + Module = [string](@($GuideMapping.Controls | Where-Object { $actionId -in @($_.ActionIds) })[0].Section) + Title = [string](@($GuideMapping.Controls | Where-Object { $actionId -in @($_.ActionIds) })[0].Summary) + Intent = 'GuideOnly' + PrimaryGuideLevel = [string](@($controlByAction[$actionId])[0].Level) + IntendedStateKeys = @() + Validation = [pscustomobject]@{ + AdapterId = $definition.Adapter + Selector = $definition.Selector + Scored = $true + Prerequisite = $prerequisite + Expected = @($fields) + } + } + } + + $guideControls = foreach ($control in @($GuideMapping.Controls)) { + [pscustomobject]@{ + ControlId = [string]$control.Id + Level = [string]$control.Level + Summary = [string]$control.Summary + ComparisonStatus = [string]$control.Status + ActionIds = @($control.ActionIds) + Rationale = [string]$control.Rationale + } + } + return [pscustomobject]@{ + IsGuideOnly = $true + SchemaVersion = '' + PlanId = '' + PlanReference = '' + PlanInputSha256 = '' + IntendedStateSha256 = '' + ManagedByTag = '[Managed by SMBTool Purview Toolkit]' + Guide = [pscustomobject]$GuideMapping.Guide + GuideControls = @($guideControls) + Actions = @($actions) + } +} + +function New-PurviewValidationModel { + <# + Assembles the one canonical model that both the HTML report and the + JSON sidecar render. Neither renderer may compute a fact of its own. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [psobject] $Plan, + [Parameter(Mandatory)] [object[]] $Results, + [Parameter()] [string] $ToolkitVersion = 'unknown', + [Parameter()] [guid] $ValidationRunId = [guid]::NewGuid(), + [Parameter()] [datetime] $ObservedAt = [datetime]::UtcNow, + [Parameter()] [int] $DurationSeconds = 0, + [Parameter()] [string] $TenantDisplayName = '', + [Parameter()] [string] $TenantIdentityCheck = 'Not verified', + [Parameter()] [psobject] $ServiceStatus, + [Parameter()] [object[]] $Diagnostics = @() + ) + + $isGuideOnly = $Plan.PSObject.Properties['IsGuideOnly'] -and [bool]$Plan.IsGuideOnly + foreach ($result in $Results) { + $observationState = switch ([string]$result.Status) { + 'Collection failed' { 'Unreadable' } + 'Not evaluated' { + if ($result.PrerequisiteDisposition -eq 'Unsatisfied') { 'Unsupported' } else { 'Unreadable' } + } + 'Informational' { 'Unsupported' } + default { + $presenceFields = @($result.Fields | Where-Object { [string]$_.Field -match 'Present$' }) + if ($presenceFields.Count -gt 0) { + $present = @($presenceFields | Where-Object { + $_.ObservedText -notin @('(not set)', 'false', '0', '(empty)') + }).Count -gt 0 + if ($present) { 'Present' } else { 'Absent' } + } else { + 'Present' + } + } + } + $intendedState = if ($isGuideOnly) { + 'NotEvaluated' + } else { + switch ([string]$result.Status) { + 'Matched' { 'Match' } + 'Drift' { 'Mismatch' } + 'Informational' { 'NotInPlan' } + default { 'NotEvaluated' } + } + } + $baselineState = switch ([string]$result.Status) { + 'Matched' { 'Meets' } + 'Drift' { 'DoesNotMeet' } + 'Informational' { 'NotApplicable' } + default { 'Indeterminate' } + } + $result | Add-Member -NotePropertyName Observation -NotePropertyValue $observationState -Force + $result | Add-Member -NotePropertyName Intended -NotePropertyValue $intendedState -Force + $result | Add-Member -NotePropertyName Baseline -NotePropertyValue $baselineState -Force + } + + $counts = [ordered]@{} + foreach ($status in $script:PurviewValidationStatuses) { + $counts[$status] = @($Results | Where-Object Status -eq $status).Count + } + + $moduleOrder = @($Results | ForEach-Object { $_.Module } | Select-Object -Unique) + $modules = foreach ($module in $moduleOrder) { + $moduleResults = @($Results | Where-Object Module -eq $module) + [pscustomobject][ordered]@{ + Module = $module + Total = $moduleResults.Count + Matched = @($moduleResults | Where-Object Status -eq 'Matched').Count + Drift = @($moduleResults | Where-Object Status -eq 'Drift').Count + NotEvaluated = @($moduleResults | Where-Object Status -eq 'Not evaluated').Count + Informational = @($moduleResults | Where-Object Status -eq 'Informational').Count + CollectionFailed = @($moduleResults | Where-Object Status -eq 'Collection failed').Count + } + } + + $controlResults = foreach ($control in @($Plan.GuideControls)) { + $mapped = @($Results | Where-Object { $_.ActionId -in @($control.ActionIds) }) + $baseline = if ([string]$control.ComparisonStatus -eq 'NotApplicable') { + 'NotApplicable' + } elseif ($isGuideOnly -and + [string]$control.Level -ne 'Good' -and + [string]$control.ComparisonStatus -ne 'Aligned') { + 'Indeterminate' + } elseif ([string]$control.ComparisonStatus -eq 'NotImplemented') { + 'Indeterminate' + } elseif ($mapped.Count -eq 0) { + 'Indeterminate' + } elseif (@($mapped | Where-Object Baseline -eq 'DoesNotMeet').Count -gt 0) { + 'DoesNotMeet' + } elseif (@($mapped | Where-Object Baseline -eq 'Indeterminate').Count -gt 0) { + 'Indeterminate' + } elseif (@($mapped | Where-Object Baseline -eq 'Meets').Count -eq $mapped.Count) { + 'Meets' + } else { + 'Indeterminate' + } + [pscustomobject]@{ + ControlId = [string]$control.ControlId + Level = [string]$control.Level + Summary = [string]$control.Summary + Baseline = $baseline + ActionIds = @($control.ActionIds) + ManualCheck = ($mapped.Count -eq 0) + Rationale = [string]$control.Rationale + } + } + $levelOrder = @('Good', 'Better', 'Best') + $proven = 'None' + $provisional = 'None' + $blockers = [Collections.Generic.List[object]]::new() + foreach ($level in $levelOrder) { + $eligibleLevels = $levelOrder[0..[array]::IndexOf($levelOrder, $level)] + $controls = @($controlResults | Where-Object { $_.Level -in $eligibleLevels }) + if (@($controls | Where-Object Baseline -eq 'DoesNotMeet').Count -eq 0) { + $provisional = $level + } + if (@($controls | Where-Object { $_.Baseline -notin @('Meets', 'NotApplicable') }).Count -eq 0) { + $proven = $level + } + } + foreach ($control in @($controlResults | Where-Object { $_.Baseline -notin @('Meets', 'NotApplicable') })) { + $blockers.Add([pscustomobject]@{ + ControlId = $control.ControlId + Level = $control.Level + Baseline = $control.Baseline + Reason = if ($control.ManualCheck) { 'Manual verification required.' } else { 'Observed state did not prove this control.' } + }) + } + + $recommendations = @( + foreach ($result in @($Results | Where-Object Status -eq 'Drift')) { + [pscustomobject][ordered]@{ + Priority = 'Review now' + Title = $result.Title + Detail = $result.NextStep + ActionId = $result.ActionId + } + } + foreach ($result in @($Results | Where-Object Status -eq 'Collection failed')) { + [pscustomobject][ordered]@{ + Priority = 'Investigate read failure' + Title = $result.Title + Detail = $result.NextStep + ActionId = $result.ActionId + } + } + foreach ($result in @($Results | Where-Object Status -eq 'Not evaluated')) { + [pscustomobject][ordered]@{ + Priority = 'Operator decision' + Title = $result.Title + Detail = $result.NextStep + ActionId = $result.ActionId + } + } + foreach ($blocker in @($blockers)) { + [pscustomobject][ordered]@{ + Priority = 'Operator decision' + Title = "Guide control $($blocker.ControlId)" + Detail = $blocker.Reason + ActionId = $blocker.ControlId + } + } + ) + + $safeServiceStatus = if ($ServiceStatus) { + [pscustomobject][ordered]@{ + GraphConnected = if ($ServiceStatus.PSObject.Properties['GraphConnected']) { + [bool]$ServiceStatus.GraphConnected + } else { $false } + ExchangeConnected = if ($ServiceStatus.PSObject.Properties['ExchangeOnlineConnected']) { + [bool]$ServiceStatus.ExchangeOnlineConnected + } elseif ($ServiceStatus.PSObject.Properties['ExchangeConnected']) { + [bool]$ServiceStatus.ExchangeConnected + } else { $false } + SecurityComplianceConnected = if ($ServiceStatus.PSObject.Properties['SecurityComplianceConnected']) { + [bool]$ServiceStatus.SecurityComplianceConnected + } else { $false } + SharePointConnected = if ($ServiceStatus.PSObject.Properties['SharePointConnected']) { + [bool]$ServiceStatus.SharePointConnected + } else { $false } + SharePointUnavailable = if ($ServiceStatus.PSObject.Properties['SharePointUnavailable']) { + [bool]$ServiceStatus.SharePointUnavailable + } else { $false } + } + } else { $null } + + $controlActionIds = @($controlResults | ForEach-Object { @($_.ActionIds) }) + $reference = 'PUR-VAL-' + $ValidationRunId.ToString().Substring(0, 8).ToUpperInvariant() + return [pscustomobject][ordered]@{ + SchemaVersion = '1.0' + ArtifactType = 'PurviewTenantValidation' + ArtifactReference = $reference + Product = 'Purview' + AssessmentMode = $(if ($isGuideOnly) { 'GuideOnly' } else { 'PlanComparison' }) + ToolkitVersion = $ToolkitVersion + ValidationRunId = $ValidationRunId.ToString() + ObservedAtUtc = $ObservedAt.ToUniversalTime().ToString('o') + DurationSeconds = $DurationSeconds + Disclaimer = 'This standalone report compares observable tenant settings with one Deployment Plan. It is separate from the deployment run report and does not assess regulatory compliance, data-protection effectiveness, or user adoption.' + Plan = [pscustomobject][ordered]@{ + PlanId = [string]$Plan.PlanId + PlanReference = if ($Plan.PSObject.Properties['PlanReference']) { [string]$Plan.PlanReference } else { '' } + SchemaVersion = [string]$Plan.SchemaVersion + PlanInputSha256 = [string]$Plan.PlanInputSha256 + IntendedStateSha256 = if ($Plan.PSObject.Properties['IntendedStateSha256']) { [string]$Plan.IntendedStateSha256 } else { '' } + GeneratedAtUtc = if ($Plan.PSObject.Properties['GeneratedAtUtc']) { [string]$Plan.GeneratedAtUtc } else { '' } + ConfigurationSha256 = if ($Plan.PSObject.Properties['Configuration']) { [string]$Plan.Configuration.Sha256 } else { '' } + } + Tenant = [pscustomobject][ordered]@{ + DisplayName = '' + IdentityCheck = $TenantIdentityCheck + } + ServiceStatus = $safeServiceStatus + Summary = [pscustomobject][ordered]@{ + TotalActions = @($Results).Count + Matched = $counts['Matched'] + Drift = $counts['Drift'] + NotEvaluated = $counts['Not evaluated'] + Informational = $counts['Informational'] + CollectionFailed = $counts['Collection failed'] + # 'Not evaluated' covers two situations an operator answers + # differently: a prerequisite the tenant genuinely does not meet + # (nothing to do) and one that could not be read (investigate + # connectivity, consent, or roles). The status vocabulary is fixed + # at five values, so the distinction is surfaced as a count. + PrerequisiteUnmet = @($Results | Where-Object { + $_.Status -eq 'Not evaluated' -and $_.PrerequisiteDisposition -eq 'Unsatisfied' + }).Count + PrerequisiteUnknown = @($Results | Where-Object { + $_.Status -eq 'Not evaluated' -and $_.PrerequisiteDisposition -eq 'Unknown' + }).Count + ScoredActions = @($Results | Where-Object Scored).Count + ProvenLevel = $proven + ProvisionalLevel = $provisional + } + Modules = @($modules) + Results = @($Results) + Guide = [pscustomobject]@{ + Id = [string]$Plan.Guide.Id + Title = [string]$Plan.Guide.Title + Levels = @($Plan.Guide.Levels) + Controls = @($controlResults) + Blockers = $blockers.ToArray() + } + Extensions = @($Results | Where-Object { $_.ActionId -notin $controlActionIds }) + ManualChecks = @($controlResults | Where-Object ManualCheck) + Recommendations = @($recommendations) + Diagnostics = @($Diagnostics) + } +} + +function Get-PurviewValidationExitCode { + <# + Maps a canonical model onto the documented exit codes: + + 0 no drift and no collection failures + 2 drift present + 4 collection failure present + 6 both + 1 fatal input, schema, identity, or connection failure + + Exit code 1 is produced by the entry point before a model exists, so it + is never returned from here. + #> + [CmdletBinding()] + [OutputType([int])] + param( + [Parameter(Mandatory)] [psobject] $Model + ) + + $code = $script:PurviewValidationExitClean + if ([int]$Model.Summary.Drift -gt 0) { $code = $code -bor $script:PurviewValidationExitDrift } + if ([int]$Model.Summary.CollectionFailed -gt 0) { + $code = $code -bor $script:PurviewValidationExitCollectionFailure + } + return $code +} diff --git a/Products/Purview/Modules/Setup-AIGovernance.ps1 b/Products/Purview/Modules/Setup-AIGovernance.ps1 index 301f402..e374a44 100644 --- a/Products/Purview/Modules/Setup-AIGovernance.ps1 +++ b/Products/Purview/Modules/Setup-AIGovernance.ps1 @@ -1,886 +1,889 @@ -#requires -Version 7.0 -<# -.SYNOPSIS - Creates Microsoft Purview DLP policies that govern AI interactions - (Microsoft 365 Copilot and Agent 365). - -.DESCRIPTION - AI governance policies live in their own toolkit category because: - - * They use a different cmdlet shape than channel-based DLP. Per the - official documentation for New-DlpCompliancePolicy / New-DlpComplianceRule, - Copilot DLP requires the new -Locations JSON parameter, the - -EnforcementPlanes parameter, an -AdvancedRule body, and - -RestrictAccess action — none of which apply to legacy - Exchange / SPO / OneDrive DLP. - * They are license-tier gated. The policy plane is part of E5 / - Purview Suite, so Setup-AIGovernance auto-skips when called with - -BPOnly. Note: the policy enforces against both paid Microsoft - 365 Copilot AND the free Microsoft 365 Copilot Chat experience - (see https://learn.microsoft.com/purview/dlp-microsoft365-copilot-location-learn-about), - so creation succeeds on E5 / Purview Suite tenants regardless - of whether paid Copilot per-user SKUs are present. - * They are default-on. The deploy script provisions them by default; - pass -SkipAIControls to opt out. - * Future "AI" controls (DSPM-for-AI assessments, agent governance, - prompt policies) plug into the same AIGovernance.DlpPolicies array. - - Currently implemented controls: - - AI_054 Block Microsoft 365 Copilot from processing content bearing - specific sensitivity labels (default: 'Highly Confidential'). - https://microsoft.github.io/zerotrustassessment/docs/workshop-guidance/AI/AI_054 - - Idempotent: existing toolkit-managed policies and rules are updated in - place; foreign objects abort the run unless -AdoptExisting is supplied. - -.PARAMETER Config - Hashtable from PurviewConfig.psd1. - -.PARAMETER AdoptExisting - Update AI DLP policies / rules that already exist but are not managed - by this toolkit. - -.PARAMETER BPOnly - Skip immediately with an "E5 / Purview Suite required" summary entry. - The orchestrator already gates the AI step on -BPOnly, but the module - honours the switch independently so direct callers (test harnesses, - ad-hoc reruns, downstream automation) get the same protection without - needing to know the license rules. - -.NOTES - Run AFTER Setup-SensitivityLabels.ps1 in the same deploy invocation so - that label name resolution (and the soft-delete tombstone remap) is - consistent. Ordering is enforced by Deploy-PurviewBestPractice.ps1. -#> -[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] -param( - [Parameter(Mandatory)] - [hashtable] $Config, - - [Parameter()] - [switch] $AdoptExisting, - - [Parameter()] - [switch] $BPOnly -) - -$ErrorActionPreference = 'Stop' -# Auto-confirm: this toolkit is designed for unattended/scripted runs. Use -WhatIf for dry-run. -$ConfirmPreference = 'None' - -# Shared retry helper for transient IPPS errors (502, 503, 504, 429, timeouts). -. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') - -if ($BPOnly) { - # Module-side defense-in-depth: the orchestrator already gates this step - # on -BPOnly, but direct callers (ad-hoc reruns, test harnesses) get - # the same guarantee here without needing to know the license rules. - Write-Host "AI governance skipped: -BPOnly was set. Microsoft 365 Copilot DLP policies require Microsoft 365 E5 / Purview Suite (the policy plane is not available on Business Premium)." -ForegroundColor Yellow - return -} - -if (-not $Config.AIGovernance) { - Write-Host "AIGovernance section not present in PurviewConfig.psd1; nothing to do." -ForegroundColor DarkGray - return -} -if (-not $Config.AIGovernance.DlpPolicies -or $Config.AIGovernance.DlpPolicies.Count -eq 0) { - Write-Host "AIGovernance.DlpPolicies is empty; nothing to do." -ForegroundColor DarkGray - return -} - -$tag = $Config.ManagedByTag - -# Extract a meaningful error message from an IPPS ErrorRecord. IPPS REST cmdlets -# sometimes leave Exception.Message empty and only populate ErrorDetails or -# FullyQualifiedErrorId, so we walk through several properties in priority order. -function Format-IPPSError { - param([Parameter(Mandatory)] $ErrorRecord) - if (-not $ErrorRecord) { return '(no error record)' } - $candidates = @( - $ErrorRecord.Exception.Message, - $ErrorRecord.ErrorDetails.Message, - $ErrorRecord.CategoryInfo.Reason, - $ErrorRecord.FullyQualifiedErrorId, - $ErrorRecord.ToString() - ) - foreach ($c in $candidates) { - if ($c -and $c.ToString().Trim().Length -gt 0) { return $c.ToString().Trim() } - } - return '(IPPS returned an empty error - check Audit logs in Purview portal)' -} - -function Test-Owned { - param($Object, [string] $Tag) - if (-not $Object) { return $false } - $desc = if ($Object.PSObject.Properties.Name -contains 'Comment') { $Object.Comment } - elseif ($Object.PSObject.Properties.Name -contains 'Description') { $Object.Description } - else { '' } - return ($desc -and $desc -like "*$Tag*") -} - -function Resolve-LabelByPath { - <# - Same logic as Setup-DLP.ps1 Resolve-LabelByPath (kept inline here so - the AI module can run standalone). Honours $Config.LabelNameRemap - published by Setup-SensitivityLabels.ps1 when soft-deleted labels - were auto-renamed (e.g. 'HighlyConfidential' -> 'HighlyConfidential-v2'). - #> - param([string] $Path) - - $remap = $null - if ($Config -is [hashtable] -and $Config.ContainsKey('LabelNameRemap')) { - $remap = $Config['LabelNameRemap'] - } - - $parts = $Path -split '/' - if ($remap -and $remap.Count -gt 0) { - $remappedParts = @() - $changed = $false - foreach ($seg in $parts) { - if ($remap.ContainsKey($seg)) { - $remappedParts += $remap[$seg] - $changed = $true - } else { - $remappedParts += $seg - } - } - if ($changed) { - $newPath = $remappedParts -join '/' - Write-Host " Label path '$Path' remapped to '$newPath' (soft-deleted tombstone bypass)." -ForegroundColor DarkYellow - $parts = $remappedParts - $Path = $newPath - } - } - - $childName = $parts[-1] - - # Resolve WITHOUT sleeping first (see Setup-DLP.ps1 for rationale): adopted - # Microsoft-default / modern-scheme labels have a 'defa4170-...' GUID Name - # that Get-Label -Identity misses, so the DisplayName fallback below catches - # them on the first pass instead of after a 45s false 'IPPS propagation' loop. - $tryResolve = { - $hit = Get-Label -Identity $childName -ErrorAction SilentlyContinue - if ($hit) { return $hit } - $cfgEntry = $null - if ($Config -and $Config.Labels) { - foreach ($_l in $Config.Labels) { - if ($_l.Name -eq $childName) { - $cfgEntry = @{ DisplayName = $_l.DisplayName; IsSub = $false; ParentName = $null; ParentDisplayName = $null; BuiltInName = $_l.BuiltInName } - break - } - if ($_l.SubLabels) { - foreach ($_s in $_l.SubLabels) { - if ($_s.Name -eq $childName) { - $cfgEntry = @{ DisplayName = $_s.DisplayName; IsSub = $true; ParentName = $_l.Name; ParentDisplayName = $_l.DisplayName; BuiltInName = $_s.BuiltInName } - break - } - } - if ($cfgEntry) { break } - } - } - } - if (-not $cfgEntry) { return $null } - - # Signature match (region/scheme-proof): adopt the Microsoft built-in label - # by its 'defa4170-...' Name regardless of DisplayName localization. Try the - # modern-scheme group form ('Group') first so a parent path resolves the - # label GROUP; fall back to the base id (leaves + classic parents). - if ($cfgEntry.BuiltInName) { - $sig = Get-Label -Identity ([string]$cfgEntry.BuiltInName + 'Group') -ErrorAction SilentlyContinue - if (-not $sig) { $sig = Get-Label -Identity ([string]$cfgEntry.BuiltInName) -ErrorAction SilentlyContinue } - if ($sig -and -not (($sig.PSObject.Properties.Name -contains 'Mode' -and $sig.Mode -eq 'PendingDeletion') -or - ($sig.PSObject.Properties.Name -contains 'Disabled' -and $sig.Disabled -eq $true))) { - Write-Host " Label '$Path' resolved by Name signature (defa4170) (Id: $($sig.Guid), Name: $($sig.Name)) — adopted built-in label." -ForegroundColor DarkYellow - Add-RunLogEntry -Module 'Setup-AIGovernance' -Action 'Resolve label path' -Target $Path -Status 'Adopted' -Detail "Matched by Name signature (defa4170) -> existing GUID $($sig.Guid)." - return $sig - } - } - $tenantLabels = @(Get-Label -ErrorAction SilentlyContinue) | Where-Object { - -not (($_.PSObject.Properties.Name -contains 'Mode' -and $_.Mode -eq 'PendingDeletion') -or - ($_.PSObject.Properties.Name -contains 'Disabled' -and $_.Disabled -eq $true)) - } - $byDisplay = $null - if ($cfgEntry.IsSub) { - $parentObj = $tenantLabels | Where-Object { $_.Name -eq $cfgEntry.ParentName -and -not $_.ParentId } | Select-Object -First 1 - if (-not $parentObj) { - $parentObj = $tenantLabels | Where-Object { $_.DisplayName -eq $cfgEntry.ParentDisplayName -and -not $_.ParentId } | Select-Object -First 1 - } - if ($parentObj) { - $byDisplay = $tenantLabels | Where-Object { $_.DisplayName -eq $cfgEntry.DisplayName -and $_.ParentId -eq $parentObj.Guid } | Select-Object -First 1 - } - } else { - $byDisplay = $tenantLabels | Where-Object { $_.DisplayName -eq $cfgEntry.DisplayName -and -not $_.ParentId } | Select-Object -First 1 - } - if ($byDisplay) { - Write-Host " Label '$Path' resolved by DisplayName '$($cfgEntry.DisplayName)' (Id: $($byDisplay.Guid), Name: $($byDisplay.Name)) — adopted-existing label." -ForegroundColor DarkYellow - Add-RunLogEntry -Module 'Setup-AIGovernance' -Action 'Resolve label path' -Target $Path -Status 'Adopted' -Detail "Matched by DisplayName '$($cfgEntry.DisplayName)' -> existing GUID $($byDisplay.Guid)." - } - return $byDisplay - } - - $child = & $tryResolve - - # Only WAIT for IPPS propagation when the label genuinely isn't visible yet - # (a label this same apply-mode run just created). Never wait under -WhatIf. - if (-not $child -and -not $WhatIfPreference) { - $maxLookupAttempts = 3 - for ($la = 1; $la -le $maxLookupAttempts; $la++) { - Write-Host (" Label '$childName' not visible yet (IPPS propagation). Waiting 15s (attempt $la/$maxLookupAttempts)...") -ForegroundColor DarkYellow - Start-Sleep -Seconds 15 - $child = & $tryResolve - if ($child) { break } - } - } - - if ($child) { - $isSoftDeleted = $false - if ($child.PSObject.Properties.Name -contains 'Mode' -and $child.Mode -eq 'PendingDeletion') { $isSoftDeleted = $true } - if ($child.PSObject.Properties.Name -contains 'Disabled' -and $child.Disabled -eq $true) { $isSoftDeleted = $true } - if ($isSoftDeleted) { - throw "Sensitivity label '$Path' is soft-deleted (Mode='$($child.Mode)') and cannot be referenced by an AI DLP rule. Run Setup-SensitivityLabels.ps1 first (it auto-renames tombstoned labels) or wait for the ~30-day purge." - } - return $child - } - - if ($WhatIfPreference) { - Write-Host " What if: label '$Path' does not yet exist; using a placeholder GUID for the preview." -ForegroundColor DarkYellow - return [pscustomobject]@{ - Name = $childName - DisplayName = $childName - Guid = [guid]::Empty - IsPreview = $true - } - } - - throw "Sensitivity label '$Path' not found. Run Setup-SensitivityLabels.ps1 first." -} - -function ConvertTo-LocationsJson { - <# - Convert the Locations hashtable array from PurviewConfig.psd1 into - the JSON string shape that New-DlpCompliancePolicy -Locations expects. - Per Microsoft Learn the value is a JSON array of: - { Workload, Location, Inclusions=[{Type,Identity}], Exclusions=[...] } - - Note: PowerShell's ConvertTo-Json collapses a single-element array to - a JSON object (not [{...}] but {...}). The IPPS deserialiser then - rejects it with "type requires a JSON array". We force-wrap when the - input has exactly one element. - #> - param([Parameter(Mandatory)] [array] $Locations) - - $list = @() - foreach ($loc in $Locations) { - $entry = [ordered]@{ - Workload = $loc.Workload - Location = $loc.Location - Inclusions = @() - } - foreach ($inc in @($loc.Inclusions)) { - if ($inc) { $entry.Inclusions += [ordered]@{ Type = $inc.Type; Identity = $inc.Identity } } - } - if ($loc.Exclusions) { - $entry['Exclusions'] = @() - foreach ($exc in @($loc.Exclusions)) { - if ($exc) { $entry.Exclusions += [ordered]@{ Type = $exc.Type; Identity = $exc.Identity } } - } - } - $list += $entry - } - $json = $list | ConvertTo-Json -Depth 10 -Compress - if ($list.Count -le 1 -and -not $json.StartsWith('[')) { - $json = "[$json]" - } - return $json -} - -function ConvertTo-PurviewJsonString { - <# - Escapes a string so it is safe to drop INSIDE a JSON string literal - (i.e. between the surrounding double-quotes) per RFC 8259 section 7. - - We assemble the AdvancedRule JSON by hand (see Build-CopilotAdvancedRuleJson - for the long explanation of why) rather than via ConvertTo-Json. Hand - assembly means we are responsible for escaping any character that would - otherwise break the JSON parse — a sensitivity label called - Acme "Confidential" Tier - or a tenant that uses a backslash in a custom display name would - produce malformed JSON without this helper, causing - New-DlpComplianceRule to fail with a cryptic IPPS deserialiser error. - - Returns the inner content only (no surrounding double-quotes). - #> - [CmdletBinding()] - [OutputType([string])] - param( - [Parameter(Mandatory)] - [AllowEmptyString()] - [string] $Value - ) - - $sb = [System.Text.StringBuilder]::new($Value.Length + 8) - foreach ($ch in $Value.ToCharArray()) { - $code = [int]$ch - # Compare by Unicode code-point so PowerShell escape-sequence ambiguity - # (`b is backspace, `a is BEL, etc.) cannot accidentally collapse two - # control chars onto the same switch case. - switch ($code) { - 8 { [void]$sb.Append('\b'); break } # backspace - 9 { [void]$sb.Append('\t'); break } # tab - 10 { [void]$sb.Append('\n'); break } # line feed - 12 { [void]$sb.Append('\f'); break } # form feed - 13 { [void]$sb.Append('\r'); break } # carriage return - 34 { [void]$sb.Append('\"'); break } # double-quote - 92 { [void]$sb.Append('\\'); break } # backslash - default { - if ($code -lt 0x20) { - # Any other C0 control char — escape as \u00XX. - [void]$sb.AppendFormat('\u{0:x4}', $code) - } else { - [void]$sb.Append($ch) - } - } - } - } - return $sb.ToString() -} - -function Test-LabelIsApplicable { - <# - Detects whether a sensitivity label can be applied to content. - - In the modern label scheme (https://learn.microsoft.com/en-us/purview/migrate-sensitivity-label-scheme) - parent labels are demoted to "label groups": they organise their - sub-labels in the picker but cannot themselves be applied to - documents or messages. The IPPS engine reports their ContentType - as 'None' (or empty), and Copilot DLP rejects them with - " is an unsupported content type (None). M365Copilot only - supports files and Email." - - Legacy parents and all sub-labels carry ContentType bits such as - 'File, Email' and ARE applicable. - - Returns $true when the label is applicable to content; - $false when it is a label group / non-applicable container. - #> - param($Label) - - if (-not $Label) { return $false } - if (-not ($Label.PSObject.Properties.Name -contains 'ContentType')) { - # Older module shape -- assume applicable to preserve legacy behaviour. - return $true - } - $ct = $Label.ContentType - if ($null -eq $ct) { return $false } - $ctStr = ($ct.ToString()).Trim() - if ($ctStr -eq '' -or $ctStr -ieq 'None') { return $false } - return $true -} - -function Get-LiveSubLabelsForParent { - <# - Return all live (not soft-deleted, not disabled) sub-labels for a - given parent label. Used to expand label groups in the modern - scheme so the Copilot DLP rule references applicable sub-labels - instead of an unsupported group container. - #> - param($Parent) - - if (-not $Parent -or -not $Parent.Guid) { return @() } - return @(Get-Label -ErrorAction SilentlyContinue) | Where-Object { - $_.ParentId -eq $Parent.Guid -and - -not (($_.PSObject.Properties.Name -contains 'Mode' -and $_.Mode -eq 'PendingDeletion') -or - ($_.PSObject.Properties.Name -contains 'Disabled' -and $_.Disabled -eq $true)) - } -} - -function Expand-CopilotLabelEntry { - <# - Resolve a LabelPath entry into the list of labels the Copilot DLP - rule should reference. The DLP engine validates each label's - ContentType server-side, so referencing a modern-scheme label - group (ContentType=None) causes the entire rule update to fail - with "unsupported content type (None)". - - Behaviour: - - Sub-label (has ParentId): use as-is. - - Parent that IS applicable (legacy scheme): use as-is. This - preserves prior behaviour for tenants still on the legacy - parent-label scheme where the parent itself can carry a - classification. - - Parent that is NOT applicable (modern-scheme label group): - expand to all live sub-labels and emit an info message so the - partner understands what happened. - - WhatIf preview placeholder: pass through unchanged so - -WhatIf reporting still works against a fresh tenant. - #> - param( - [Parameter(Mandatory)] $Label, - [Parameter(Mandatory)] [string] $Path - ) - - if ($Label.PSObject.Properties.Name -contains 'IsPreview' -and $Label.IsPreview) { - return ,$Label - } - - if ($Label.PSObject.Properties.Name -contains 'ParentId' -and $Label.ParentId) { - return ,$Label - } - - if (Test-LabelIsApplicable -Label $Label) { - return ,$Label - } - - $subs = Get-LiveSubLabelsForParent -Parent $Label - if (-not $subs -or $subs.Count -eq 0) { - throw "Label path '$Path' resolved to label group '$($Label.DisplayName)' (Name='$($Label.Name)') which has ContentType='None' and no live sub-labels. Copilot DLP can only reference labels that are applicable to content. Either add sub-labels under this group in the Purview portal or replace the entry in PurviewConfig.psd1 AIGovernance.DlpPolicies[].LabelPaths with explicit sub-label paths (e.g. 'HighlyConfidential/HCAllEmps')." - } - $subDisplay = ($subs | ForEach-Object { $_.DisplayName }) -join ', ' - Write-Host " Label path '$Path' is a modern-scheme label group (ContentType=None); expanded to $($subs.Count) applicable sub-label(s): $subDisplay." -ForegroundColor DarkYellow - Add-RunLogEntry -Module 'Setup-AIGovernance' -Action 'Expand label group' -Target $Path -Status 'Info' -Detail "Modern-scheme label group '$($Label.DisplayName)' (Guid=$($Label.Guid)) expanded to sub-labels: $subDisplay." - return $subs -} - -function Build-CopilotAdvancedRuleJson { - <# - Build the AdvancedRule JSON body for a Copilot/Application-location DLP - rule that matches on sensitivity labels and triggers RestrictAccess. - - Why this shape: - The Microsoft Learn snippet on the New-DlpCompliancePolicy doc page - shows lowercase keys ('groups', 'labels', 'name', 'type') and a - label entry of just { name = GUID; type = 'Sensitivity' }. The - IPPS engine accepts that shape and stores it verbatim, BUT the - Purview UI rule-edit wizard cannot parse it -- the "Customize - advanced DLP rules" page renders an empty rule list ("0 items / - No rules created"), even though the rule exists and is - enforceable. - - When you create the same rule via the UI, the engine stores it in - PascalCase with a richer label entry that includes BOTH the - display name and the GUID: - - "Labels": [ - { "Name": "", "Id": "", "Type": "Sensitivity" } - ] - - That is the shape the UI parser knows how to render. We mirror it - exactly so script-created rules show up in the same wizard UI - that admins use to inspect / hand-edit the policy. - - Resulting JSON: - { - "Version": "1.0", - "Condition": { - "Operator": "And", - "SubConditions": [ - { - "ConditionName": "ContentContainsSensitiveInformation", - "Value": [ - { - "Groups": [ - { - "Name": "Default", - "Operator": "Or", - "Labels": [ - { "Name": "", "Id": "", "Type": "Sensitivity" } - ] - } - ], - "Operator": "And" - } - ] - } - ] - } - } - - We assemble the JSON as a string rather than via ConvertTo-Json to - guarantee exact casing, property order, and array shape -- - ConvertTo-Json has repeatedly proven unreliable for single-element - arrays here. - #> - param( - [Parameter(Mandatory)] - # Each entry must be a [pscustomobject] / hashtable with at minimum: - # .Name - sensitivity label display name (post-tombstone-bump value) - # .Guid - sensitivity label GUID (string or [guid]) - [array] $Labels - ) - - if (-not $Labels -or $Labels.Count -eq 0) { - throw "Build-CopilotAdvancedRuleJson: at least one label must be supplied." - } - - $labelLines = @() - foreach ($l in $Labels) { - if (-not $l.Name -or -not $l.Guid) { - throw "Build-CopilotAdvancedRuleJson: label entry missing Name or Guid." - } - # PR5/5a: escape Name + Guid before string-concatenating into JSON. Labels - # named with quotes, backslashes, or control chars used to produce invalid - # JSON that the IPPS engine rejected with a cryptic deserialiser error. - $nameEsc = ConvertTo-PurviewJsonString -Value ([string]$l.Name) - $guidEsc = ConvertTo-PurviewJsonString -Value ([string]$l.Guid) - $labelLines += " { ""Name"": ""$nameEsc"", ""Id"": ""$guidEsc"", ""Type"": ""Sensitivity"" }" - } - $labelsBlock = $labelLines -join ",`n" - - return @" -{ - "Version": "1.0", - "Condition": { - "Operator": "And", - "SubConditions": [ - { - "ConditionName": "ContentContainsSensitiveInformation", - "Value": [ - { - "Groups": [ - { - "Name": "Default", - "Operator": "Or", - "Labels": [ -$labelsBlock - ] - } - ], - "Operator": "And" - } - ] - } - ] - } -} -"@ -} - -# --------------------------------------------------------------------------- -# Sanity-check: are the Copilot DLP parameters available on this tenant's -# IPPS endpoint? -EnforcementPlanes and -Locations were added in 2024; -# older module versions silently ignore them, which would create a broken -# policy. Bail clearly if the parameter is missing. -# --------------------------------------------------------------------------- -$policyCmd = Get-Command New-DlpCompliancePolicy -ErrorAction SilentlyContinue -if (-not $policyCmd) { - throw "New-DlpCompliancePolicy is not available. Connect to Security & Compliance (IPPS) first." -} -if (-not $policyCmd.Parameters.ContainsKey('Locations') -or -not $policyCmd.Parameters.ContainsKey('EnforcementPlanes')) { - $exoMod = Get-Module ExchangeOnlineManagement -ListAvailable -ErrorAction SilentlyContinue | - Sort-Object Version -Descending | Select-Object -First 1 - $installedVer = if ($exoMod) { "v$($exoMod.Version)" } else { 'not installed' } - $minVer = '3.9.0' - throw @" -Your ExchangeOnlineManagement module ($installedVer) does not expose the Copilot DLP parameters (-Locations / -EnforcementPlanes). -The IPPS REST proxy built by ExchangeOnlineManagement v$minVer+ is required for Microsoft 365 Copilot DLP policies. - -Fix (in an ELEVATED PowerShell, then close and reopen PowerShell before re-running this script): - Update-Module ExchangeOnlineManagement -Force -or - Install-Module ExchangeOnlineManagement -Scope AllUsers -Force -AllowClobber - -This was flagged earlier at connect time as a non-blocking 'Module currency' warning — see the HTML report for the same install commands. -"@ -} - -foreach ($cfg in $Config.AIGovernance.DlpPolicies) { - Write-Host "AI policy: $($cfg.Name)" -ForegroundColor Cyan - - if ($cfg.Name.Length -gt 64) { - throw "AI DLP policy name '$($cfg.Name)' is $($cfg.Name.Length) characters; Microsoft Purview enforces a 64-character maximum. Edit PurviewConfig.psd1 and shorten the Name field." - } - if ($cfg.RuleName.Length -gt 64) { - throw "AI DLP rule name '$($cfg.RuleName)' is $($cfg.RuleName.Length) characters; Microsoft Purview enforces a 64-character maximum. Edit PurviewConfig.psd1 and shorten the RuleName field." - } - - # ------------------------------------------------------------------- - # Resolve labels we will block in the rule (need both Name and GUID: - # the AdvancedRule JSON shape that the Purview UI rule-edit wizard - # parses correctly carries both -- see Build-CopilotAdvancedRuleJson). - # ------------------------------------------------------------------- - $labelPaths = @($cfg.LabelPaths) - if (-not $labelPaths -or $labelPaths.Count -eq 0) { - throw "AI DLP policy '$($cfg.Name)' has no LabelPaths configured. Add at least one parent or parent/child path." - } - $labelInfos = @() - $seenGuids = @{} - foreach ($lp in $labelPaths) { - $lbl = Resolve-LabelByPath -Path $lp - $expanded = Expand-CopilotLabelEntry -Label $lbl -Path $lp - foreach ($e in @($expanded)) { - $g = $e.Guid.ToString() - if ($seenGuids.ContainsKey($g)) { - Write-Verbose " Skipping duplicate label '$($e.Name)' ($g) — already added via another LabelPath." - continue - } - $seenGuids[$g] = $true - $labelInfos += [pscustomobject]@{ - Name = $e.Name - Guid = $g - } - Write-Verbose " Resolved '$lp' -> '$($e.Name)' ($g)" - } - } - if ($labelInfos.Count -eq 0) { - throw "AI DLP policy '$($cfg.Name)' resolved no usable sensitivity labels from LabelPaths. Check that the labels exist and are applicable to content (label groups must have live sub-labels)." - } - - # ------------------------------------------------------------------- - # Policy - # ------------------------------------------------------------------- - $existing = Get-DlpCompliancePolicy -Identity $cfg.Name -ErrorAction SilentlyContinue - $owned = Test-Owned -Object $existing -Tag $tag - - if ($existing -and -not $owned) { - Write-Host " AI DLP policy '$($cfg.Name)' exists but is not tagged as toolkit-managed. Updating in place (will be re-tagged)." -ForegroundColor DarkYellow - } - - $locationsJson = ConvertTo-LocationsJson -Locations $cfg.Locations - Write-Verbose " Locations JSON: $locationsJson" - - # Mode resolution: - # - The toolkit-wide `DlpStartInSimulation` flag (PurviewConfig.psd1) - # forces ALL DLP policies — including AI/Copilot — into simulation - # mode (TestWithoutNotifications) when $true. This is the SMB - # deploy default so partners can verify scope before enforcement. - # - Set `DlpStartInSimulation = $false` to honour the per-policy - # `Mode` setting from this config block (defaults to 'Enable'). - $startInSim = $true - if ($Config.PSObject.Properties.Name -contains 'DlpStartInSimulation' -and $null -ne $Config.DlpStartInSimulation) { - $startInSim = [bool] $Config.DlpStartInSimulation - } elseif ($Config -is [hashtable] -and $Config.ContainsKey('DlpStartInSimulation')) { - $startInSim = [bool] $Config['DlpStartInSimulation'] - } - if ($startInSim) { - $mode = 'TestWithoutNotifications' - } else { - $mode = if ($cfg.Mode) { $cfg.Mode } else { 'Enable' } - } - - if (-not $existing) { - if ($PSCmdlet.ShouldProcess($cfg.Name, "New-DlpCompliancePolicy (Copilot, -Mode $mode)")) { - try { - Invoke-WithTransientRetry -Description ("New-DlpCompliancePolicy (Copilot) '$($cfg.Name)'") -AlreadyExistsIsSuccess -Action { - New-DlpCompliancePolicy ` - -Name $cfg.Name ` - -Comment "$tag $($cfg.Comment)" ` - -Locations $locationsJson ` - -EnforcementPlanes $cfg.EnforcementPlanes ` - -Mode $mode ` - -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null - } - if ($mode -eq 'TestWithoutNotifications') { - Write-Host " + Created Copilot DLP policy in simulation mode (Mode=TestWithoutNotifications). Toggle 'Turn the policy on if it's not edited within fifteen days of simulation' in the Purview portal to auto-enable." -ForegroundColor Green - } else { - Write-Host " + Created Copilot DLP policy (Mode=$mode)." -ForegroundColor Green - } - $existing = Get-DlpCompliancePolicy -Identity $cfg.Name -ErrorAction SilentlyContinue - } catch { - Write-Warning " Failed to create AI policy '$($cfg.Name)': $(Format-IPPSError $_)" - continue - } - } - } else { - $currentMode = $existing.Mode - $setArgs = @{ - Identity = $cfg.Name - Comment = "$tag $($cfg.Comment)" - Locations = $locationsJson - EnforcementPlanes = $cfg.EnforcementPlanes - } - # Reconcile Mode against config (mirrors Setup-DLP.ps1): - # - simulation requested but currently in another state -> flip to TestWithoutNotifications - # - simulation not requested but currently in TestWithoutNotifications -> flip to per-policy Mode - if ($startInSim -and $currentMode -ne 'TestWithoutNotifications') { - $setArgs['Mode'] = 'TestWithoutNotifications' - } elseif (-not $startInSim -and $currentMode -eq 'TestWithoutNotifications') { - $setArgs['Mode'] = $mode - } - - if ($PSCmdlet.ShouldProcess($cfg.Name, 'Set-DlpCompliancePolicy (refresh)')) { - try { - Invoke-WithTransientRetry -Description ("Set-DlpCompliancePolicy (Copilot) '$($cfg.Name)'") -Action { - Set-DlpCompliancePolicy @setArgs ` - -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null - } - if ($setArgs.ContainsKey('Mode')) { - Write-Host " ~ Updated Copilot DLP policy and flipped Mode '$currentMode' -> '$($setArgs['Mode'])' (config-driven)." -ForegroundColor Green - } else { - Write-Host " ~ Updated Copilot DLP policy (Mode=$currentMode unchanged)." -ForegroundColor Yellow - } - } catch { - Write-Warning " Failed to update AI policy '$($cfg.Name)': $(Format-IPPSError $_)" - } - } - } - - # ------------------------------------------------------------------- - # Rule (sensitivity-label condition + RestrictAccess block) - # ------------------------------------------------------------------- - # We write -AdvancedRule directly instead of -ContentContainsSensitiveInformation. - # The IPPS engine accepts -ContentContainsSensitiveInformation for the - # Copilot/Application location and persists it -- but the resulting JSON - # is in lowercase keys with label entries of just { name = GUID; type = ... }. - # The Purview UI rule-edit wizard cannot parse that shape and renders - # "No rules created", even though Get-DlpComplianceRule confirms the - # rule exists and is enforceable. Writing AdvancedRule ourselves with - # the PascalCase + Name+Id label shape that the UI itself produces - # makes our rules round-trip correctly through the wizard. - $advRuleJson = Build-CopilotAdvancedRuleJson -Labels $labelInfos - Write-Verbose " AdvancedRule JSON: $advRuleJson" - - # ----- Manual repro snippet (only emitted when -Debug is passed) ----- - if ($DebugPreference -ne 'SilentlyContinue') { - $reproRestrict = ($cfg.RestrictAccess | ForEach-Object { - "@{ setting = '$($_.setting)'; value = '$($_.value)' }" - }) -join ', ' - Write-Host " --- Manual repro (copy/paste in same IPPS session) ---" -ForegroundColor DarkCyan - Write-Host " `$advRule = @'" -ForegroundColor DarkCyan - foreach ($line in ($advRuleJson -split "`r?`n")) { - Write-Host " $line" -ForegroundColor DarkCyan - } - Write-Host " '@" -ForegroundColor DarkCyan - Write-Host " New-DlpComplianceRule ``" -ForegroundColor DarkCyan - Write-Host " -Name '$($cfg.RuleName)' ``" -ForegroundColor DarkCyan - Write-Host " -Policy '$($cfg.Name)' ``" -ForegroundColor DarkCyan - Write-Host " -AdvancedRule `$advRule ``" -ForegroundColor DarkCyan - Write-Host " -RestrictAccess @($reproRestrict)" -ForegroundColor DarkCyan - Write-Host " --- end repro ---" -ForegroundColor DarkCyan - } - - $existingRule = Get-DlpComplianceRule -Identity $cfg.RuleName -ErrorAction SilentlyContinue - $ruleOwned = Test-Owned -Object $existingRule -Tag $tag - - if ($existingRule -and -not $ruleOwned -and -not $AdoptExisting) { - throw "AI DLP rule '$($cfg.RuleName)' exists but is not managed by this toolkit. Re-run with -AdoptExisting to update it." - } - - if (-not $existingRule) { - if ($PSCmdlet.ShouldProcess($cfg.RuleName, 'New-DlpComplianceRule (Copilot)')) { - # DLP compliance rules behave like sensitivity labels: Remove-DlpComplianceRule - # leaves the name in a soft-delete tombstone for ~30 days. Re-using the - # name fails with "ComplianceRuleAlreadyExistsInScenarioException ... already - # exists in scenario(s) 'Dlp'". When that happens we bump the rule name to - # the next free '-vN' suffix, identical to the label-tombstone bypass in - # Setup-SensitivityLabels.ps1. The bump is in-memory only; PurviewConfig.psd1 - # on disk is unchanged, and the original name will become reusable once the - # tombstone purges. - $candName = $cfg.RuleName - $maxBumps = 20 - $bumpCount = 0 - $created = $false - while (-not $created -and $bumpCount -le $maxBumps) { - if ($candName.Length -gt 64) { - Write-Warning " Cannot bump rule name to bypass tombstone: '$candName' would exceed 64 chars. Edit RuleName in PurviewConfig.psd1." - break - } - try { - Invoke-WithTransientRetry -Description ("New-DlpComplianceRule (Copilot) '$candName'") -Action { - New-DlpComplianceRule ` - -Name $candName ` - -Policy $cfg.Name ` - -Comment "$tag AI DLP rule (Copilot)." ` - -AdvancedRule $advRuleJson ` - -RestrictAccess $cfg.RestrictAccess ` - -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null - } - if ($candName -ne $cfg.RuleName) { - Write-Host " + Created Copilot DLP rule as '$candName' (original name '$($cfg.RuleName)' tombstoned)." -ForegroundColor Green - } else { - Write-Host " + Created Copilot DLP rule." -ForegroundColor Green - } - $created = $true - break - } catch { - $msg = (Format-IPPSError $_) - if ($msg -match 'already exists in scenario|ComplianceRuleAlreadyExistsInScenarioException') { - $bumpCount++ - $nextName = "$($cfg.RuleName)-v$($bumpCount + 1)" - Write-Host " '$candName' is held by a soft-delete tombstone; retrying with '$nextName'..." -ForegroundColor DarkYellow - $candName = $nextName - continue - } - Write-Warning " Failed to create AI rule '$candName': $msg" - break - } - } - if (-not $created -and $bumpCount -gt $maxBumps) { - Write-Warning " Could not find a free rule name after $maxBumps bumps. Edit RuleName in PurviewConfig.psd1 and re-run." - } - } - } else { - if ($existingRule.ParentPolicyName -ne $cfg.Name) { - Write-Warning "Rule '$($cfg.RuleName)' belongs to policy '$($existingRule.ParentPolicyName)', not '$($cfg.Name)'. Skipping update — DLP rules cannot be moved between policies." - continue - } - if ($PSCmdlet.ShouldProcess($cfg.RuleName, 'Set-DlpComplianceRule (refresh)')) { - $setSucceeded = $false - $setMsg = $null - try { - Invoke-WithTransientRetry -Description ("Set-DlpComplianceRule (Copilot) '$($cfg.RuleName)'") -Action { - Set-DlpComplianceRule ` - -Identity $cfg.RuleName ` - -Comment "$tag AI DLP rule (Copilot)." ` - -AdvancedRule $advRuleJson ` - -RestrictAccess $cfg.RestrictAccess ` - -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null - } - Write-Host " ~ Updated Copilot DLP rule." -ForegroundColor Yellow - $setSucceeded = $true - } catch { - $setMsg = (Format-IPPSError $_) - } - if (-not $setSucceeded) { - # Get-DlpComplianceRule sometimes returns a tombstone (soft-deleted) - # row even though the rule is gone from the live store. Set then fails - # with ErrorCommonComplianceRuleIsDeletedException. Detect that and - # fall through to a fresh Create with bump-on-tombstone. - if ($setMsg -match 'has been deleted and cannot be modified|ErrorCommonComplianceRuleIsDeletedException') { - Write-Host " Existing rule is a soft-delete tombstone; recreating under bumped name." -ForegroundColor DarkYellow - $candName = "$($cfg.RuleName)-v2" - $maxBumps = 20 - $bumpCount = 1 - $created = $false - while (-not $created -and $bumpCount -le $maxBumps) { - if ($candName.Length -gt 64) { - Write-Warning " Cannot bump rule name to bypass tombstone: '$candName' would exceed 64 chars. Edit RuleName in PurviewConfig.psd1." - break - } - try { - Invoke-WithTransientRetry -Description ("New-DlpComplianceRule (Copilot, tombstone-recreate) '$candName'") -Action { - New-DlpComplianceRule ` - -Name $candName ` - -Policy $cfg.Name ` - -Comment "$tag AI DLP rule (Copilot)." ` - -AdvancedRule $advRuleJson ` - -RestrictAccess $cfg.RestrictAccess ` - -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null - } - Write-Host " + Created Copilot DLP rule as '$candName' (original name '$($cfg.RuleName)' tombstoned)." -ForegroundColor Green - $created = $true - break - } catch { - $msg2 = (Format-IPPSError $_) - if ($msg2 -match 'already exists in scenario|ComplianceRuleAlreadyExistsInScenarioException') { - $bumpCount++ - $nextName = "$($cfg.RuleName)-v$($bumpCount + 1)" - Write-Host " '$candName' is held by a soft-delete tombstone; retrying with '$nextName'..." -ForegroundColor DarkYellow - $candName = $nextName - continue - } - Write-Warning " Failed to create AI rule '$candName': $msg2" - break - } - } - } else { - Write-Warning " Failed to update AI rule '$($cfg.RuleName)': $setMsg" - } - } - } - } -} - -Write-Host "AI governance policies complete." -ForegroundColor Green -Write-Host "NOTE: Microsoft 365 Copilot DLP enforcement requires Copilot per-user licensing." -ForegroundColor DarkYellow -Write-Host " Policy changes can take up to an hour to begin enforcement." -ForegroundColor DarkYellow +#requires -Version 7.0 +<# +.SYNOPSIS + Creates Microsoft Purview DLP policies that govern AI interactions + (Microsoft 365 Copilot and Agent 365). + +.DESCRIPTION + AI governance policies live in their own toolkit category because: + + * They use a different cmdlet shape than channel-based DLP. Per the + official documentation for New-DlpCompliancePolicy / New-DlpComplianceRule, + Copilot DLP requires the new -Locations JSON parameter, the + -EnforcementPlanes parameter, an -AdvancedRule body, and + -RestrictAccess action — none of which apply to legacy + Exchange / SPO / OneDrive DLP. + * They are license-tier gated. The policy plane is part of E5 / + Purview Suite, so Setup-AIGovernance auto-skips when called with + -BPOnly. Note: the policy enforces against both paid Microsoft + 365 Copilot AND the free Microsoft 365 Copilot Chat experience + (see https://learn.microsoft.com/purview/dlp-microsoft365-copilot-location-learn-about), + so creation succeeds on E5 / Purview Suite tenants regardless + of whether paid Copilot per-user SKUs are present. + * They are default-on. The deploy script provisions them by default; + pass -SkipAIControls to opt out. + * Future "AI" controls (DSPM-for-AI assessments, agent governance, + prompt policies) plug into the same AIGovernance.DlpPolicies array. + + Currently implemented controls: + + AI_054 Block Microsoft 365 Copilot from processing content bearing + specific sensitivity labels (default: 'Highly Confidential'). + https://microsoft.github.io/zerotrustassessment/docs/workshop-guidance/AI/AI_054 + + Idempotent: existing toolkit-managed policies and rules are updated in + place; foreign objects abort the run unless -AdoptExisting is supplied. + +.PARAMETER Config + Hashtable from PurviewConfig.psd1. + +.PARAMETER AdoptExisting + Update AI DLP policies / rules that already exist but are not managed + by this toolkit. + +.PARAMETER BPOnly + Skip immediately with an "E5 / Purview Suite required" summary entry. + The orchestrator already gates the AI step on -BPOnly, but the module + honours the switch independently so direct callers (test harnesses, + ad-hoc reruns, downstream automation) get the same protection without + needing to know the license rules. + +.NOTES + Run AFTER Setup-SensitivityLabels.ps1 in the same deploy invocation so + that label name resolution (and the soft-delete tombstone remap) is + consistent. Ordering is enforced by Deploy-PurviewBestPractice.ps1. +#> +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +param( + [Parameter(Mandatory)] + [hashtable] $Config, + + [Parameter()] + [switch] $AdoptExisting, + + [Parameter()] + [switch] $BPOnly +) + +$ErrorActionPreference = 'Stop' +# Auto-confirm: this toolkit is designed for unattended/scripted runs. Use -WhatIf for dry-run. +$ConfirmPreference = 'None' + +# Shared retry helper for transient IPPS errors (502, 503, 504, 429, timeouts). +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') +. (Join-Path $PSScriptRoot 'PurviewConfigurationContract.ps1') + +Assert-PurviewLabelIdentityConfiguration -Config $Config + +if ($BPOnly) { + # Module-side defense-in-depth: the orchestrator already gates this step + # on -BPOnly, but direct callers (ad-hoc reruns, test harnesses) get + # the same guarantee here without needing to know the license rules. + Write-Host "AI governance skipped: -BPOnly was set. Microsoft 365 Copilot DLP policies require Microsoft 365 E5 / Purview Suite (the policy plane is not available on Business Premium)." -ForegroundColor Yellow + return +} + +if (-not $Config.AIGovernance) { + Write-Host "AIGovernance section not present in PurviewConfig.psd1; nothing to do." -ForegroundColor DarkGray + return +} +if (-not $Config.AIGovernance.DlpPolicies -or $Config.AIGovernance.DlpPolicies.Count -eq 0) { + Write-Host "AIGovernance.DlpPolicies is empty; nothing to do." -ForegroundColor DarkGray + return +} + +$tag = $Config.ManagedByTag + +# Extract a meaningful error message from an IPPS ErrorRecord. IPPS REST cmdlets +# sometimes leave Exception.Message empty and only populate ErrorDetails or +# FullyQualifiedErrorId, so we walk through several properties in priority order. +function Format-IPPSError { + param([Parameter(Mandatory)] $ErrorRecord) + if (-not $ErrorRecord) { return '(no error record)' } + $candidates = @( + $ErrorRecord.Exception.Message, + $ErrorRecord.ErrorDetails.Message, + $ErrorRecord.CategoryInfo.Reason, + $ErrorRecord.FullyQualifiedErrorId, + $ErrorRecord.ToString() + ) + foreach ($c in $candidates) { + if ($c -and $c.ToString().Trim().Length -gt 0) { return $c.ToString().Trim() } + } + return '(IPPS returned an empty error - check Audit logs in Purview portal)' +} + +function Test-Owned { + param($Object, [string] $Tag) + if (-not $Object) { return $false } + $desc = if ($Object.PSObject.Properties.Name -contains 'Comment') { $Object.Comment } + elseif ($Object.PSObject.Properties.Name -contains 'Description') { $Object.Description } + else { '' } + return ($desc -and $desc -like "*$Tag*") +} + +function Resolve-LabelByPath { + <# + Same logic as Setup-DLP.ps1 Resolve-LabelByPath (kept inline here so + the AI module can run standalone). Honours $Config.LabelNameRemap + published by Setup-SensitivityLabels.ps1 when soft-deleted labels + were auto-renamed (e.g. 'HighlyConfidential' -> 'HighlyConfidential-v2'). + #> + param([string] $Path) + + $remap = $null + if ($Config -is [hashtable] -and $Config.ContainsKey('LabelNameRemap')) { + $remap = $Config['LabelNameRemap'] + } + + $parts = $Path -split '/' + if ($remap -and $remap.Count -gt 0) { + $remappedParts = @() + $changed = $false + foreach ($seg in $parts) { + if ($remap.ContainsKey($seg)) { + $remappedParts += $remap[$seg] + $changed = $true + } else { + $remappedParts += $seg + } + } + if ($changed) { + $newPath = $remappedParts -join '/' + Write-Host " Label path '$Path' remapped to '$newPath' (soft-deleted tombstone bypass)." -ForegroundColor DarkYellow + $parts = $remappedParts + $Path = $newPath + } + } + + $childName = $parts[-1] + + # Resolve WITHOUT sleeping first (see Setup-DLP.ps1 for rationale): adopted + # Microsoft-default / modern-scheme labels have a 'defa4170-...' GUID Name + # that Get-Label -Identity misses, so the DisplayName fallback below catches + # them on the first pass instead of after a 45s false 'IPPS propagation' loop. + $tryResolve = { + $hit = Get-Label -Identity $childName -ErrorAction SilentlyContinue + if ($hit) { return $hit } + $cfgEntry = $null + if ($Config -and $Config.Labels) { + foreach ($_l in $Config.Labels) { + if ($_l.Name -eq $childName) { + $cfgEntry = @{ DisplayName = $_l.DisplayName; IsSub = $false; ParentName = $null; ParentDisplayName = $null; BuiltInName = $_l.BuiltInName } + break + } + if ($_l.SubLabels) { + foreach ($_s in $_l.SubLabels) { + if ($_s.Name -eq $childName) { + $cfgEntry = @{ DisplayName = $_s.DisplayName; IsSub = $true; ParentName = $_l.Name; ParentDisplayName = $_l.DisplayName; BuiltInName = $_s.BuiltInName } + break + } + } + if ($cfgEntry) { break } + } + } + } + if (-not $cfgEntry) { return $null } + + # Signature match (region/scheme-proof): adopt the Microsoft built-in label + # by its 'defa4170-...' Name regardless of DisplayName localization. Try the + # modern-scheme group form ('Group') first so a parent path resolves the + # label GROUP; fall back to the base id (leaves + classic parents). + if ($cfgEntry.BuiltInName) { + $sig = Get-Label -Identity ([string]$cfgEntry.BuiltInName + 'Group') -ErrorAction SilentlyContinue + if (-not $sig) { $sig = Get-Label -Identity ([string]$cfgEntry.BuiltInName) -ErrorAction SilentlyContinue } + if ($sig -and -not (($sig.PSObject.Properties.Name -contains 'Mode' -and $sig.Mode -eq 'PendingDeletion') -or + ($sig.PSObject.Properties.Name -contains 'Disabled' -and $sig.Disabled -eq $true))) { + Write-Host " Label '$Path' resolved by Name signature (defa4170) (Id: $($sig.Guid), Name: $($sig.Name)) — adopted built-in label." -ForegroundColor DarkYellow + Add-RunLogEntry -Module 'Setup-AIGovernance' -Action 'Resolve label path' -Target $Path -Status 'Adopted' -Detail "Matched by Name signature (defa4170) -> existing GUID $($sig.Guid)." + return $sig + } + } + $tenantLabels = @(Get-Label -ErrorAction SilentlyContinue) | Where-Object { + -not (($_.PSObject.Properties.Name -contains 'Mode' -and $_.Mode -eq 'PendingDeletion') -or + ($_.PSObject.Properties.Name -contains 'Disabled' -and $_.Disabled -eq $true)) + } + $byDisplay = $null + if ($cfgEntry.IsSub) { + $parentObj = $tenantLabels | Where-Object { $_.Name -eq $cfgEntry.ParentName -and -not $_.ParentId } | Select-Object -First 1 + if (-not $parentObj) { + $parentObj = $tenantLabels | Where-Object { $_.DisplayName -eq $cfgEntry.ParentDisplayName -and -not $_.ParentId } | Select-Object -First 1 + } + if ($parentObj) { + $byDisplay = $tenantLabels | Where-Object { $_.DisplayName -eq $cfgEntry.DisplayName -and $_.ParentId -eq $parentObj.Guid } | Select-Object -First 1 + } + } else { + $byDisplay = $tenantLabels | Where-Object { $_.DisplayName -eq $cfgEntry.DisplayName -and -not $_.ParentId } | Select-Object -First 1 + } + if ($byDisplay) { + Write-Host " Label '$Path' resolved by DisplayName '$($cfgEntry.DisplayName)' (Id: $($byDisplay.Guid), Name: $($byDisplay.Name)) — adopted-existing label." -ForegroundColor DarkYellow + Add-RunLogEntry -Module 'Setup-AIGovernance' -Action 'Resolve label path' -Target $Path -Status 'Adopted' -Detail "Matched by DisplayName '$($cfgEntry.DisplayName)' -> existing GUID $($byDisplay.Guid)." + } + return $byDisplay + } + + $child = & $tryResolve + + # Only WAIT for IPPS propagation when the label genuinely isn't visible yet + # (a label this same apply-mode run just created). Never wait under -WhatIf. + if (-not $child -and -not $WhatIfPreference) { + $maxLookupAttempts = 3 + for ($la = 1; $la -le $maxLookupAttempts; $la++) { + Write-Host (" Label '$childName' not visible yet (IPPS propagation). Waiting 15s (attempt $la/$maxLookupAttempts)...") -ForegroundColor DarkYellow + Start-Sleep -Seconds 15 + $child = & $tryResolve + if ($child) { break } + } + } + + if ($child) { + $isSoftDeleted = $false + if ($child.PSObject.Properties.Name -contains 'Mode' -and $child.Mode -eq 'PendingDeletion') { $isSoftDeleted = $true } + if ($child.PSObject.Properties.Name -contains 'Disabled' -and $child.Disabled -eq $true) { $isSoftDeleted = $true } + if ($isSoftDeleted) { + throw "Sensitivity label '$Path' is soft-deleted (Mode='$($child.Mode)') and cannot be referenced by an AI DLP rule. Run Setup-SensitivityLabels.ps1 first (it auto-renames tombstoned labels) or wait for the ~30-day purge." + } + return $child + } + + if ($WhatIfPreference) { + Write-Host " What if: label '$Path' does not yet exist; using a placeholder GUID for the preview." -ForegroundColor DarkYellow + return [pscustomobject]@{ + Name = $childName + DisplayName = $childName + Guid = [guid]::Empty + IsPreview = $true + } + } + + throw "Sensitivity label '$Path' not found. Run Setup-SensitivityLabels.ps1 first." +} + +function ConvertTo-LocationsJson { + <# + Convert the Locations hashtable array from PurviewConfig.psd1 into + the JSON string shape that New-DlpCompliancePolicy -Locations expects. + Per Microsoft Learn the value is a JSON array of: + { Workload, Location, Inclusions=[{Type,Identity}], Exclusions=[...] } + + Note: PowerShell's ConvertTo-Json collapses a single-element array to + a JSON object (not [{...}] but {...}). The IPPS deserialiser then + rejects it with "type requires a JSON array". We force-wrap when the + input has exactly one element. + #> + param([Parameter(Mandatory)] [array] $Locations) + + $list = @() + foreach ($loc in $Locations) { + $entry = [ordered]@{ + Workload = $loc.Workload + Location = $loc.Location + Inclusions = @() + } + foreach ($inc in @($loc.Inclusions)) { + if ($inc) { $entry.Inclusions += [ordered]@{ Type = $inc.Type; Identity = $inc.Identity } } + } + if ($loc.Exclusions) { + $entry['Exclusions'] = @() + foreach ($exc in @($loc.Exclusions)) { + if ($exc) { $entry.Exclusions += [ordered]@{ Type = $exc.Type; Identity = $exc.Identity } } + } + } + $list += $entry + } + $json = $list | ConvertTo-Json -Depth 10 -Compress + if ($list.Count -le 1 -and -not $json.StartsWith('[')) { + $json = "[$json]" + } + return $json +} + +function ConvertTo-PurviewJsonString { + <# + Escapes a string so it is safe to drop INSIDE a JSON string literal + (i.e. between the surrounding double-quotes) per RFC 8259 section 7. + + We assemble the AdvancedRule JSON by hand (see Build-CopilotAdvancedRuleJson + for the long explanation of why) rather than via ConvertTo-Json. Hand + assembly means we are responsible for escaping any character that would + otherwise break the JSON parse — a sensitivity label called + Acme "Confidential" Tier + or a tenant that uses a backslash in a custom display name would + produce malformed JSON without this helper, causing + New-DlpComplianceRule to fail with a cryptic IPPS deserialiser error. + + Returns the inner content only (no surrounding double-quotes). + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Value + ) + + $sb = [System.Text.StringBuilder]::new($Value.Length + 8) + foreach ($ch in $Value.ToCharArray()) { + $code = [int]$ch + # Compare by Unicode code-point so PowerShell escape-sequence ambiguity + # (`b is backspace, `a is BEL, etc.) cannot accidentally collapse two + # control chars onto the same switch case. + switch ($code) { + 8 { [void]$sb.Append('\b'); break } # backspace + 9 { [void]$sb.Append('\t'); break } # tab + 10 { [void]$sb.Append('\n'); break } # line feed + 12 { [void]$sb.Append('\f'); break } # form feed + 13 { [void]$sb.Append('\r'); break } # carriage return + 34 { [void]$sb.Append('\"'); break } # double-quote + 92 { [void]$sb.Append('\\'); break } # backslash + default { + if ($code -lt 0x20) { + # Any other C0 control char — escape as \u00XX. + [void]$sb.AppendFormat('\u{0:x4}', $code) + } else { + [void]$sb.Append($ch) + } + } + } + } + return $sb.ToString() +} + +function Test-LabelIsApplicable { + <# + Detects whether a sensitivity label can be applied to content. + + In the modern label scheme (https://learn.microsoft.com/en-us/purview/migrate-sensitivity-label-scheme) + parent labels are demoted to "label groups": they organise their + sub-labels in the picker but cannot themselves be applied to + documents or messages. The IPPS engine reports their ContentType + as 'None' (or empty), and Copilot DLP rejects them with + " is an unsupported content type (None). M365Copilot only + supports files and Email." + + Legacy parents and all sub-labels carry ContentType bits such as + 'File, Email' and ARE applicable. + + Returns $true when the label is applicable to content; + $false when it is a label group / non-applicable container. + #> + param($Label) + + if (-not $Label) { return $false } + if (-not ($Label.PSObject.Properties.Name -contains 'ContentType')) { + # Older module shape -- assume applicable to preserve legacy behaviour. + return $true + } + $ct = $Label.ContentType + if ($null -eq $ct) { return $false } + $ctStr = ($ct.ToString()).Trim() + if ($ctStr -eq '' -or $ctStr -ieq 'None') { return $false } + return $true +} + +function Get-LiveSubLabelsForParent { + <# + Return all live (not soft-deleted, not disabled) sub-labels for a + given parent label. Used to expand label groups in the modern + scheme so the Copilot DLP rule references applicable sub-labels + instead of an unsupported group container. + #> + param($Parent) + + if (-not $Parent -or -not $Parent.Guid) { return @() } + return @(Get-Label -ErrorAction SilentlyContinue) | Where-Object { + $_.ParentId -eq $Parent.Guid -and + -not (($_.PSObject.Properties.Name -contains 'Mode' -and $_.Mode -eq 'PendingDeletion') -or + ($_.PSObject.Properties.Name -contains 'Disabled' -and $_.Disabled -eq $true)) + } +} + +function Expand-CopilotLabelEntry { + <# + Resolve a LabelPath entry into the list of labels the Copilot DLP + rule should reference. The DLP engine validates each label's + ContentType server-side, so referencing a modern-scheme label + group (ContentType=None) causes the entire rule update to fail + with "unsupported content type (None)". + + Behaviour: + - Sub-label (has ParentId): use as-is. + - Parent that IS applicable (legacy scheme): use as-is. This + preserves prior behaviour for tenants still on the legacy + parent-label scheme where the parent itself can carry a + classification. + - Parent that is NOT applicable (modern-scheme label group): + expand to all live sub-labels and emit an info message so the + partner understands what happened. + - WhatIf preview placeholder: pass through unchanged so + -WhatIf reporting still works against a fresh tenant. + #> + param( + [Parameter(Mandatory)] $Label, + [Parameter(Mandatory)] [string] $Path + ) + + if ($Label.PSObject.Properties.Name -contains 'IsPreview' -and $Label.IsPreview) { + return ,$Label + } + + if ($Label.PSObject.Properties.Name -contains 'ParentId' -and $Label.ParentId) { + return ,$Label + } + + if (Test-LabelIsApplicable -Label $Label) { + return ,$Label + } + + $subs = Get-LiveSubLabelsForParent -Parent $Label + if (-not $subs -or $subs.Count -eq 0) { + throw "Label path '$Path' resolved to label group '$($Label.DisplayName)' (Name='$($Label.Name)') which has ContentType='None' and no live sub-labels. Copilot DLP can only reference labels that are applicable to content. Either add sub-labels under this group in the Purview portal or replace the entry in PurviewConfig.psd1 AIGovernance.DlpPolicies[].LabelPaths with explicit sub-label paths (e.g. 'HighlyConfidential/HCAllEmps')." + } + $subDisplay = ($subs | ForEach-Object { $_.DisplayName }) -join ', ' + Write-Host " Label path '$Path' is a modern-scheme label group (ContentType=None); expanded to $($subs.Count) applicable sub-label(s): $subDisplay." -ForegroundColor DarkYellow + Add-RunLogEntry -Module 'Setup-AIGovernance' -Action 'Expand label group' -Target $Path -Status 'Info' -Detail "Modern-scheme label group '$($Label.DisplayName)' (Guid=$($Label.Guid)) expanded to sub-labels: $subDisplay." + return $subs +} + +function Build-CopilotAdvancedRuleJson { + <# + Build the AdvancedRule JSON body for a Copilot/Application-location DLP + rule that matches on sensitivity labels and triggers RestrictAccess. + + Why this shape: + The Microsoft Learn snippet on the New-DlpCompliancePolicy doc page + shows lowercase keys ('groups', 'labels', 'name', 'type') and a + label entry of just { name = GUID; type = 'Sensitivity' }. The + IPPS engine accepts that shape and stores it verbatim, BUT the + Purview UI rule-edit wizard cannot parse it -- the "Customize + advanced DLP rules" page renders an empty rule list ("0 items / + No rules created"), even though the rule exists and is + enforceable. + + When you create the same rule via the UI, the engine stores it in + PascalCase with a richer label entry that includes BOTH the + display name and the GUID: + + "Labels": [ + { "Name": "", "Id": "", "Type": "Sensitivity" } + ] + + That is the shape the UI parser knows how to render. We mirror it + exactly so script-created rules show up in the same wizard UI + that admins use to inspect / hand-edit the policy. + + Resulting JSON: + { + "Version": "1.0", + "Condition": { + "Operator": "And", + "SubConditions": [ + { + "ConditionName": "ContentContainsSensitiveInformation", + "Value": [ + { + "Groups": [ + { + "Name": "Default", + "Operator": "Or", + "Labels": [ + { "Name": "", "Id": "", "Type": "Sensitivity" } + ] + } + ], + "Operator": "And" + } + ] + } + ] + } + } + + We assemble the JSON as a string rather than via ConvertTo-Json to + guarantee exact casing, property order, and array shape -- + ConvertTo-Json has repeatedly proven unreliable for single-element + arrays here. + #> + param( + [Parameter(Mandatory)] + # Each entry must be a [pscustomobject] / hashtable with at minimum: + # .Name - sensitivity label display name (post-tombstone-bump value) + # .Guid - sensitivity label GUID (string or [guid]) + [array] $Labels + ) + + if (-not $Labels -or $Labels.Count -eq 0) { + throw "Build-CopilotAdvancedRuleJson: at least one label must be supplied." + } + + $labelLines = @() + foreach ($l in $Labels) { + if (-not $l.Name -or -not $l.Guid) { + throw "Build-CopilotAdvancedRuleJson: label entry missing Name or Guid." + } + # Escape Name and Guid before string-concatenating into JSON. Labels + # named with quotes, backslashes, or control chars used to produce invalid + # JSON that the IPPS engine rejected with a cryptic deserialiser error. + $nameEsc = ConvertTo-PurviewJsonString -Value ([string]$l.Name) + $guidEsc = ConvertTo-PurviewJsonString -Value ([string]$l.Guid) + $labelLines += " { ""Name"": ""$nameEsc"", ""Id"": ""$guidEsc"", ""Type"": ""Sensitivity"" }" + } + $labelsBlock = $labelLines -join ",`n" + + return @" +{ + "Version": "1.0", + "Condition": { + "Operator": "And", + "SubConditions": [ + { + "ConditionName": "ContentContainsSensitiveInformation", + "Value": [ + { + "Groups": [ + { + "Name": "Default", + "Operator": "Or", + "Labels": [ +$labelsBlock + ] + } + ], + "Operator": "And" + } + ] + } + ] + } +} +"@ +} + +# --------------------------------------------------------------------------- +# Sanity-check: are the Copilot DLP parameters available on this tenant's +# IPPS endpoint? -EnforcementPlanes and -Locations were added in 2024; +# older module versions silently ignore them, which would create a broken +# policy. Bail clearly if the parameter is missing. +# --------------------------------------------------------------------------- +$policyCmd = Get-Command New-DlpCompliancePolicy -ErrorAction SilentlyContinue +if (-not $policyCmd) { + throw "New-DlpCompliancePolicy is not available. Connect to Security & Compliance (IPPS) first." +} +if (-not $policyCmd.Parameters.ContainsKey('Locations') -or -not $policyCmd.Parameters.ContainsKey('EnforcementPlanes')) { + $exoMod = Get-Module ExchangeOnlineManagement -ListAvailable -ErrorAction SilentlyContinue | + Sort-Object Version -Descending | Select-Object -First 1 + $installedVer = if ($exoMod) { "v$($exoMod.Version)" } else { 'not installed' } + $minVer = '3.9.0' + throw @" +Your ExchangeOnlineManagement module ($installedVer) does not expose the Copilot DLP parameters (-Locations / -EnforcementPlanes). +The IPPS REST proxy built by ExchangeOnlineManagement v$minVer+ is required for Microsoft 365 Copilot DLP policies. + +Fix (in an ELEVATED PowerShell, then close and reopen PowerShell before re-running this script): + Update-Module ExchangeOnlineManagement -Force +or + Install-Module ExchangeOnlineManagement -Scope AllUsers -Force -AllowClobber + +This was flagged earlier at connect time as a non-blocking 'Module currency' warning — see the HTML report for the same install commands. +"@ +} + +foreach ($cfg in $Config.AIGovernance.DlpPolicies) { + Write-Host "AI policy: $($cfg.Name)" -ForegroundColor Cyan + + if ($cfg.Name.Length -gt 64) { + throw "AI DLP policy name '$($cfg.Name)' is $($cfg.Name.Length) characters; Microsoft Purview enforces a 64-character maximum. Edit PurviewConfig.psd1 and shorten the Name field." + } + if ($cfg.RuleName.Length -gt 64) { + throw "AI DLP rule name '$($cfg.RuleName)' is $($cfg.RuleName.Length) characters; Microsoft Purview enforces a 64-character maximum. Edit PurviewConfig.psd1 and shorten the RuleName field." + } + + # ------------------------------------------------------------------- + # Resolve labels we will block in the rule (need both Name and GUID: + # the AdvancedRule JSON shape that the Purview UI rule-edit wizard + # parses correctly carries both -- see Build-CopilotAdvancedRuleJson). + # ------------------------------------------------------------------- + $labelPaths = @($cfg.LabelPaths) + if (-not $labelPaths -or $labelPaths.Count -eq 0) { + throw "AI DLP policy '$($cfg.Name)' has no LabelPaths configured. Add at least one parent or parent/child path." + } + $labelInfos = @() + $seenGuids = @{} + foreach ($lp in $labelPaths) { + $lbl = Resolve-LabelByPath -Path $lp + $expanded = Expand-CopilotLabelEntry -Label $lbl -Path $lp + foreach ($e in @($expanded)) { + $g = $e.Guid.ToString() + if ($seenGuids.ContainsKey($g)) { + Write-Verbose " Skipping duplicate label '$($e.Name)' ($g) — already added via another LabelPath." + continue + } + $seenGuids[$g] = $true + $labelInfos += [pscustomobject]@{ + Name = $e.Name + Guid = $g + } + Write-Verbose " Resolved '$lp' -> '$($e.Name)' ($g)" + } + } + if ($labelInfos.Count -eq 0) { + throw "AI DLP policy '$($cfg.Name)' resolved no usable sensitivity labels from LabelPaths. Check that the labels exist and are applicable to content (label groups must have live sub-labels)." + } + + # ------------------------------------------------------------------- + # Policy + # ------------------------------------------------------------------- + $existing = Get-DlpCompliancePolicy -Identity $cfg.Name -ErrorAction SilentlyContinue + $owned = Test-Owned -Object $existing -Tag $tag + + if ($existing -and -not $owned) { + Write-Host " AI DLP policy '$($cfg.Name)' exists but is not tagged as toolkit-managed. Updating in place (will be re-tagged)." -ForegroundColor DarkYellow + } + + $locationsJson = ConvertTo-LocationsJson -Locations $cfg.Locations + Write-Verbose " Locations JSON: $locationsJson" + + # Mode resolution: + # - The toolkit-wide `DlpStartInSimulation` flag (PurviewConfig.psd1) + # forces ALL DLP policies — including AI/Copilot — into simulation + # mode (TestWithoutNotifications) when $true. This is the SMB + # deploy default so partners can verify scope before enforcement. + # - Set `DlpStartInSimulation = $false` to honour the per-policy + # `Mode` setting from this config block (defaults to 'Enable'). + $startInSim = $true + if ($Config.PSObject.Properties.Name -contains 'DlpStartInSimulation' -and $null -ne $Config.DlpStartInSimulation) { + $startInSim = [bool] $Config.DlpStartInSimulation + } elseif ($Config -is [hashtable] -and $Config.ContainsKey('DlpStartInSimulation')) { + $startInSim = [bool] $Config['DlpStartInSimulation'] + } + if ($startInSim) { + $mode = 'TestWithoutNotifications' + } else { + $mode = if ($cfg.Mode) { $cfg.Mode } else { 'Enable' } + } + + if (-not $existing) { + if ($PSCmdlet.ShouldProcess($cfg.Name, "New-DlpCompliancePolicy (Copilot, -Mode $mode)")) { + try { + Invoke-WithTransientRetry -Description ("New-DlpCompliancePolicy (Copilot) '$($cfg.Name)'") -AlreadyExistsIsSuccess -Action { + New-DlpCompliancePolicy ` + -Name $cfg.Name ` + -Comment "$tag $($cfg.Comment)" ` + -Locations $locationsJson ` + -EnforcementPlanes $cfg.EnforcementPlanes ` + -Mode $mode ` + -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null + } + if ($mode -eq 'TestWithoutNotifications') { + Write-Host " + Created Copilot DLP policy in simulation mode (Mode=TestWithoutNotifications). Toggle 'Turn the policy on if it's not edited within fifteen days of simulation' in the Purview portal to auto-enable." -ForegroundColor Green + } else { + Write-Host " + Created Copilot DLP policy (Mode=$mode)." -ForegroundColor Green + } + $existing = Get-DlpCompliancePolicy -Identity $cfg.Name -ErrorAction SilentlyContinue + } catch { + Write-Warning " Failed to create AI policy '$($cfg.Name)': $(Format-IPPSError $_)" + continue + } + } + } else { + $currentMode = $existing.Mode + $setArgs = @{ + Identity = $cfg.Name + Comment = "$tag $($cfg.Comment)" + Locations = $locationsJson + EnforcementPlanes = $cfg.EnforcementPlanes + } + # Reconcile Mode against config (mirrors Setup-DLP.ps1): + # - simulation requested but currently in another state -> flip to TestWithoutNotifications + # - simulation not requested but currently in TestWithoutNotifications -> flip to per-policy Mode + if ($startInSim -and $currentMode -ne 'TestWithoutNotifications') { + $setArgs['Mode'] = 'TestWithoutNotifications' + } elseif (-not $startInSim -and $currentMode -eq 'TestWithoutNotifications') { + $setArgs['Mode'] = $mode + } + + if ($PSCmdlet.ShouldProcess($cfg.Name, 'Set-DlpCompliancePolicy (refresh)')) { + try { + Invoke-WithTransientRetry -Description ("Set-DlpCompliancePolicy (Copilot) '$($cfg.Name)'") -Action { + Set-DlpCompliancePolicy @setArgs ` + -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null + } + if ($setArgs.ContainsKey('Mode')) { + Write-Host " ~ Updated Copilot DLP policy and flipped Mode '$currentMode' -> '$($setArgs['Mode'])' (config-driven)." -ForegroundColor Green + } else { + Write-Host " ~ Updated Copilot DLP policy (Mode=$currentMode unchanged)." -ForegroundColor Yellow + } + } catch { + Write-Warning " Failed to update AI policy '$($cfg.Name)': $(Format-IPPSError $_)" + } + } + } + + # ------------------------------------------------------------------- + # Rule (sensitivity-label condition + RestrictAccess block) + # ------------------------------------------------------------------- + # We write -AdvancedRule directly instead of -ContentContainsSensitiveInformation. + # The IPPS engine accepts -ContentContainsSensitiveInformation for the + # Copilot/Application location and persists it -- but the resulting JSON + # is in lowercase keys with label entries of just { name = GUID; type = ... }. + # The Purview UI rule-edit wizard cannot parse that shape and renders + # "No rules created", even though Get-DlpComplianceRule confirms the + # rule exists and is enforceable. Writing AdvancedRule ourselves with + # the PascalCase + Name+Id label shape that the UI itself produces + # makes our rules round-trip correctly through the wizard. + $advRuleJson = Build-CopilotAdvancedRuleJson -Labels $labelInfos + Write-Verbose " AdvancedRule JSON: $advRuleJson" + + # ----- Manual repro snippet (only emitted when -Debug is passed) ----- + if ($DebugPreference -ne 'SilentlyContinue') { + $reproRestrict = ($cfg.RestrictAccess | ForEach-Object { + "@{ setting = '$($_.setting)'; value = '$($_.value)' }" + }) -join ', ' + Write-Host " --- Manual repro (copy/paste in same IPPS session) ---" -ForegroundColor DarkCyan + Write-Host " `$advRule = @'" -ForegroundColor DarkCyan + foreach ($line in ($advRuleJson -split "`r?`n")) { + Write-Host " $line" -ForegroundColor DarkCyan + } + Write-Host " '@" -ForegroundColor DarkCyan + Write-Host " New-DlpComplianceRule ``" -ForegroundColor DarkCyan + Write-Host " -Name '$($cfg.RuleName)' ``" -ForegroundColor DarkCyan + Write-Host " -Policy '$($cfg.Name)' ``" -ForegroundColor DarkCyan + Write-Host " -AdvancedRule `$advRule ``" -ForegroundColor DarkCyan + Write-Host " -RestrictAccess @($reproRestrict)" -ForegroundColor DarkCyan + Write-Host " --- end repro ---" -ForegroundColor DarkCyan + } + + $existingRule = Get-DlpComplianceRule -Identity $cfg.RuleName -ErrorAction SilentlyContinue + $ruleOwned = Test-Owned -Object $existingRule -Tag $tag + + if ($existingRule -and -not $ruleOwned -and -not $AdoptExisting) { + throw "AI DLP rule '$($cfg.RuleName)' exists but is not managed by this toolkit. Re-run with -AdoptExisting to update it." + } + + if (-not $existingRule) { + if ($PSCmdlet.ShouldProcess($cfg.RuleName, 'New-DlpComplianceRule (Copilot)')) { + # DLP compliance rules behave like sensitivity labels: Remove-DlpComplianceRule + # leaves the name in a soft-delete tombstone for ~30 days. Re-using the + # name fails with "ComplianceRuleAlreadyExistsInScenarioException ... already + # exists in scenario(s) 'Dlp'". When that happens we bump the rule name to + # the next free '-vN' suffix, identical to the label-tombstone bypass in + # Setup-SensitivityLabels.ps1. The bump is in-memory only; PurviewConfig.psd1 + # on disk is unchanged, and the original name will become reusable once the + # tombstone purges. + $candName = $cfg.RuleName + $maxBumps = 20 + $bumpCount = 0 + $created = $false + while (-not $created -and $bumpCount -le $maxBumps) { + if ($candName.Length -gt 64) { + Write-Warning " Cannot bump rule name to bypass tombstone: '$candName' would exceed 64 chars. Edit RuleName in PurviewConfig.psd1." + break + } + try { + Invoke-WithTransientRetry -Description ("New-DlpComplianceRule (Copilot) '$candName'") -Action { + New-DlpComplianceRule ` + -Name $candName ` + -Policy $cfg.Name ` + -Comment "$tag AI DLP rule (Copilot)." ` + -AdvancedRule $advRuleJson ` + -RestrictAccess $cfg.RestrictAccess ` + -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null + } + if ($candName -ne $cfg.RuleName) { + Write-Host " + Created Copilot DLP rule as '$candName' (original name '$($cfg.RuleName)' tombstoned)." -ForegroundColor Green + } else { + Write-Host " + Created Copilot DLP rule." -ForegroundColor Green + } + $created = $true + break + } catch { + $msg = (Format-IPPSError $_) + if ($msg -match 'already exists in scenario|ComplianceRuleAlreadyExistsInScenarioException') { + $bumpCount++ + $nextName = "$($cfg.RuleName)-v$($bumpCount + 1)" + Write-Host " '$candName' is held by a soft-delete tombstone; retrying with '$nextName'..." -ForegroundColor DarkYellow + $candName = $nextName + continue + } + Write-Warning " Failed to create AI rule '$candName': $msg" + break + } + } + if (-not $created -and $bumpCount -gt $maxBumps) { + Write-Warning " Could not find a free rule name after $maxBumps bumps. Edit RuleName in PurviewConfig.psd1 and re-run." + } + } + } else { + if ($existingRule.ParentPolicyName -ne $cfg.Name) { + Write-Warning "Rule '$($cfg.RuleName)' belongs to policy '$($existingRule.ParentPolicyName)', not '$($cfg.Name)'. Skipping update — DLP rules cannot be moved between policies." + continue + } + if ($PSCmdlet.ShouldProcess($cfg.RuleName, 'Set-DlpComplianceRule (refresh)')) { + $setSucceeded = $false + $setMsg = $null + try { + Invoke-WithTransientRetry -Description ("Set-DlpComplianceRule (Copilot) '$($cfg.RuleName)'") -Action { + Set-DlpComplianceRule ` + -Identity $cfg.RuleName ` + -Comment "$tag AI DLP rule (Copilot)." ` + -AdvancedRule $advRuleJson ` + -RestrictAccess $cfg.RestrictAccess ` + -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null + } + Write-Host " ~ Updated Copilot DLP rule." -ForegroundColor Yellow + $setSucceeded = $true + } catch { + $setMsg = (Format-IPPSError $_) + } + if (-not $setSucceeded) { + # Get-DlpComplianceRule sometimes returns a tombstone (soft-deleted) + # row even though the rule is gone from the live store. Set then fails + # with ErrorCommonComplianceRuleIsDeletedException. Detect that and + # fall through to a fresh Create with bump-on-tombstone. + if ($setMsg -match 'has been deleted and cannot be modified|ErrorCommonComplianceRuleIsDeletedException') { + Write-Host " Existing rule is a soft-delete tombstone; recreating under bumped name." -ForegroundColor DarkYellow + $candName = "$($cfg.RuleName)-v2" + $maxBumps = 20 + $bumpCount = 1 + $created = $false + while (-not $created -and $bumpCount -le $maxBumps) { + if ($candName.Length -gt 64) { + Write-Warning " Cannot bump rule name to bypass tombstone: '$candName' would exceed 64 chars. Edit RuleName in PurviewConfig.psd1." + break + } + try { + Invoke-WithTransientRetry -Description ("New-DlpComplianceRule (Copilot, tombstone-recreate) '$candName'") -Action { + New-DlpComplianceRule ` + -Name $candName ` + -Policy $cfg.Name ` + -Comment "$tag AI DLP rule (Copilot)." ` + -AdvancedRule $advRuleJson ` + -RestrictAccess $cfg.RestrictAccess ` + -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null + } + Write-Host " + Created Copilot DLP rule as '$candName' (original name '$($cfg.RuleName)' tombstoned)." -ForegroundColor Green + $created = $true + break + } catch { + $msg2 = (Format-IPPSError $_) + if ($msg2 -match 'already exists in scenario|ComplianceRuleAlreadyExistsInScenarioException') { + $bumpCount++ + $nextName = "$($cfg.RuleName)-v$($bumpCount + 1)" + Write-Host " '$candName' is held by a soft-delete tombstone; retrying with '$nextName'..." -ForegroundColor DarkYellow + $candName = $nextName + continue + } + Write-Warning " Failed to create AI rule '$candName': $msg2" + break + } + } + } else { + Write-Warning " Failed to update AI rule '$($cfg.RuleName)': $setMsg" + } + } + } + } +} + +Write-Host "AI governance policies complete." -ForegroundColor Green +Write-Host "NOTE: Microsoft 365 Copilot DLP enforcement requires Copilot per-user licensing." -ForegroundColor DarkYellow +Write-Host " Policy changes can take up to an hour to begin enforcement." -ForegroundColor DarkYellow diff --git a/Products/Purview/Modules/Setup-DLP.ps1 b/Products/Purview/Modules/Setup-DLP.ps1 index ace9fe3..beabf22 100644 --- a/Products/Purview/Modules/Setup-DLP.ps1 +++ b/Products/Purview/Modules/Setup-DLP.ps1 @@ -1,640 +1,655 @@ -#requires -Version 7.0 -<# -.SYNOPSIS - Creates Microsoft Purview DLP policies that block external sharing of - content labelled "Confidential\AllEmployees". - -.DESCRIPTION - Implements Priority 1 DLP from the Microsoft 365 Business Premium Data - Security Best Practice Deployment guide. Per Microsoft guidance, separate - DLP policies are created per workload: - - * Exchange policy — blocks send to external recipients - * SharePoint + OneDrive — blocks external sharing/access - - Both policies match content via the SENSITIVITY LABEL GUID (resolved at - runtime) — never by display name — so they cannot accidentally collide - with other labels of the same name. - - Idempotent: existing toolkit-managed policies and rules are updated in - place; foreign objects abort the run unless -AdoptExisting is supplied. - -.PARAMETER Config - Hashtable from PurviewConfig.psd1. - -.PARAMETER AdoptExisting - Update DLP policies / rules that already exist but are not managed by - this toolkit. - -.PARAMETER BPOnly - Reject DLP workloads that require Microsoft 365 E5 / Purview Suite - licensing (Endpoint DLP / Devices, Defender for Cloud Apps, - OnPremisesScanner, Power BI). Used by partners deploying against - Microsoft 365 Business Premium tenants. -#> -[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] -param( - [Parameter(Mandatory)] - [hashtable] $Config, - - [Parameter()] - [switch] $AdoptExisting, - - [Parameter()] - [switch] $BPOnly -) - -$ErrorActionPreference = 'Stop' -# Auto-confirm: this toolkit is designed for unattended/scripted runs. Use -WhatIf for dry-run. -$ConfirmPreference = 'None' - -# Shared retry helper for transient IPPS / Graph errors (502, 503, 504, 429, timeouts). -. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') - -# Extract a meaningful error message from an IPPS ErrorRecord. IPPS REST cmdlets -# sometimes leave Exception.Message empty and only populate ErrorDetails or -# FullyQualifiedErrorId, so we walk through several properties in priority order. -function Format-IPPSError { - param([Parameter(Mandatory)] $ErrorRecord) - if (-not $ErrorRecord) { return '(no error record)' } - $candidates = @( - $ErrorRecord.Exception.Message, - $ErrorRecord.ErrorDetails.Message, - $ErrorRecord.CategoryInfo.Reason, - $ErrorRecord.FullyQualifiedErrorId, - $ErrorRecord.ToString() - ) - foreach ($c in $candidates) { - if ($c -and $c.ToString().Trim().Length -gt 0) { return $c.ToString().Trim() } - } - return '(IPPS returned an empty error - check Audit logs in Purview portal)' -} -$tag = $Config.ManagedByTag - -# Workloads that require Microsoft 365 E5 / Purview Suite licensing. -# When -BPOnly is passed, any DLP policy referencing one of these is rejected. -$script:E5OnlyWorkloads = @( - 'Endpoint','Devices','EndpointDevices','EndpointDlp', - 'OnPremisesScanner','OnPremises','OnPremScanner', - 'ThirdPartyApps','DefenderForCloudApps','MCAS', - 'PowerBI' -) - -function Test-Owned { - param($Object, [string] $Tag) - if (-not $Object) { return $false } - $desc = if ($Object.PSObject.Properties.Name -contains 'Comment') { $Object.Comment } - elseif ($Object.PSObject.Properties.Name -contains 'Description') { $Object.Description } - else { '' } - return ($desc -and $desc -like "*$Tag*") -} - -function Resolve-LabelByPath { - <# - LabelPath is "Parent/Child" by Name. Sub-label names are unique so the - child Name is sufficient for Get-Label, but we validate the parent to - catch typos. - - In -WhatIf mode against a clean tenant the label may not yet exist - because Setup-SensitivityLabels.ps1 hasn't actually created it. - Return a synthetic preview object so the rest of the module can - produce a complete WhatIf preview. - - Soft-delete handling: Get-Label returns soft-deleted labels - (Mode='PendingDeletion') for ~30 days after Remove-Label. Their - GUIDs are tombstoned and the IPPS server will reject any DLP rule - that references them with InvalidLabelParameterNameValueException. - - When Setup-SensitivityLabels.ps1 ran first in this same deploy, it - auto-renamed any tombstoned configured labels to '-vN' and - published a remap on $Config.LabelNameRemap. We apply that remap to - each segment of LabelPath so 'Confidential/AllEmployees' becomes - e.g. 'Confidential-v2/AllEmployees-v2' transparently. If a segment - is still tombstoned after the remap (no entry was added — e.g. the - labels module didn't run), we throw a clear error. - #> - param([string] $Path) - $remap = $null - if ($Config -and $Config.PSObject.Properties.Name -contains 'LabelNameRemap') { - $remap = $Config.LabelNameRemap - } elseif ($Config -is [hashtable] -and $Config.ContainsKey('LabelNameRemap')) { - $remap = $Config['LabelNameRemap'] - } - - $parts = $Path -split '/' - if ($remap -and $remap.Count -gt 0) { - $remappedParts = @() - $changed = $false - foreach ($seg in $parts) { - if ($remap.ContainsKey($seg)) { - $remappedParts += $remap[$seg] - $changed = $true - } else { - $remappedParts += $seg - } - } - if ($changed) { - $newPath = $remappedParts -join '/' - Write-Host " Label path '$Path' remapped to '$newPath' (soft-deleted tombstone bypass)." -ForegroundColor DarkYellow - $parts = $remappedParts - $Path = $newPath - } - } - - $childName = $parts[-1] - - # Resolve WITHOUT sleeping first. Get-Label -Identity only matches - # Name/ImmutableId/Guid, so a Path that uses our configured internal Name - # MISSES adopted labels (Microsoft-default / wizard-created / modern-scheme) - # whose internal Name is a 'defa4170-...' GUID. The DisplayName fallback - # below catches those. Running both BEFORE any wait means adopted labels - # resolve on the first pass instead of after a 45s false 'IPPS propagation' - # loop. (See Design-Notes/2026-06-30-label-taxonomy-ms-default-adoption.md.) - $tryResolve = { - $hit = Get-Label -Identity $childName -ErrorAction SilentlyContinue - if ($hit) { return $hit } - $cfgEntry = $null - if ($Config -and $Config.Labels) { - foreach ($_l in $Config.Labels) { - if ($_l.Name -eq $childName) { - $cfgEntry = @{ DisplayName = $_l.DisplayName; IsSub = $false; ParentName = $null; ParentDisplayName = $null; BuiltInName = $_l.BuiltInName } - break - } - if ($_l.SubLabels) { - foreach ($_s in $_l.SubLabels) { - if ($_s.Name -eq $childName) { - $cfgEntry = @{ DisplayName = $_s.DisplayName; IsSub = $true; ParentName = $_l.Name; ParentDisplayName = $_l.DisplayName; BuiltInName = $_s.BuiltInName } - break - } - } - if ($cfgEntry) { break } - } - } - } - if (-not $cfgEntry) { return $null } - - # Signature match (region/scheme-proof): adopt the Microsoft built-in label - # by its 'defa4170-...' Name regardless of DisplayName localization. Try the - # modern-scheme group form ('Group') first so a parent path resolves the - # label GROUP; fall back to the base id (leaves + classic parents). - if ($cfgEntry.BuiltInName) { - $sig = Get-Label -Identity ([string]$cfgEntry.BuiltInName + 'Group') -ErrorAction SilentlyContinue - if (-not $sig) { $sig = Get-Label -Identity ([string]$cfgEntry.BuiltInName) -ErrorAction SilentlyContinue } - if ($sig -and -not (($sig.PSObject.Properties.Name -contains 'Mode' -and $sig.Mode -eq 'PendingDeletion') -or - ($sig.PSObject.Properties.Name -contains 'Disabled' -and $sig.Disabled -eq $true))) { - Write-Host " Label '$Path' resolved by Name signature (defa4170) (Id: $($sig.Guid), Name: $($sig.Name)) — adopted built-in label." -ForegroundColor DarkYellow - return $sig - } - } - $tenantLabels = @(Get-Label -ErrorAction SilentlyContinue) | Where-Object { - -not (($_.PSObject.Properties.Name -contains 'Mode' -and $_.Mode -eq 'PendingDeletion') -or - ($_.PSObject.Properties.Name -contains 'Disabled' -and $_.Disabled -eq $true)) - } - $byDisplay = $null - if ($cfgEntry.IsSub) { - $parentObj = $tenantLabels | Where-Object { $_.Name -eq $cfgEntry.ParentName -and -not $_.ParentId } | Select-Object -First 1 - if (-not $parentObj) { - $parentObj = $tenantLabels | Where-Object { $_.DisplayName -eq $cfgEntry.ParentDisplayName -and -not $_.ParentId } | Select-Object -First 1 - } - if ($parentObj) { - $byDisplay = $tenantLabels | Where-Object { $_.DisplayName -eq $cfgEntry.DisplayName -and $_.ParentId -eq $parentObj.Guid } | Select-Object -First 1 - } - } else { - $byDisplay = $tenantLabels | Where-Object { $_.DisplayName -eq $cfgEntry.DisplayName -and -not $_.ParentId } | Select-Object -First 1 - } - if ($byDisplay) { - Write-Host " Label '$Path' resolved by DisplayName '$($cfgEntry.DisplayName)' (Id: $($byDisplay.Guid), Name: $($byDisplay.Name)) — adopted-existing label." -ForegroundColor DarkYellow - } - return $byDisplay - } - - $child = & $tryResolve - - # Only WAIT for IPPS propagation when the label genuinely isn't visible yet - # (e.g. a label this same apply-mode run just created). Never wait under - # -WhatIf: nothing was created, so a wait is pure dead time. - if (-not $child -and -not $WhatIfPreference) { - $maxLookupAttempts = 3 - for ($la = 1; $la -le $maxLookupAttempts; $la++) { - Write-Host (" Label '$childName' not visible yet (IPPS propagation). Waiting 15s (attempt $la/$maxLookupAttempts)...") -ForegroundColor DarkYellow - Start-Sleep -Seconds 15 - $child = & $tryResolve - if ($child) { break } - } - } - - if ($child) { - $isSoftDeleted = $false - if ($child.PSObject.Properties.Name -contains 'Mode' -and $child.Mode -eq 'PendingDeletion') { $isSoftDeleted = $true } - if ($child.PSObject.Properties.Name -contains 'Disabled' -and $child.Disabled -eq $true) { $isSoftDeleted = $true } - if ($isSoftDeleted) { - throw "Sensitivity label '$Path' is soft-deleted (Mode='$($child.Mode)') and cannot be referenced by a DLP rule. Run Setup-SensitivityLabels.ps1 first (it auto-renames tombstoned labels), or wait for the ~30-day purge." - } - return $child - } - - if ($WhatIfPreference) { - Write-Host " What if: label '$Path' does not yet exist; using a placeholder GUID for the preview (Setup-SensitivityLabels.ps1 would create it before this module runs in apply mode)." -ForegroundColor DarkYellow - return [pscustomobject]@{ - Name = $childName - DisplayName = $childName - Guid = [guid]::Empty - IsPreview = $true - } - } - - throw "Sensitivity label '$Path' not found. Run Setup-SensitivityLabels.ps1 first." -} - -foreach ($cfg in $Config.DlpPolicies) { - Write-Host "DLP policy: $($cfg.Name)" -ForegroundColor Cyan - - # Purview enforces a 64-character maximum on policy/rule names. The IPPS - # backend rejects longer names with an empty/cryptic error, so we - # validate upfront to give partners a clear, actionable diagnostic. - if ($cfg.Name.Length -gt 64) { - throw "DLP policy name '$($cfg.Name)' is $($cfg.Name.Length) characters; Microsoft Purview enforces a 64-character maximum. Edit PurviewConfig.psd1 and shorten the Name field." - } - if ($cfg.RuleName.Length -gt 64) { - throw "DLP rule name '$($cfg.RuleName)' is $($cfg.RuleName.Length) characters; Microsoft Purview enforces a 64-character maximum. Edit PurviewConfig.psd1 and shorten the RuleName field." - } - - if ($BPOnly -and ($script:E5OnlyWorkloads -contains $cfg.Workload)) { - # Soft-skip (warn + continue) instead of throwing. On a Business Premium - # tenant the default PurviewConfig.psd1 still ships the Endpoint DLP - # policy; throwing would mark the entire DLP step as FAILED and skip the - # Exchange + SPO/ODB policies too. Soft-skip lets the BP-eligible - # policies deploy normally and surfaces a single clear warning for the - # workload(s) we can't create. - Write-Warning " Skipping DLP policy '$($cfg.Name)': workload '$($cfg.Workload)' requires Microsoft 365 E5 / Purview Suite, and -BPOnly is in effect (passed explicitly or auto-set by license detection). Remove this policy from PurviewConfig.psd1 to silence, or omit -BPOnly / -NoLicenseAutoDetect when the customer holds an E5 / Purview Suite SKU." - continue - } - - # ------------------------------------------------------------------- - # Resolve the label GUID(s) we will match in the DLP rule. - # Supports either LabelPaths (array, preferred) or LabelPath (single, - # backward-compat). Multiple labels are OR-matched at the rule level. - # ------------------------------------------------------------------- - $labelPathList = @() - if ($cfg.PSObject.Properties.Name -contains 'LabelPaths' -and $cfg.LabelPaths) { - $labelPathList = @($cfg.LabelPaths) - } elseif ($cfg.ContainsKey('LabelPaths') -and $cfg.LabelPaths) { - $labelPathList = @($cfg.LabelPaths) - } elseif ($cfg.LabelPath) { - $labelPathList = @($cfg.LabelPath) - } else { - throw "DLP policy '$($cfg.Name)' has no LabelPath or LabelPaths configured." - } - - $resolvedLabelGuids = @() - $resolvedLabels = @() # array of @{ Name = ...; Guid = ... } for AdvancedRule JSON - foreach ($lp in $labelPathList) { - $resolved = Resolve-LabelByPath -Path $lp - $g = $resolved.Guid.ToString() - - # ----------------------------------------------------------------- - # GUARDRAIL (do not remove) — bind labels by their live GUID only. - # A sensitivity-label operand in a DLP rule MUST be the label's live - # tenant GUID. Microsoft's ContentContainsSensitiveInformation schema - # stores that GUID in BOTH the 'name' and 'id' fields of the label - # entry — i.e. the 'name' field is the GUID, NOT the friendly/display - # name. Binding a friendly name, the config Name, or a DisplayName here - # produces a rule that persists but NEVER matches (silent failure). - # We therefore assert the resolved value is a real GUID before use, so - # any future refactor that regresses to name-binding fails loudly. - # See Skills/Purview/Setup-DLP.skill.md "Label-binding guardrail". - # ----------------------------------------------------------------- - $parsedGuid = [guid]::Empty - if (-not [guid]::TryParse($g, [ref]$parsedGuid)) { - throw "DLP label-binding guardrail: LabelPath '$lp' resolved to '$g', which is not a GUID. A DLP rule must bind labels by their live tenant GUID (resolved from the label signature/Name/DisplayName), never by a friendly name. This is a code/config regression — see Skills/Purview/Setup-DLP.skill.md 'Label-binding guardrail'." - } - if ($parsedGuid -eq [guid]::Empty -and -not $WhatIfPreference) { - throw "DLP label-binding guardrail: LabelPath '$lp' resolved to an empty GUID outside -WhatIf. Setup-SensitivityLabels.ps1 must create the label before the DLP module runs. See Skills/Purview/Setup-DLP.skill.md 'Label-binding guardrail'." - } - - $resolvedLabelGuids += $g - $resolvedLabels += @{ Name = $resolved.Name; Guid = $g } - Write-Verbose " Resolved '$lp' to GUID $g" - } - if ($resolvedLabelGuids.Count -eq 0) { - throw "DLP policy '$($cfg.Name)' resolved no labels — check LabelPaths." - } - - # ------------------------------------------------------------------- - # Simulation mode - # ------------------------------------------------------------------- - # When DlpStartInSimulation is $true (default) the policy is - # provisioned with -Mode TestWithoutNotifications ("simulation - # mode"). The UI checkbox "Turn the policy on if it's not edited - # within fifteen days of simulation" is not exposed via PowerShell; - # toggle it in the Purview portal if you want Purview to flip the - # policy to Enable automatically after 15 days. Set - # DlpStartInSimulation = $false to create the policy directly in - # Enable mode. - # ------------------------------------------------------------------- - $startInSim = $true - if ($Config.PSObject.Properties.Name -contains 'DlpStartInSimulation' -and $null -ne $Config.DlpStartInSimulation) { - $startInSim = [bool] $Config.DlpStartInSimulation - } elseif ($Config -is [hashtable] -and $Config.ContainsKey('DlpStartInSimulation')) { - $startInSim = [bool] $Config['DlpStartInSimulation'] - } - $desiredMode = if ($startInSim) { 'TestWithoutNotifications' } else { 'Enable' } - - # ------------------------------------------------------------------- - # Policy - # ------------------------------------------------------------------- - $existing = Get-DlpCompliancePolicy -Identity $cfg.Name -ErrorAction SilentlyContinue - $owned = Test-Owned -Object $existing -Tag $tag - - if ($existing -and -not $owned) { - Write-Host " DLP policy '$($cfg.Name)' exists but is not tagged as toolkit-managed. Updating in place (will be re-tagged)." -ForegroundColor DarkYellow - } - - $policyArgs = @{ - Name = $cfg.Name - Comment = "$tag $($cfg.Comment)" - } - $skipPolicy = $false - switch ($cfg.Workload) { - 'Exchange' { - $policyArgs['ExchangeLocation'] = 'All' - } - 'SharePointOneDrive' { - $policyArgs['SharePointLocation'] = 'All' - $policyArgs['OneDriveLocation'] = 'All' - } - 'Endpoint' { - $endpointCmd = Get-Command New-DlpCompliancePolicy -ErrorAction SilentlyContinue - if (-not $endpointCmd -or -not $endpointCmd.Parameters.ContainsKey('EndpointDlpLocation')) { - Write-Warning "Skipping '$($cfg.Name)' — Endpoint DLP not available in this IPPS session (required capability/parameter missing)." - $skipPolicy = $true - break - } - - # Endpoint DLP scope is the device. 'All' covers every onboarded - # device in the tenant; narrow with EndpointDlpLocationException - # if needed (not currently exposed in PurviewConfig). - $policyArgs['EndpointDlpLocation'] = 'All' - } - default { throw "Unknown DLP workload: $($cfg.Workload)" } - } - - if ($skipPolicy) { continue } - - if (-not $existing) { - if ($PSCmdlet.ShouldProcess($cfg.Name, "New-DlpCompliancePolicy (-Mode $desiredMode)")) { - try { - Invoke-WithTransientRetry -Description ("New-DlpCompliancePolicy '$($cfg.Name)'") -AlreadyExistsIsSuccess -Action { - New-DlpCompliancePolicy @policyArgs -Mode $desiredMode ` - -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null - } - if ($desiredMode -eq 'TestWithoutNotifications') { - Write-Host " + Created policy in simulation mode (Mode=TestWithoutNotifications). Toggle 'Turn the policy on if it's not edited within fifteen days of simulation' in the Purview portal to auto-enable." -ForegroundColor Green - } else { - Write-Host " + Created policy (Mode=Enable; simulation disabled by config)." -ForegroundColor Green - } - $existing = Get-DlpCompliancePolicy -Identity $cfg.Name -ErrorAction SilentlyContinue - } catch { - Write-Warning " Failed to create policy '$($cfg.Name)': $(Format-IPPSError $_)" - continue - } - } - } else { - $currentMode = $existing.Mode - $setPolicyArgs = @{ - Identity = $cfg.Name - Comment = "$tag $($cfg.Comment)" - } - # Reconcile Mode against config: - # - DlpStartInSimulation $true : force Mode = TestWithoutNotifications - # if the existing policy is in any other state (Enable / Disable / - # TestWithNotifications). The script is the source of truth. - # - DlpStartInSimulation $false : promote to Enable if the existing - # policy is still in simulation. - if ($startInSim -and $currentMode -ne 'TestWithoutNotifications') { - $setPolicyArgs['Mode'] = 'TestWithoutNotifications' - } elseif (-not $startInSim -and $currentMode -eq 'TestWithoutNotifications') { - $setPolicyArgs['Mode'] = 'Enable' - } - - if ($PSCmdlet.ShouldProcess($cfg.Name, 'Set-DlpCompliancePolicy (refresh)')) { - try { - Invoke-WithTransientRetry -Description ("Set-DlpCompliancePolicy '$($cfg.Name)'") -Action { - Set-DlpCompliancePolicy @setPolicyArgs ` - -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null - } - if ($setPolicyArgs.ContainsKey('Mode')) { - $newMode = $setPolicyArgs['Mode'] - Write-Host " ~ Updated policy and flipped Mode '$currentMode' -> '$newMode' (config-driven)." -ForegroundColor Green - Add-RunLogEntry -Module 'Setup-DLP' -Action 'Set-DlpCompliancePolicy (Mode flip)' -Target $cfg.Name -Status 'Updated' -Detail "Mode: $currentMode -> $newMode" - if ($newMode -eq 'TestWithoutNotifications') { - Write-Host " Toggle 'Turn the policy on if it's not edited within fifteen days of simulation' in the Purview portal to auto-enable." -ForegroundColor DarkGray - } - } else { - Write-Host " ~ Updated policy (Mode=$currentMode unchanged — already in sync with config)." -ForegroundColor Yellow - Add-RunLogEntry -Module 'Setup-DLP' -Action 'Set-DlpCompliancePolicy' -Target $cfg.Name -Status 'Updated' -Detail "Mode=$currentMode unchanged (already in sync)." - } - } catch { - Write-Warning " Failed to update policy '$($cfg.Name)': $(Format-IPPSError $_)" - } - } - } - - # ------------------------------------------------------------------- - # Rule - # ------------------------------------------------------------------- - # Build OR-matched label list for the rule. Within a single group, - # operator='Or' means "any of these labels triggers the rule". - # GUARDRAIL: 'name' MUST be the label GUID (Microsoft's - # ContentContainsSensitiveInformation schema). It is NOT the display or - # friendly name — the backend echoes the same GUID into an 'id' field on - # persist, which is why a stored rule shows name==id==GUID. Do NOT "fix" - # this to a human-readable name; that silently breaks enforcement. - # See Skills/Purview/Setup-DLP.skill.md "Label-binding guardrail". - $labelMatches = @() - foreach ($g in $resolvedLabelGuids) { - $labelMatches += @{ name = $g; type = 'Sensitivity' } - } - $contentMatch = @{ - operator = 'And' - groups = @( - @{ - operator = 'Or' - name = 'Default' - labels = $labelMatches - } - ) - } - - $existingRule = Get-DlpComplianceRule -Identity $cfg.RuleName -ErrorAction SilentlyContinue - $ruleOwned = Test-Owned -Object $existingRule -Tag $tag - - if ($existingRule -and -not $ruleOwned) { - Write-Host " DLP rule '$($cfg.RuleName)' exists but is not tagged as toolkit-managed. Updating in place (will be re-tagged)." -ForegroundColor DarkYellow - } - - if ($cfg.Workload -eq 'Endpoint') { - # --------------------------------------------------------------- - # Endpoint DLP rule. - # The Purview portal stores Endpoint sensitivity-label conditions - # as -AdvancedRule JSON (NOT -ContentContainsSensitiveInformation). - # The schema below mirrors what the portal emits and what the - # IPPS backend persists in `AdvancedRule` on the rule object. - # --------------------------------------------------------------- - $advLabels = @() - foreach ($lbl in $resolvedLabels) { - # GUARDRAIL: Id is the live tenant GUID and is what enforcement keys - # off. Name carries the label's internal Name for portal readability - # only — never rely on it for matching. See the labels module and - # Skills/Purview/Setup-DLP.skill.md "Label-binding guardrail". - $advLabels += [ordered]@{ - Name = $lbl.Name - Id = $lbl.Guid - Type = 'Sensitivity' - } - } - $advancedRule = [ordered]@{ - Version = '1.0' - Condition = [ordered]@{ - Operator = 'And' - SubConditions = @( - [ordered]@{ - ConditionName = 'ContentContainsSensitiveInformation' - Value = @( - [ordered]@{ - Groups = @( - [ordered]@{ - Name = 'Default' - Operator = 'Or' - Labels = $advLabels - } - ) - Operator = 'And' - } - ) - } - ) - } - } - $advancedRuleJson = $advancedRule | ConvertTo-Json -Depth 20 - - # Endpoint device-action restrictions: pass-through from config. - # Microsoft's schema requires keys 'Setting' and 'Value' (case- - # sensitive). We accept either 'Value' (correct) or 'Action' (a - # common mistake) and normalise to 'Value' before sending. - $endpointRestrictions = @() - if ($cfg.EndpointDlpRestrictions) { - foreach ($r in $cfg.EndpointDlpRestrictions) { - $setting = $null - $value = $null - if ($r -is [hashtable]) { - if ($r.ContainsKey('Setting')) { $setting = $r['Setting'] } - if ($r.ContainsKey('Value')) { $value = $r['Value'] } - elseif ($r.ContainsKey('Action')) { $value = $r['Action'] } - } else { - if ($r.PSObject.Properties.Name -contains 'Setting') { $setting = $r.Setting } - if ($r.PSObject.Properties.Name -contains 'Value') { $value = $r.Value } - elseif ($r.PSObject.Properties.Name -contains 'Action') { $value = $r.Action } - } - if (-not $setting -or -not $value) { - Write-Warning " EndpointDlpRestrictions entry missing Setting/Value — skipping: $($r | ConvertTo-Json -Compress -Depth 4)" - continue - } - $endpointRestrictions += @{ Setting = [string]$setting; Value = [string]$value } - } - } - - $ruleArgs = @{ - Name = $cfg.RuleName - Policy = $cfg.Name - Comment = "$tag DLP rule for Endpoint." - AdvancedRule = $advancedRuleJson - EndpointDlpRestrictions = $endpointRestrictions - } - if ($cfg.PSObject.Properties.Name -contains 'EnforcePortalAccess' -and $null -ne $cfg.EnforcePortalAccess) { - $ruleArgs['EnforcePortalAccess'] = [bool] $cfg.EnforcePortalAccess - } elseif ($cfg -is [hashtable] -and $cfg.ContainsKey('EnforcePortalAccess')) { - $ruleArgs['EnforcePortalAccess'] = [bool] $cfg['EnforcePortalAccess'] - } - if ($cfg.ReportSeverityLevel) { $ruleArgs['ReportSeverityLevel'] = $cfg.ReportSeverityLevel } - if ($null -ne $cfg.GenerateAlert) { $ruleArgs['GenerateAlert'] = [bool] $cfg.GenerateAlert } - if ($cfg.NotifyUser) { $ruleArgs['NotifyUser'] = $cfg.NotifyUser } - if ($cfg.GenerateIncidentReport) { $ruleArgs['GenerateIncidentReport'] = $cfg.GenerateIncidentReport } - } else { - $ruleArgs = @{ - Name = $cfg.RuleName - Policy = $cfg.Name - Comment = "$tag DLP rule for $($cfg.Workload)." - ContentContainsSensitiveInformation = $contentMatch - BlockAccess = $cfg.BlockAccess - BlockAccessScope = $cfg.BlockAccessScope - NotifyUser = $cfg.NotifyUser - GenerateIncidentReport = $cfg.GenerateIncidentReport - } - - # Workload-specific "external recipient / external sharing" condition - if ($cfg.Workload -eq 'Exchange') { - # Block only when sent OUTSIDE the organisation - $ruleArgs['AccessScope'] = 'NotInOrganization' - } elseif ($cfg.Workload -eq 'SharePointOneDrive') { - # Block external (anonymous + guest) access to the labelled file. - # BlockAccessScope is taken from config ('All' | 'PerUser' | 'PerAnonymousUser'). - $ruleArgs['AccessScope'] = 'NotInOrganization' - } - } - - if (-not $existingRule) { - if ($PSCmdlet.ShouldProcess($cfg.RuleName, 'New-DlpComplianceRule')) { - try { - Invoke-WithTransientRetry -Description ("New-DlpComplianceRule '$($cfg.RuleName)'") -AlreadyExistsIsSuccess -Action { - New-DlpComplianceRule @ruleArgs ` - -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null - } - Write-Host " + Created rule." -ForegroundColor Green - } catch { - Write-Warning " Failed to create rule '$($cfg.RuleName)': $(Format-IPPSError $_)" - } - } - } else { - if ($existingRule.ParentPolicyName -ne $cfg.Name) { - Write-Warning "Rule '$($cfg.RuleName)' belongs to policy '$($existingRule.ParentPolicyName)', not '$($cfg.Name)'. Skipping update — DLP rules cannot be moved between policies." - continue - } - if ($PSCmdlet.ShouldProcess($cfg.RuleName, 'Set-DlpComplianceRule (refresh conditions/actions)')) { - if ($cfg.Workload -eq 'Endpoint') { - $setArgs = @{ - Identity = $cfg.RuleName - Comment = $ruleArgs.Comment - AdvancedRule = $ruleArgs.AdvancedRule - EndpointDlpRestrictions = $ruleArgs.EndpointDlpRestrictions - } - foreach ($k in 'EnforcePortalAccess','ReportSeverityLevel','GenerateAlert','NotifyUser','GenerateIncidentReport') { - if ($ruleArgs.ContainsKey($k)) { $setArgs[$k] = $ruleArgs[$k] } - } - } else { - $setArgs = @{ - Identity = $cfg.RuleName - Comment = $ruleArgs.Comment - ContentContainsSensitiveInformation = $contentMatch - BlockAccess = $cfg.BlockAccess - BlockAccessScope = $ruleArgs.BlockAccessScope - NotifyUser = $cfg.NotifyUser - GenerateIncidentReport = $cfg.GenerateIncidentReport - AccessScope = $ruleArgs.AccessScope - } - } - try { - Invoke-WithTransientRetry -Description ("Set-DlpComplianceRule '$($cfg.RuleName)'") -Action { - Set-DlpComplianceRule @setArgs ` - -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null - } - Write-Host " ~ Updated rule." -ForegroundColor Yellow - } catch { - Write-Warning " Failed to update rule '$($cfg.RuleName)': $(Format-IPPSError $_)" - } - } - } -} - -Write-Host "DLP policies complete." -ForegroundColor Green -Write-Host "NOTE: DLP policy changes can take up to an hour to begin enforcement." -ForegroundColor DarkYellow +#requires -Version 7.0 +<# +.SYNOPSIS + Creates Microsoft Purview DLP policies that block external sharing of + content labelled "Confidential\AllEmployees". + +.DESCRIPTION + Implements Priority 1 DLP from the Microsoft 365 Business Premium Data + Security Best Practice Deployment guide. Per Microsoft guidance, separate + DLP policies are created per workload: + + * Exchange policy — blocks send to external recipients + * SharePoint + OneDrive — blocks external sharing/access + + Both policies match content via the SENSITIVITY LABEL GUID (resolved at + runtime) — never by display name — so they cannot accidentally collide + with other labels of the same name. + + Idempotent: existing toolkit-managed policies and rules are updated in + place; foreign objects abort the run unless -AdoptExisting is supplied. + +.PARAMETER Config + Hashtable from PurviewConfig.psd1. + +.PARAMETER AdoptExisting + Update DLP policies / rules that already exist but are not managed by + this toolkit. + +.PARAMETER BPOnly + Reject DLP workloads that require Microsoft 365 E5 / Purview Suite + licensing (Endpoint DLP / Devices, Defender for Cloud Apps, + OnPremisesScanner, Power BI). Used by partners deploying against + Microsoft 365 Business Premium tenants. +#> +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +param( + [Parameter(Mandatory)] + [hashtable] $Config, + + [Parameter()] + [switch] $AdoptExisting, + + [Parameter()] + [switch] $BPOnly +) + +$ErrorActionPreference = 'Stop' +# Auto-confirm: this toolkit is designed for unattended/scripted runs. Use -WhatIf for dry-run. +$ConfirmPreference = 'None' + +# Shared retry helper for transient IPPS / Graph errors (502, 503, 504, 429, timeouts). +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') +. (Join-Path $PSScriptRoot 'PurviewConfigurationContract.ps1') + +Assert-PurviewLabelIdentityConfiguration -Config $Config + +# Extract a meaningful error message from an IPPS ErrorRecord. IPPS REST cmdlets +# sometimes leave Exception.Message empty and only populate ErrorDetails or +# FullyQualifiedErrorId, so we walk through several properties in priority order. +function Format-IPPSError { + param([Parameter(Mandatory)] $ErrorRecord) + if (-not $ErrorRecord) { return '(no error record)' } + $candidates = @( + $ErrorRecord.Exception.Message, + $ErrorRecord.ErrorDetails.Message, + $ErrorRecord.CategoryInfo.Reason, + $ErrorRecord.FullyQualifiedErrorId, + $ErrorRecord.ToString() + ) + foreach ($c in $candidates) { + if ($c -and $c.ToString().Trim().Length -gt 0) { return $c.ToString().Trim() } + } + return '(IPPS returned an empty error - check Audit logs in Purview portal)' +} +$tag = $Config.ManagedByTag + +# Workloads that require Microsoft 365 E5 / Purview Suite licensing. +# When -BPOnly is passed, any DLP policy referencing one of these is rejected. +$script:E5OnlyWorkloads = @( + 'Endpoint','Devices','EndpointDevices','EndpointDlp', + 'OnPremisesScanner','OnPremises','OnPremScanner', + 'ThirdPartyApps','DefenderForCloudApps','MCAS', + 'PowerBI' +) + +function Test-Owned { + param($Object, [string] $Tag) + if (-not $Object) { return $false } + $desc = if ($Object.PSObject.Properties.Name -contains 'Comment') { $Object.Comment } + elseif ($Object.PSObject.Properties.Name -contains 'Description') { $Object.Description } + else { '' } + return ($desc -and $desc -like "*$Tag*") +} + +function Resolve-LabelByPath { + <# + LabelPath is "Parent/Child" by Name. Sub-label names are unique so the + child Name is sufficient for Get-Label, but we validate the parent to + catch typos. + + In -WhatIf mode against a clean tenant the label may not yet exist + because Setup-SensitivityLabels.ps1 hasn't actually created it. + Return a synthetic preview object so the rest of the module can + produce a complete WhatIf preview. + + Soft-delete handling: Get-Label returns soft-deleted labels + (Mode='PendingDeletion') for ~30 days after Remove-Label. Their + GUIDs are tombstoned and the IPPS server will reject any DLP rule + that references them with InvalidLabelParameterNameValueException. + + When Setup-SensitivityLabels.ps1 ran first in this same deploy, it + auto-renamed any tombstoned configured labels to '-vN' and + published a remap on $Config.LabelNameRemap. We apply that remap to + each segment of LabelPath so 'Confidential/AllEmployees' becomes + e.g. 'Confidential-v2/AllEmployees-v2' transparently. If a segment + is still tombstoned after the remap (no entry was added — e.g. the + labels module didn't run), we throw a clear error. + #> + param([string] $Path) + $remap = $null + if ($Config -and $Config.PSObject.Properties.Name -contains 'LabelNameRemap') { + $remap = $Config.LabelNameRemap + } elseif ($Config -is [hashtable] -and $Config.ContainsKey('LabelNameRemap')) { + $remap = $Config['LabelNameRemap'] + } + + $parts = $Path -split '/' + if ($remap -and $remap.Count -gt 0) { + $remappedParts = @() + $changed = $false + foreach ($seg in $parts) { + if ($remap.ContainsKey($seg)) { + $remappedParts += $remap[$seg] + $changed = $true + } else { + $remappedParts += $seg + } + } + if ($changed) { + $newPath = $remappedParts -join '/' + Write-Host " Label path '$Path' remapped to '$newPath' (soft-deleted tombstone bypass)." -ForegroundColor DarkYellow + $parts = $remappedParts + $Path = $newPath + } + } + + $childName = $parts[-1] + + # Resolve WITHOUT sleeping first. Get-Label -Identity only matches + # Name/ImmutableId/Guid, so a Path that uses our configured internal Name + # MISSES adopted labels (Microsoft-default / wizard-created / modern-scheme) + # whose internal Name is a 'defa4170-...' GUID. The DisplayName fallback + # below catches those. Running both BEFORE any wait means adopted labels + # resolve on the first pass instead of after a 45s false 'IPPS propagation' + # loop. (See Design-Notes/2026-06-30-label-taxonomy-ms-default-adoption.md.) + $tryResolve = { + $hit = Get-Label -Identity $childName -ErrorAction SilentlyContinue + if ($hit) { return $hit } + $cfgEntry = $null + if ($Config -and $Config.Labels) { + foreach ($_l in $Config.Labels) { + if ($_l.Name -eq $childName) { + $cfgEntry = @{ DisplayName = $_l.DisplayName; IsSub = $false; ParentName = $null; ParentDisplayName = $null; BuiltInName = $_l.BuiltInName } + break + } + if ($_l.SubLabels) { + foreach ($_s in $_l.SubLabels) { + if ($_s.Name -eq $childName) { + $cfgEntry = @{ DisplayName = $_s.DisplayName; IsSub = $true; ParentName = $_l.Name; ParentDisplayName = $_l.DisplayName; BuiltInName = $_s.BuiltInName } + break + } + } + if ($cfgEntry) { break } + } + } + } + if (-not $cfgEntry) { return $null } + + # Signature match (region/scheme-proof): adopt the Microsoft built-in label + # by its 'defa4170-...' Name regardless of DisplayName localization. Try the + # modern-scheme group form ('Group') first so a parent path resolves the + # label GROUP; fall back to the base id (leaves + classic parents). + if ($cfgEntry.BuiltInName) { + $sig = Get-Label -Identity ([string]$cfgEntry.BuiltInName + 'Group') -ErrorAction SilentlyContinue + if (-not $sig) { $sig = Get-Label -Identity ([string]$cfgEntry.BuiltInName) -ErrorAction SilentlyContinue } + if ($sig -and -not (($sig.PSObject.Properties.Name -contains 'Mode' -and $sig.Mode -eq 'PendingDeletion') -or + ($sig.PSObject.Properties.Name -contains 'Disabled' -and $sig.Disabled -eq $true))) { + Write-Host " Label '$Path' resolved by Name signature (defa4170) (Id: $($sig.Guid), Name: $($sig.Name)) — adopted built-in label." -ForegroundColor DarkYellow + return $sig + } + } + $tenantLabels = @(Get-Label -ErrorAction SilentlyContinue) | Where-Object { + -not (($_.PSObject.Properties.Name -contains 'Mode' -and $_.Mode -eq 'PendingDeletion') -or + ($_.PSObject.Properties.Name -contains 'Disabled' -and $_.Disabled -eq $true)) + } + $byDisplay = $null + if ($cfgEntry.IsSub) { + $parentObj = $tenantLabels | Where-Object { $_.Name -eq $cfgEntry.ParentName -and -not $_.ParentId } | Select-Object -First 1 + if (-not $parentObj) { + $parentObj = $tenantLabels | Where-Object { $_.DisplayName -eq $cfgEntry.ParentDisplayName -and -not $_.ParentId } | Select-Object -First 1 + } + if ($parentObj) { + $byDisplay = $tenantLabels | Where-Object { $_.DisplayName -eq $cfgEntry.DisplayName -and $_.ParentId -eq $parentObj.Guid } | Select-Object -First 1 + } + } else { + $byDisplay = $tenantLabels | Where-Object { $_.DisplayName -eq $cfgEntry.DisplayName -and -not $_.ParentId } | Select-Object -First 1 + } + if ($byDisplay) { + Write-Host " Label '$Path' resolved by DisplayName '$($cfgEntry.DisplayName)' (Id: $($byDisplay.Guid), Name: $($byDisplay.Name)) — adopted-existing label." -ForegroundColor DarkYellow + } + return $byDisplay + } + + $child = & $tryResolve + + # Only WAIT for IPPS propagation when the label genuinely isn't visible yet + # (e.g. a label this same apply-mode run just created). Never wait under + # -WhatIf: nothing was created, so a wait is pure dead time. + if (-not $child -and -not $WhatIfPreference) { + $maxLookupAttempts = 3 + for ($la = 1; $la -le $maxLookupAttempts; $la++) { + Write-Host (" Label '$childName' not visible yet (IPPS propagation). Waiting 15s (attempt $la/$maxLookupAttempts)...") -ForegroundColor DarkYellow + Start-Sleep -Seconds 15 + $child = & $tryResolve + if ($child) { break } + } + } + + if ($child) { + $isSoftDeleted = $false + if ($child.PSObject.Properties.Name -contains 'Mode' -and $child.Mode -eq 'PendingDeletion') { $isSoftDeleted = $true } + if ($child.PSObject.Properties.Name -contains 'Disabled' -and $child.Disabled -eq $true) { $isSoftDeleted = $true } + if ($isSoftDeleted) { + throw "Sensitivity label '$Path' is soft-deleted (Mode='$($child.Mode)') and cannot be referenced by a DLP rule. Run Setup-SensitivityLabels.ps1 first (it auto-renames tombstoned labels), or wait for the ~30-day purge." + } + return $child + } + + if ($WhatIfPreference) { + Write-Host " What if: label '$Path' does not yet exist; using a placeholder GUID for the preview (Setup-SensitivityLabels.ps1 would create it before this module runs in apply mode)." -ForegroundColor DarkYellow + return [pscustomobject]@{ + Name = $childName + DisplayName = $childName + Guid = [guid]::Empty + IsPreview = $true + } + } + + throw "Sensitivity label '$Path' not found. Run Setup-SensitivityLabels.ps1 first." +} + +foreach ($cfg in $Config.DlpPolicies) { + Write-Host "DLP policy: $($cfg.Name)" -ForegroundColor Cyan + + $supportedWorkloads = @('Exchange', 'SharePointOneDrive', 'Endpoint') + if ([string]$cfg.Workload -notin $supportedWorkloads) { + Write-Warning "Skipping '$($cfg.Name)' because workload '$($cfg.Workload)' is not supported by Setup-DLP." + Add-RunLogEntry -Module 'Setup-DLP' -Action 'Validate DLP workload' ` + -Target $cfg.Name -Status 'Skipped' ` + -Detail "Unsupported workload '$($cfg.Workload)'. Supported workloads: Exchange, SharePointOneDrive, Endpoint." + continue + } + + # Purview enforces a 64-character maximum on policy/rule names. The IPPS + # backend rejects longer names with an empty/cryptic error, so we + # validate upfront to give partners a clear, actionable diagnostic. + if ($cfg.Name.Length -gt 64) { + throw "DLP policy name '$($cfg.Name)' is $($cfg.Name.Length) characters; Microsoft Purview enforces a 64-character maximum. Edit PurviewConfig.psd1 and shorten the Name field." + } + if ($cfg.RuleName.Length -gt 64) { + throw "DLP rule name '$($cfg.RuleName)' is $($cfg.RuleName.Length) characters; Microsoft Purview enforces a 64-character maximum. Edit PurviewConfig.psd1 and shorten the RuleName field." + } + + if ($BPOnly -and ($script:E5OnlyWorkloads -contains $cfg.Workload)) { + # Soft-skip (warn + continue) instead of throwing. On a Business Premium + # tenant the default PurviewConfig.psd1 still ships the Endpoint DLP + # policy; throwing would mark the entire DLP step as FAILED and skip the + # Exchange + SPO/ODB policies too. Soft-skip lets the BP-eligible + # policies deploy normally and surfaces a single clear warning for the + # workload(s) we can't create. + Write-Warning " Skipping DLP policy '$($cfg.Name)': workload '$($cfg.Workload)' requires Microsoft 365 E5 / Purview Suite, and -BPOnly is in effect (passed explicitly or auto-set by license detection). Remove this policy from PurviewConfig.psd1 to silence, or omit -BPOnly / -NoLicenseAutoDetect when the customer holds an E5 / Purview Suite SKU." + Add-RunLogEntry -Module 'Setup-DLP' -Action 'Validate DLP workload' ` + -Target $cfg.Name -Status 'Skipped' ` + -Detail "Workload '$($cfg.Workload)' requires Microsoft 365 E5 / Purview Suite and -BPOnly is in effect." + continue + } + + # ------------------------------------------------------------------- + # Resolve the label GUID(s) we will match in the DLP rule. + # Supports either LabelPaths (array, preferred) or LabelPath (single, + # backward-compat). Multiple labels are OR-matched at the rule level. + # ------------------------------------------------------------------- + $labelPathList = @() + if ($cfg.PSObject.Properties.Name -contains 'LabelPaths' -and $cfg.LabelPaths) { + $labelPathList = @($cfg.LabelPaths) + } elseif ($cfg.ContainsKey('LabelPaths') -and $cfg.LabelPaths) { + $labelPathList = @($cfg.LabelPaths) + } elseif ($cfg.LabelPath) { + $labelPathList = @($cfg.LabelPath) + } else { + throw "DLP policy '$($cfg.Name)' has no LabelPath or LabelPaths configured." + } + + $resolvedLabelGuids = @() + $resolvedLabels = @() # array of @{ Name = ...; Guid = ... } for AdvancedRule JSON + foreach ($lp in $labelPathList) { + $resolved = Resolve-LabelByPath -Path $lp + $g = $resolved.Guid.ToString() + + # ----------------------------------------------------------------- + # GUARDRAIL (do not remove) — bind labels by their live GUID only. + # A sensitivity-label operand in a DLP rule MUST be the label's live + # tenant GUID. Microsoft's ContentContainsSensitiveInformation schema + # stores that GUID in BOTH the 'name' and 'id' fields of the label + # entry — i.e. the 'name' field is the GUID, NOT the friendly/display + # name. Binding a friendly name, the config Name, or a DisplayName here + # produces a rule that persists but NEVER matches (silent failure). + # We therefore assert the resolved value is a real GUID before use, so + # any future refactor that regresses to name-binding fails loudly. + # Match label operands by resolved GUID; never substitute display names. + # ----------------------------------------------------------------- + $parsedGuid = [guid]::Empty + if (-not [guid]::TryParse($g, [ref]$parsedGuid)) { + throw "DLP label-binding guardrail: LabelPath '$lp' resolved to '$g', which is not a GUID. A DLP rule must bind labels by their live tenant GUID (resolved from the label signature/Name/DisplayName), never by a friendly name. Check the label configuration and resolved tenant GUIDs before retrying." + } + if ($parsedGuid -eq [guid]::Empty -and -not $WhatIfPreference) { + throw "DLP label-binding guardrail: LabelPath '$lp' resolved to an empty GUID outside -WhatIf. Setup-SensitivityLabels.ps1 must create the label before the DLP module runs. Verify label creation and GUID resolution before retrying." + } + + $resolvedLabelGuids += $g + $resolvedLabels += @{ Name = $resolved.Name; Guid = $g } + Write-Verbose " Resolved '$lp' to GUID $g" + } + if ($resolvedLabelGuids.Count -eq 0) { + throw "DLP policy '$($cfg.Name)' resolved no labels — check LabelPaths." + } + + # ------------------------------------------------------------------- + # Simulation mode + # ------------------------------------------------------------------- + # When DlpStartInSimulation is $true (default) the policy is + # provisioned with -Mode TestWithoutNotifications ("simulation + # mode"). The UI checkbox "Turn the policy on if it's not edited + # within fifteen days of simulation" is not exposed via PowerShell; + # toggle it in the Purview portal if you want Purview to flip the + # policy to Enable automatically after 15 days. Set + # DlpStartInSimulation = $false to create the policy directly in + # Enable mode. + # ------------------------------------------------------------------- + $startInSim = $true + if ($Config.PSObject.Properties.Name -contains 'DlpStartInSimulation' -and $null -ne $Config.DlpStartInSimulation) { + $startInSim = [bool] $Config.DlpStartInSimulation + } elseif ($Config -is [hashtable] -and $Config.ContainsKey('DlpStartInSimulation')) { + $startInSim = [bool] $Config['DlpStartInSimulation'] + } + $desiredMode = if ($startInSim) { 'TestWithoutNotifications' } else { 'Enable' } + + # ------------------------------------------------------------------- + # Policy + # ------------------------------------------------------------------- + $existing = Get-DlpCompliancePolicy -Identity $cfg.Name -ErrorAction SilentlyContinue + $owned = Test-Owned -Object $existing -Tag $tag + + if ($existing -and -not $owned) { + Write-Host " DLP policy '$($cfg.Name)' exists but is not tagged as toolkit-managed. Updating in place (will be re-tagged)." -ForegroundColor DarkYellow + } + + $policyArgs = @{ + Name = $cfg.Name + Comment = "$tag $($cfg.Comment)" + } + $skipPolicy = $false + switch ($cfg.Workload) { + 'Exchange' { + $policyArgs['ExchangeLocation'] = 'All' + } + 'SharePointOneDrive' { + $policyArgs['SharePointLocation'] = 'All' + $policyArgs['OneDriveLocation'] = 'All' + } + 'Endpoint' { + $endpointCmd = Get-Command New-DlpCompliancePolicy -ErrorAction SilentlyContinue + if (-not $endpointCmd -or -not $endpointCmd.Parameters.ContainsKey('EndpointDlpLocation')) { + Write-Warning "Skipping '$($cfg.Name)' — Endpoint DLP not available in this IPPS session (required capability/parameter missing)." + $skipPolicy = $true + break + } + + # Endpoint DLP scope is the device. 'All' covers every onboarded + # device in the tenant; narrow with EndpointDlpLocationException + # if needed (not currently exposed in PurviewConfig). + $policyArgs['EndpointDlpLocation'] = 'All' + } + default { $skipPolicy = $true } + } + + if ($skipPolicy) { continue } + + if (-not $existing) { + if ($PSCmdlet.ShouldProcess($cfg.Name, "New-DlpCompliancePolicy (-Mode $desiredMode)")) { + try { + Invoke-WithTransientRetry -Description ("New-DlpCompliancePolicy '$($cfg.Name)'") -AlreadyExistsIsSuccess -Action { + New-DlpCompliancePolicy @policyArgs -Mode $desiredMode ` + -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null + } + if ($desiredMode -eq 'TestWithoutNotifications') { + Write-Host " + Created policy in simulation mode (Mode=TestWithoutNotifications). Toggle 'Turn the policy on if it's not edited within fifteen days of simulation' in the Purview portal to auto-enable." -ForegroundColor Green + } else { + Write-Host " + Created policy (Mode=Enable; simulation disabled by config)." -ForegroundColor Green + } + $existing = Get-DlpCompliancePolicy -Identity $cfg.Name -ErrorAction SilentlyContinue + } catch { + Write-Warning " Failed to create policy '$($cfg.Name)': $(Format-IPPSError $_)" + continue + } + } + } else { + $currentMode = $existing.Mode + $setPolicyArgs = @{ + Identity = $cfg.Name + Comment = "$tag $($cfg.Comment)" + } + # Reconcile Mode against config: + # - DlpStartInSimulation $true : force Mode = TestWithoutNotifications + # if the existing policy is in any other state (Enable / Disable / + # TestWithNotifications). The script is the source of truth. + # - DlpStartInSimulation $false : promote to Enable if the existing + # policy is still in simulation. + if ($startInSim -and $currentMode -ne 'TestWithoutNotifications') { + $setPolicyArgs['Mode'] = 'TestWithoutNotifications' + } elseif (-not $startInSim -and $currentMode -eq 'TestWithoutNotifications') { + $setPolicyArgs['Mode'] = 'Enable' + } + + if ($PSCmdlet.ShouldProcess($cfg.Name, 'Set-DlpCompliancePolicy (refresh)')) { + try { + Invoke-WithTransientRetry -Description ("Set-DlpCompliancePolicy '$($cfg.Name)'") -Action { + Set-DlpCompliancePolicy @setPolicyArgs ` + -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null + } + if ($setPolicyArgs.ContainsKey('Mode')) { + $newMode = $setPolicyArgs['Mode'] + Write-Host " ~ Updated policy and flipped Mode '$currentMode' -> '$newMode' (config-driven)." -ForegroundColor Green + Add-RunLogEntry -Module 'Setup-DLP' -Action 'Set-DlpCompliancePolicy (Mode flip)' -Target $cfg.Name -Status 'Updated' -Detail "Mode: $currentMode -> $newMode" + if ($newMode -eq 'TestWithoutNotifications') { + Write-Host " Toggle 'Turn the policy on if it's not edited within fifteen days of simulation' in the Purview portal to auto-enable." -ForegroundColor DarkGray + } + } else { + Write-Host " ~ Updated policy (Mode=$currentMode unchanged — already in sync with config)." -ForegroundColor Yellow + Add-RunLogEntry -Module 'Setup-DLP' -Action 'Set-DlpCompliancePolicy' -Target $cfg.Name -Status 'Updated' -Detail "Mode=$currentMode unchanged (already in sync)." + } + } catch { + Write-Warning " Failed to update policy '$($cfg.Name)': $(Format-IPPSError $_)" + } + } + } + + # ------------------------------------------------------------------- + # Rule + # ------------------------------------------------------------------- + # Build OR-matched label list for the rule. Within a single group, + # operator='Or' means "any of these labels triggers the rule". + # GUARDRAIL: 'name' MUST be the label GUID (Microsoft's + # ContentContainsSensitiveInformation schema). It is NOT the display or + # friendly name — the backend echoes the same GUID into an 'id' field on + # persist, which is why a stored rule shows name==id==GUID. Do NOT "fix" + # this to a human-readable name; that silently breaks enforcement. + # Match label operands by resolved GUID; never substitute display names. + $labelMatches = @() + foreach ($g in $resolvedLabelGuids) { + $labelMatches += @{ name = $g; type = 'Sensitivity' } + } + $contentMatch = @{ + operator = 'And' + groups = @( + @{ + operator = 'Or' + name = 'Default' + labels = $labelMatches + } + ) + } + + $existingRule = Get-DlpComplianceRule -Identity $cfg.RuleName -ErrorAction SilentlyContinue + $ruleOwned = Test-Owned -Object $existingRule -Tag $tag + + if ($existingRule -and -not $ruleOwned) { + Write-Host " DLP rule '$($cfg.RuleName)' exists but is not tagged as toolkit-managed. Updating in place (will be re-tagged)." -ForegroundColor DarkYellow + } + + if ($cfg.Workload -eq 'Endpoint') { + # --------------------------------------------------------------- + # Endpoint DLP rule. + # The Purview portal stores Endpoint sensitivity-label conditions + # as -AdvancedRule JSON (NOT -ContentContainsSensitiveInformation). + # The schema below mirrors what the portal emits and what the + # IPPS backend persists in `AdvancedRule` on the rule object. + # --------------------------------------------------------------- + $advLabels = @() + foreach ($lbl in $resolvedLabels) { + # GUARDRAIL: Id is the live tenant GUID and is what enforcement keys + # off. Name carries the label's internal Name for portal readability + # only — never rely on it for matching. See the labels module and + # preserve resolved GUID matching in this rule. + $advLabels += [ordered]@{ + Name = $lbl.Name + Id = $lbl.Guid + Type = 'Sensitivity' + } + } + $advancedRule = [ordered]@{ + Version = '1.0' + Condition = [ordered]@{ + Operator = 'And' + SubConditions = @( + [ordered]@{ + ConditionName = 'ContentContainsSensitiveInformation' + Value = @( + [ordered]@{ + Groups = @( + [ordered]@{ + Name = 'Default' + Operator = 'Or' + Labels = $advLabels + } + ) + Operator = 'And' + } + ) + } + ) + } + } + $advancedRuleJson = $advancedRule | ConvertTo-Json -Depth 20 + + # Endpoint device-action restrictions: pass-through from config. + # Microsoft's schema requires keys 'Setting' and 'Value' (case- + # sensitive). We accept either 'Value' (correct) or 'Action' (a + # common mistake) and normalise to 'Value' before sending. + $endpointRestrictions = @() + if ($cfg.EndpointDlpRestrictions) { + foreach ($r in $cfg.EndpointDlpRestrictions) { + $setting = $null + $value = $null + if ($r -is [hashtable]) { + if ($r.ContainsKey('Setting')) { $setting = $r['Setting'] } + if ($r.ContainsKey('Value')) { $value = $r['Value'] } + elseif ($r.ContainsKey('Action')) { $value = $r['Action'] } + } else { + if ($r.PSObject.Properties.Name -contains 'Setting') { $setting = $r.Setting } + if ($r.PSObject.Properties.Name -contains 'Value') { $value = $r.Value } + elseif ($r.PSObject.Properties.Name -contains 'Action') { $value = $r.Action } + } + if (-not $setting -or -not $value) { + Write-Warning " EndpointDlpRestrictions entry missing Setting/Value — skipping: $($r | ConvertTo-Json -Compress -Depth 4)" + continue + } + $endpointRestrictions += @{ Setting = [string]$setting; Value = [string]$value } + } + } + + $ruleArgs = @{ + Name = $cfg.RuleName + Policy = $cfg.Name + Comment = "$tag DLP rule for Endpoint." + AdvancedRule = $advancedRuleJson + EndpointDlpRestrictions = $endpointRestrictions + } + if ($cfg.PSObject.Properties.Name -contains 'EnforcePortalAccess' -and $null -ne $cfg.EnforcePortalAccess) { + $ruleArgs['EnforcePortalAccess'] = [bool] $cfg.EnforcePortalAccess + } elseif ($cfg -is [hashtable] -and $cfg.ContainsKey('EnforcePortalAccess')) { + $ruleArgs['EnforcePortalAccess'] = [bool] $cfg['EnforcePortalAccess'] + } + if ($cfg.ReportSeverityLevel) { $ruleArgs['ReportSeverityLevel'] = $cfg.ReportSeverityLevel } + if ($null -ne $cfg.GenerateAlert) { $ruleArgs['GenerateAlert'] = [bool] $cfg.GenerateAlert } + if ($cfg.NotifyUser) { $ruleArgs['NotifyUser'] = $cfg.NotifyUser } + if ($cfg.GenerateIncidentReport) { $ruleArgs['GenerateIncidentReport'] = $cfg.GenerateIncidentReport } + } else { + $ruleArgs = @{ + Name = $cfg.RuleName + Policy = $cfg.Name + Comment = "$tag DLP rule for $($cfg.Workload)." + ContentContainsSensitiveInformation = $contentMatch + BlockAccess = $cfg.BlockAccess + BlockAccessScope = $cfg.BlockAccessScope + NotifyUser = $cfg.NotifyUser + GenerateIncidentReport = $cfg.GenerateIncidentReport + } + + # Workload-specific "external recipient / external sharing" condition + if ($cfg.Workload -eq 'Exchange') { + # Block only when sent OUTSIDE the organisation + $ruleArgs['AccessScope'] = 'NotInOrganization' + } elseif ($cfg.Workload -eq 'SharePointOneDrive') { + # Block external (anonymous + guest) access to the labelled file. + # BlockAccessScope is taken from config ('All' | 'PerUser' | 'PerAnonymousUser'). + $ruleArgs['AccessScope'] = 'NotInOrganization' + } + } + + if (-not $existingRule) { + if ($PSCmdlet.ShouldProcess($cfg.RuleName, 'New-DlpComplianceRule')) { + try { + Invoke-WithTransientRetry -Description ("New-DlpComplianceRule '$($cfg.RuleName)'") -AlreadyExistsIsSuccess -Action { + New-DlpComplianceRule @ruleArgs ` + -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null + } + Write-Host " + Created rule." -ForegroundColor Green + } catch { + Write-Warning " Failed to create rule '$($cfg.RuleName)': $(Format-IPPSError $_)" + } + } + } else { + if ($existingRule.ParentPolicyName -ne $cfg.Name) { + Write-Warning "Rule '$($cfg.RuleName)' belongs to policy '$($existingRule.ParentPolicyName)', not '$($cfg.Name)'. Skipping update — DLP rules cannot be moved between policies." + continue + } + if ($PSCmdlet.ShouldProcess($cfg.RuleName, 'Set-DlpComplianceRule (refresh conditions/actions)')) { + if ($cfg.Workload -eq 'Endpoint') { + $setArgs = @{ + Identity = $cfg.RuleName + Comment = $ruleArgs.Comment + AdvancedRule = $ruleArgs.AdvancedRule + EndpointDlpRestrictions = $ruleArgs.EndpointDlpRestrictions + } + foreach ($k in 'EnforcePortalAccess','ReportSeverityLevel','GenerateAlert','NotifyUser','GenerateIncidentReport') { + if ($ruleArgs.ContainsKey($k)) { $setArgs[$k] = $ruleArgs[$k] } + } + } else { + $setArgs = @{ + Identity = $cfg.RuleName + Comment = $ruleArgs.Comment + ContentContainsSensitiveInformation = $contentMatch + BlockAccess = $cfg.BlockAccess + BlockAccessScope = $ruleArgs.BlockAccessScope + NotifyUser = $cfg.NotifyUser + GenerateIncidentReport = $cfg.GenerateIncidentReport + AccessScope = $ruleArgs.AccessScope + } + } + try { + Invoke-WithTransientRetry -Description ("Set-DlpComplianceRule '$($cfg.RuleName)'") -Action { + Set-DlpComplianceRule @setArgs ` + -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null + } + Write-Host " ~ Updated rule." -ForegroundColor Yellow + } catch { + Write-Warning " Failed to update rule '$($cfg.RuleName)': $(Format-IPPSError $_)" + } + } + } +} + +Write-Host "DLP policies complete." -ForegroundColor Green +Write-Host "NOTE: DLP policy changes can take up to an hour to begin enforcement." -ForegroundColor DarkYellow diff --git a/Products/Purview/Modules/Setup-Retention.ps1 b/Products/Purview/Modules/Setup-Retention.ps1 index 17cc7eb..f217032 100644 --- a/Products/Purview/Modules/Setup-Retention.ps1 +++ b/Products/Purview/Modules/Setup-Retention.ps1 @@ -1,189 +1,209 @@ -#requires -Version 7.0 -<# -.SYNOPSIS - Creates a Microsoft Purview retention policy for Exchange mailboxes - (default: 7 years, retain-then-delete). - -.DESCRIPTION - Implements Priority 2 from the Microsoft 365 Business Premium Data Security - Best Practice Deployment guide. Default scope is Exchange mailboxes; can - be widened to SharePoint and OneDrive via the config file's - Retention.Locations array. - - Idempotent: existing toolkit-managed objects are updated in place; foreign - objects abort the run unless -AdoptExisting is supplied. - -.PARAMETER Config - Hashtable from PurviewConfig.psd1. - -.PARAMETER AdoptExisting - Update retention objects that already exist but are not managed by this - toolkit. -#> -[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] -param( - [Parameter(Mandatory)] - [hashtable] $Config, - - [Parameter()] - [switch] $AdoptExisting -) - -$ErrorActionPreference = 'Stop' -# Auto-confirm: this toolkit is designed for unattended/scripted runs. Use -WhatIf for dry-run. -$ConfirmPreference = 'None' - -# Shared retry helper for transient IPPS errors (502, 503, 504, 429, timeouts). -. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') - -# Extract a meaningful error message from an IPPS ErrorRecord. IPPS REST cmdlets -# sometimes leave Exception.Message empty and only populate ErrorDetails or -# FullyQualifiedErrorId, so we walk through several properties in priority order. -function Format-IPPSError { - param([Parameter(Mandatory)] $ErrorRecord) - if (-not $ErrorRecord) { return '(no error record)' } - $candidates = @( - $ErrorRecord.Exception.Message, - $ErrorRecord.ErrorDetails.Message, - $ErrorRecord.CategoryInfo.Reason, - $ErrorRecord.FullyQualifiedErrorId, - $ErrorRecord.ToString() - ) - foreach ($c in $candidates) { - if ($c -and $c.ToString().Trim().Length -gt 0) { return $c.ToString().Trim() } - } - return '(IPPS returned an empty error - check Audit logs in Purview portal)' -} -$tag = $Config.ManagedByTag -$r = $Config.Retention - -function Test-Owned { - param($Object, [string] $Tag) - if (-not $Object) { return $false } - $desc = if ($Object.PSObject.Properties.Name -contains 'Comment') { $Object.Comment } - elseif ($Object.PSObject.Properties.Name -contains 'Description') { $Object.Description } - else { '' } - return ($desc -and $desc -like "*$Tag*") -} - -Write-Host "Retention policy: $($r.Name)" -ForegroundColor Cyan - -# Purview enforces a 64-character maximum on policy/rule names. The IPPS -# backend rejects longer names with an empty/cryptic error, so we validate -# upfront to give partners a clear, actionable diagnostic. -if ($r.Name.Length -gt 64) { - throw "Retention policy name '$($r.Name)' is $($r.Name.Length) characters; Microsoft Purview enforces a 64-character maximum. Edit PurviewConfig.psd1 and shorten the Retention.Name field." -} -if ($r.RuleName.Length -gt 64) { - throw "Retention rule name '$($r.RuleName)' is $($r.RuleName.Length) characters; Microsoft Purview enforces a 64-character maximum. Edit PurviewConfig.psd1 and shorten the Retention.RuleName field." -} - -# --------------------------------------------------------------------------- -# Policy -# --------------------------------------------------------------------------- -$existing = Get-RetentionCompliancePolicy -Identity $r.Name -ErrorAction SilentlyContinue -$owned = Test-Owned -Object $existing -Tag $tag - -if ($existing -and -not $owned -and -not $AdoptExisting) { - throw "Retention policy '$($r.Name)' exists but is not managed by this toolkit. Re-run with -AdoptExisting to update it." -} - -$policyArgs = @{ - Name = $r.Name - Comment = "$tag $($r.Comment)" -} -foreach ($loc in $r.Locations) { - switch ($loc) { - 'Exchange' { $policyArgs['ExchangeLocation'] = 'All' } - 'SharePoint' { $policyArgs['SharePointLocation'] = 'All' } - 'OneDrive' { $policyArgs['OneDriveLocation'] = 'All' } - default { Write-Warning "Unknown retention location '$loc' — skipping." } - } -} - -if (-not $existing) { - if ($PSCmdlet.ShouldProcess($r.Name, 'New-RetentionCompliancePolicy')) { - try { - Invoke-WithTransientRetry -Description ("New-RetentionCompliancePolicy '$($r.Name)'") -AlreadyExistsIsSuccess -Action { - New-RetentionCompliancePolicy @policyArgs ` - -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null - } - Write-Host " + Created policy." -ForegroundColor Green - $existing = Get-RetentionCompliancePolicy -Identity $r.Name -ErrorAction SilentlyContinue - } catch { - Write-Warning " Failed to create policy '$($r.Name)': $(Format-IPPSError $_)" - return - } - } -} else { - if ($PSCmdlet.ShouldProcess($r.Name, 'Set-RetentionCompliancePolicy (comment refresh)')) { - try { - Invoke-WithTransientRetry -Description ("Set-RetentionCompliancePolicy '$($r.Name)'") -Action { - Set-RetentionCompliancePolicy -Identity $r.Name -Comment $policyArgs.Comment ` - -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null - } - Write-Host " ~ Updated policy comment." -ForegroundColor Yellow - } catch { - Write-Warning " Failed to update policy '$($r.Name)': $(Format-IPPSError $_)" - } - } -} - -# --------------------------------------------------------------------------- -# Rule -# --------------------------------------------------------------------------- -$existingRule = Get-RetentionComplianceRule -Identity $r.RuleName -ErrorAction SilentlyContinue -$ruleOwned = Test-Owned -Object $existingRule -Tag $tag - -if ($existingRule -and -not $ruleOwned -and -not $AdoptExisting) { - throw "Retention rule '$($r.RuleName)' exists but is not managed by this toolkit. Re-run with -AdoptExisting to update it." -} - -$ruleArgs = @{ - Name = $r.RuleName - Policy = $r.Name - Comment = "$tag Retention rule" - RetentionDuration = $r.DurationDays - RetentionDurationDisplayHint = $r.DurationDisplayHint - RetentionComplianceAction = $r.Action - ExpirationDateOption = $r.ExpirationDateOption -} - -if (-not $existingRule) { - if ($PSCmdlet.ShouldProcess($r.RuleName, 'New-RetentionComplianceRule')) { - try { - Invoke-WithTransientRetry -Description ("New-RetentionComplianceRule '$($r.RuleName)'") -AlreadyExistsIsSuccess -Action { - New-RetentionComplianceRule @ruleArgs ` - -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null - } - Write-Host " + Created rule." -ForegroundColor Green - } catch { - Write-Warning " Failed to create rule '$($r.RuleName)': $(Format-IPPSError $_)" - } - } -} else { - if ($existingRule.Policy -ne $r.Name) { - Write-Warning "Rule '$($r.RuleName)' belongs to policy '$($existingRule.Policy)', not '$($r.Name)'. Skipping — retention rules cannot be moved between policies." - Add-RunLogEntry -Module 'Setup-Retention' -Action 'Set-RetentionComplianceRule' -Target $r.RuleName -Status 'Skipped' -Detail "Rule belongs to a different policy ('$($existingRule.Policy)'); cannot move." - } elseif ($PSCmdlet.ShouldProcess($r.RuleName, 'Set-RetentionComplianceRule (refresh)')) { - $setArgs = @{ - Identity = $r.RuleName - Comment = $ruleArgs.Comment - RetentionDuration = $r.DurationDays - RetentionDurationDisplayHint = $r.DurationDisplayHint - RetentionComplianceAction = $r.Action - ExpirationDateOption = $r.ExpirationDateOption - } - try { - Invoke-WithTransientRetry -Description ("Set-RetentionComplianceRule '$($r.RuleName)'") -Action { - Set-RetentionComplianceRule @setArgs ` - -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null - } - Write-Host " ~ Updated rule." -ForegroundColor Yellow - } catch { - Write-Warning " Failed to update rule '$($r.RuleName)': $(Format-IPPSError $_)" - } - } -} - -Write-Host "Retention policy complete." -ForegroundColor Green +#requires -Version 7.0 +<# +.SYNOPSIS + Creates a Microsoft Purview retention policy for Exchange mailboxes + (default: 7 years, retain-then-delete). + +.DESCRIPTION + Implements Priority 2 from the Microsoft 365 Business Premium Data Security + Best Practice Deployment guide. Default scope is Exchange mailboxes; can + be widened to SharePoint and OneDrive via the config file's + Retention.Locations array. + + Idempotent: existing toolkit-managed objects are updated in place; foreign + objects abort the run unless -AdoptExisting is supplied. + +.PARAMETER Config + Hashtable from PurviewConfig.psd1. + +.PARAMETER AdoptExisting + Update retention objects that already exist but are not managed by this + toolkit. +#> +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +param( + [Parameter(Mandatory)] + [hashtable] $Config, + + [Parameter()] + [switch] $AdoptExisting +) + +$ErrorActionPreference = 'Stop' +# Auto-confirm: this toolkit is designed for unattended/scripted runs. Use -WhatIf for dry-run. +$ConfirmPreference = 'None' + +# Shared retry helper for transient IPPS errors (502, 503, 504, 429, timeouts). +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') +. (Join-Path $PSScriptRoot 'PurviewConfigurationContract.ps1') + +# Extract a meaningful error message from an IPPS ErrorRecord. IPPS REST cmdlets +# sometimes leave Exception.Message empty and only populate ErrorDetails or +# FullyQualifiedErrorId, so we walk through several properties in priority order. +function Format-IPPSError { + param([Parameter(Mandatory)] $ErrorRecord) + if (-not $ErrorRecord) { return '(no error record)' } + $candidates = @( + $ErrorRecord.Exception.Message, + $ErrorRecord.ErrorDetails.Message, + $ErrorRecord.CategoryInfo.Reason, + $ErrorRecord.FullyQualifiedErrorId, + $ErrorRecord.ToString() + ) + foreach ($c in $candidates) { + if ($c -and $c.ToString().Trim().Length -gt 0) { return $c.ToString().Trim() } + } + return '(IPPS returned an empty error - check Audit logs in Purview portal)' +} +$tag = $Config.ManagedByTag +$r = $Config.Retention +$retentionLocations = Get-PurviewRetentionLocationClassification -Value $r.Locations +foreach ($unsupportedLocation in @($retentionLocations.UnsupportedEvidence)) { + Write-Warning "Unsupported retention location '$unsupportedLocation' - skipping." + if (Get-Command -Name 'Add-RunLogEntry' -ErrorAction SilentlyContinue) { + Add-RunLogEntry -Module 'Setup-Retention' -Action 'Validate retention location' ` + -Target $unsupportedLocation -Status 'Skipped' ` + -Detail 'The location is not supported by Setup-Retention.ps1.' + } +} +if ($retentionLocations.Supported.Count -eq 0) { + $message = ( + 'Retention.Locations contains no supported destination. ' + + 'Configure Exchange, SharePoint, or OneDrive before applying retention.' + ) + if (Get-Command -Name 'Add-RunLogEntry' -ErrorAction SilentlyContinue) { + Add-RunLogEntry -Module 'Setup-Retention' -Action 'Validate retention locations' ` + -Status 'Failed' -Detail $message + } + throw $message +} + +function Test-Owned { + param($Object, [string] $Tag) + if (-not $Object) { return $false } + $desc = if ($Object.PSObject.Properties.Name -contains 'Comment') { $Object.Comment } + elseif ($Object.PSObject.Properties.Name -contains 'Description') { $Object.Description } + else { '' } + return ($desc -and $desc -like "*$Tag*") +} + +Write-Host "Retention policy: $($r.Name)" -ForegroundColor Cyan + +# Purview enforces a 64-character maximum on policy/rule names. The IPPS +# backend rejects longer names with an empty/cryptic error, so we validate +# upfront to give partners a clear, actionable diagnostic. +if ($r.Name.Length -gt 64) { + throw "Retention policy name '$($r.Name)' is $($r.Name.Length) characters; Microsoft Purview enforces a 64-character maximum. Edit PurviewConfig.psd1 and shorten the Retention.Name field." +} +if ($r.RuleName.Length -gt 64) { + throw "Retention rule name '$($r.RuleName)' is $($r.RuleName.Length) characters; Microsoft Purview enforces a 64-character maximum. Edit PurviewConfig.psd1 and shorten the Retention.RuleName field." +} + +# --------------------------------------------------------------------------- +# Policy +# --------------------------------------------------------------------------- +$existing = Get-RetentionCompliancePolicy -Identity $r.Name -ErrorAction SilentlyContinue +$owned = Test-Owned -Object $existing -Tag $tag + +if ($existing -and -not $owned -and -not $AdoptExisting) { + throw "Retention policy '$($r.Name)' exists but is not managed by this toolkit. Re-run with -AdoptExisting to update it." +} + +$policyArgs = @{ + Name = $r.Name + Comment = "$tag $($r.Comment)" +} +foreach ($loc in $retentionLocations.Supported) { + switch ($loc) { + 'Exchange' { $policyArgs['ExchangeLocation'] = 'All' } + 'SharePoint' { $policyArgs['SharePointLocation'] = 'All' } + 'OneDrive' { $policyArgs['OneDriveLocation'] = 'All' } + } +} + +if (-not $existing) { + if ($PSCmdlet.ShouldProcess($r.Name, 'New-RetentionCompliancePolicy')) { + try { + Invoke-WithTransientRetry -Description ("New-RetentionCompliancePolicy '$($r.Name)'") -AlreadyExistsIsSuccess -Action { + New-RetentionCompliancePolicy @policyArgs ` + -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null + } + Write-Host " + Created policy." -ForegroundColor Green + $existing = Get-RetentionCompliancePolicy -Identity $r.Name -ErrorAction SilentlyContinue + } catch { + Write-Warning " Failed to create policy '$($r.Name)': $(Format-IPPSError $_)" + return + } + } +} else { + if ($PSCmdlet.ShouldProcess($r.Name, 'Set-RetentionCompliancePolicy (comment refresh)')) { + try { + Invoke-WithTransientRetry -Description ("Set-RetentionCompliancePolicy '$($r.Name)'") -Action { + Set-RetentionCompliancePolicy -Identity $r.Name -Comment $policyArgs.Comment ` + -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null + } + Write-Host " ~ Updated policy comment." -ForegroundColor Yellow + } catch { + Write-Warning " Failed to update policy '$($r.Name)': $(Format-IPPSError $_)" + } + } +} + +# --------------------------------------------------------------------------- +# Rule +# --------------------------------------------------------------------------- +$existingRule = Get-RetentionComplianceRule -Identity $r.RuleName -ErrorAction SilentlyContinue +$ruleOwned = Test-Owned -Object $existingRule -Tag $tag + +if ($existingRule -and -not $ruleOwned -and -not $AdoptExisting) { + throw "Retention rule '$($r.RuleName)' exists but is not managed by this toolkit. Re-run with -AdoptExisting to update it." +} + +$ruleArgs = @{ + Name = $r.RuleName + Policy = $r.Name + Comment = "$tag Retention rule" + RetentionDuration = $r.DurationDays + RetentionDurationDisplayHint = $r.DurationDisplayHint + RetentionComplianceAction = $r.Action + ExpirationDateOption = $r.ExpirationDateOption +} + +if (-not $existingRule) { + if ($PSCmdlet.ShouldProcess($r.RuleName, 'New-RetentionComplianceRule')) { + try { + Invoke-WithTransientRetry -Description ("New-RetentionComplianceRule '$($r.RuleName)'") -AlreadyExistsIsSuccess -Action { + New-RetentionComplianceRule @ruleArgs ` + -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null + } + Write-Host " + Created rule." -ForegroundColor Green + } catch { + Write-Warning " Failed to create rule '$($r.RuleName)': $(Format-IPPSError $_)" + } + } +} else { + if ($existingRule.Policy -ne $r.Name) { + Write-Warning "Rule '$($r.RuleName)' belongs to policy '$($existingRule.Policy)', not '$($r.Name)'. Skipping — retention rules cannot be moved between policies." + Add-RunLogEntry -Module 'Setup-Retention' -Action 'Set-RetentionComplianceRule' -Target $r.RuleName -Status 'Skipped' -Detail "Rule belongs to a different policy ('$($existingRule.Policy)'); cannot move." + } elseif ($PSCmdlet.ShouldProcess($r.RuleName, 'Set-RetentionComplianceRule (refresh)')) { + $setArgs = @{ + Identity = $r.RuleName + Comment = $ruleArgs.Comment + RetentionDuration = $r.DurationDays + RetentionDurationDisplayHint = $r.DurationDisplayHint + RetentionComplianceAction = $r.Action + ExpirationDateOption = $r.ExpirationDateOption + } + try { + Invoke-WithTransientRetry -Description ("Set-RetentionComplianceRule '$($r.RuleName)'") -Action { + Set-RetentionComplianceRule @setArgs ` + -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null + } + Write-Host " ~ Updated rule." -ForegroundColor Yellow + } catch { + Write-Warning " Failed to update rule '$($r.RuleName)': $(Format-IPPSError $_)" + } + } +} + +Write-Host "Retention policy complete." -ForegroundColor Green diff --git a/Products/Purview/Modules/Setup-SensitivityLabels.ps1 b/Products/Purview/Modules/Setup-SensitivityLabels.ps1 index 80db90e..2a480e8 100644 --- a/Products/Purview/Modules/Setup-SensitivityLabels.ps1 +++ b/Products/Purview/Modules/Setup-SensitivityLabels.ps1 @@ -1,2425 +1,2433 @@ -#requires -Version 7.0 -<# -.SYNOPSIS - Creates and publishes Microsoft Purview sensitivity labels (SMB profile). - -.DESCRIPTION - SMB-tuned subset of the Microsoft default-sensitivity-labels-policies - spec (https://learn.microsoft.com/en-us/purview/default-sensitivity-labels-policies). - Trims the full 10-label MS spec down to 8 labels that fit a typical - small/mid-business deployment, with encryption only at the top tier: - - * Public — no protection - * General — no protection (DEFAULT for email) - * Confidential — no protection (parent) - * Confidential \ All Employees — Footer "Classified as Confidential" (DEFAULT for documents) - * Confidential \ Specific People — Footer + UserDefined encryption (Outlook: Do Not Forward; prompt in Word/PPT/Excel) - * Confidential \ Internal Exception — Footer "Classified as Confidential" - * Highly Confidential — Watermark "HIGHLY CONFIDENTIAL" - * Highly Confidential \ All Employees — Footer + Template encryption (all users in your tenant only) - * Highly Confidential \ Specific People — Footer + UserDefined encryption (Outlook: Do Not Forward; prompt in Word/PPT/Excel) - * Highly Confidential \ Internal Exception — Footer + Template encryption (all users in your tenant only) - - Encryption is applied to the two Highly Confidential Template-protected - sub-labels and to both "Specific People" sub-labels (UserDefined). - Confidential \ All Employees / Internal Exception carry visual markings - (footer) but no encryption — the SMB-friendly profile (encryption on - these breaks too many third-party integrations and external - collaboration scenarios for typical SMB customers). - - Template encryption is scoped to the tenant via the `{TenantDomain}` - token in `EncryptionRightsDefinitions`. The token resolves at runtime - against the auto-discovered tenant identity (default verified domain, - or initial `*.onmicrosoft.com` domain as fallback). Per Entra - rights-management semantics, ANY verified domain expands to ALL - verified domains in the tenant, so this grants encrypted-label rights - to "all users in this tenant only" — NOT external `AuthenticatedUsers` - from other M365 tenants. To opt in to the broad scope (e.g. you - intentionally collaborate cross-tenant via the label), edit the config - to use `{AuthenticatedUsers}` or the literal `AuthenticatedUsers`. - - The default rights bundle is Microsoft's "Reviewer" set (View, Edit - Content, Save, Reply/Reply-All/Forward). If you need a wider bundle - (e.g. the "Co-Author" set adding Copy, Print, Allow Macros for Office - co-authoring or third-party tools that read doc metadata via the - Office object model), edit `EncryptionRightsDefinitions` in - PurviewConfig.psd1 directly. Validate in a pilot tenant before - promoting — OBJMODEL access is the right that most often breaks - third-party integrations. - - The label policy publishes all 8 labels with separate defaults for - documents and email: - * Default for DOCUMENTS = Confidential \ All Employees - * Default for EMAIL = General - The email default is applied via the IPPS `OutlookDefaultLabel` - advanced setting. - - Auto-labeling (client-side and service-side) is intentionally NOT - configured. Add it deliberately via the Purview portal once the team - has reviewed false-positive risk and user-impact trade-offs. - - Idempotency: - * Labels are matched by Name (not DisplayName). Existing labels managed - by this toolkit (description contains the ManagedByTag) are updated - in place. Existing labels NOT managed by this toolkit cause the script - to ABORT unless -AdoptExisting is passed, to prevent silently mutating - a customer's existing baseline. - -.PARAMETER Config - Hashtable from PurviewConfig.psd1. - -.PARAMETER AdoptExisting - Update labels and policies that already exist but were not created by this - toolkit. Use only after auditing the existing configuration. - -.PARAMETER TenantIdentity - Tenant-identity object from `Get-ActualTenantIdentity` in the driver - script (pscustomobject with `DisplayName`, `DefaultDomain`, - `InitialDomain`, `TenantId`). Used to resolve the `{TenantDomain}` - token in `EncryptionRightsDefinitions` so encrypted labels grant - rights to all users in the tenant only (not external - `AuthenticatedUsers`). If the token is present and cannot be - resolved, the script aborts the labels module with an actionable - error — fall-back to broad `AuthenticatedUsers` would silently - reintroduce the exact scope this token was added to remove. - -.PARAMETER SkipContainerLabels - When set, strip the container-scope bits (`Site`, `UnifiedGroup`) from - each label's `ContentType` before calling `New-Label` / `Set-Label`. - The labels still get the `File, Email` scope. Used by the driver - script when license auto-detect cannot identify a Microsoft 365 BP - / E5 / Purview Suite SKU, OR when the operator passes - `-SkipContainerLabels` to `Deploy-PurviewBestPractice.ps1` to opt out. - - Rationale: container scope (Groups & sites) requires the tenant-side - `Group.Unified EnableMIPLabels = $true` toggle, which is set by - `Setup-TenantSettings.ps1` step [5/5]. When that step is skipped, the - label's container bits would have no effect — so we drop them here too - and log a one-line info message per affected label. This keeps the - Purview portal `Edit sensitivity label -> Scope` page honest about - what the toolkit actually provisioned. - -.NOTES - Label and policy changes can take up to 24 hours to fully propagate to - all users and clients. -#> -[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] -param( - [Parameter(Mandatory)] - [hashtable] $Config, - - [Parameter()] - [switch] $AdoptExisting, - - # [object] (not [hashtable]) so we accept both the pscustomobject from - # Get-ActualTenantIdentity AND a plain hashtable from a partner-driver - # wrapper. Property access via `.DefaultDomain` etc. works for both. - [Parameter()] - [object] $TenantIdentity, - - [Parameter()] - [switch] $SkipContainerLabels -) - -$ErrorActionPreference = 'Stop' -# Auto-confirm: this toolkit is designed for unattended/scripted runs. Use -WhatIf for dry-run. -$ConfirmPreference = 'None' - -# Shared retry helper for transient IPPS errors (502, 503, 504, 429, timeouts). -. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') - -# Extract a meaningful error message from an IPPS ErrorRecord. IPPS REST cmdlets -# sometimes leave Exception.Message empty and only populate ErrorDetails or -# FullyQualifiedErrorId, so we walk through several properties in priority order. -function Format-IPPSError { - param([Parameter(Mandatory)] $ErrorRecord) - if (-not $ErrorRecord) { return '(no error record)' } - $candidates = @( - $ErrorRecord.Exception.Message, - $ErrorRecord.ErrorDetails.Message, - $ErrorRecord.CategoryInfo.Reason, - $ErrorRecord.FullyQualifiedErrorId, - $ErrorRecord.ToString() - ) - foreach ($c in $candidates) { - if ($c -and $c.ToString().Trim().Length -gt 0) { return $c.ToString().Trim() } - } - return '(IPPS returned an empty error - check Audit logs in Purview portal)' -} - -$tag = $Config.ManagedByTag -# Encryption rights bundle. Defined once in PurviewConfig.psd1 as -# `EncryptionRightsDefinitions`. Default is Microsoft's "Reviewer" set -# (View/Edit/Save/Reply/ReplyAll/Forward). Partners who need a wider -# bundle (e.g. Copy, Print, OBJMODEL for third-party integrations) edit -# the config string directly. OBJMODEL is the right that most often -# breaks third-party apps reading Office doc metadata, so the default -# is conservative. -# -# Token resolution (case-insensitive): -# * `{TenantDomain}` -> $TenantIdentity.DefaultDomain (preferred) -# or .InitialDomain (fallback). Per Entra -# rights-management semantics, ANY verified -# domain in the tenant expands to ALL -# verified domains in that tenant, so this -# scopes the rights bundle to "all users in -# this tenant only" (not external -# authenticated users from other M365 -# tenants). -# If neither domain is available, the -# module ABORTS with an actionable error. -# Silent fall-back to `AuthenticatedUsers` -# would reintroduce the exact broad scope -# this token was added to remove — partners -# who want broad scope must opt-in via -# `{AuthenticatedUsers}` (or the literal -# `AuthenticatedUsers`) in config. -# * `{AuthenticatedUsers}` -> the literal `AuthenticatedUsers` keyword, -# for explicit broad scope. -# * Any other identity (email / `AuthenticatedUsers` already in the -# string) passes through unchanged. -# -# A label may also override the global default by setting its own -# `EncryptionRightsDefinitions` field — same token semantics apply. -# Used to grant the wider Microsoft "Co-Author" rights bundle -# (adds EXTRACT, PRINT, OBJMODEL) to specific labels like -# `Highly Confidential\All Employees` without affecting siblings. -function Resolve-EncryptionRightsTokens { - [CmdletBinding()] - param( - [Parameter(Mandatory)] [string] $RightsDefinitions, - [object] $TenantIdentity, - [string] $Source = 'EncryptionRightsDefinitions' - ) - $resolved = [string] $RightsDefinitions - if ($resolved -match '\{TenantDomain\}') { - $tenantDom = $null - if ($TenantIdentity) { - # Works for both pscustomobject and hashtable shapes. - if ($TenantIdentity.DefaultDomain) { $tenantDom = [string] $TenantIdentity.DefaultDomain } - elseif ($TenantIdentity.InitialDomain) { $tenantDom = [string] $TenantIdentity.InitialDomain } - } - if ($tenantDom) { - $resolved = $resolved -replace '\{TenantDomain\}', $tenantDom - } else { - $msg = "Cannot resolve '{TenantDomain}' token in $Source : TenantIdentity was not supplied or has no DefaultDomain/InitialDomain. " + - "Refusing to fall back to 'AuthenticatedUsers' because that would silently grant encrypted-label access to ALL Microsoft 365 tenants (the exact scope this token was added to remove). " + - "Remediation: re-run via Deploy-PurviewBestPractice.ps1 so tenant identity is auto-discovered, OR (if you intentionally want broad scope) replace '{TenantDomain}' with '{AuthenticatedUsers}' in the affected config field." - if (Get-Command Add-RunLogEntry -ErrorAction SilentlyContinue) { - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action "Resolve {TenantDomain} ($Source)" ` - -Target '(unresolved)' -Status 'Failed' -Detail $msg - } - throw $msg - } - } - if ($resolved -match '\{AuthenticatedUsers\}') { - $resolved = $resolved -replace '\{AuthenticatedUsers\}', 'AuthenticatedUsers' - } - return $resolved -} - -$rights = Resolve-EncryptionRightsTokens -RightsDefinitions $Config.EncryptionRightsDefinitions ` - -TenantIdentity $TenantIdentity ` - -Source 'Config.EncryptionRightsDefinitions (global default)' -if ($Config.EncryptionRightsDefinitions -match '\{TenantDomain\}') { - $tenantDom = if ($TenantIdentity.DefaultDomain) { $TenantIdentity.DefaultDomain } else { $TenantIdentity.InitialDomain } - Write-Host "Encryption rights bundle scoped to tenant domain '$tenantDom' (all users in this tenant only)." -ForegroundColor DarkGray - if (Get-Command Add-RunLogEntry -ErrorAction SilentlyContinue) { - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Resolve {TenantDomain}' ` - -Target $tenantDom -Status 'Info' ` - -Detail "Resolved {TenantDomain} token in EncryptionRightsDefinitions (global default) to '$tenantDom' (all users in this tenant only; excludes external AuthenticatedUsers)." - } -} -$offlineDays = $Config.EncryptionOfflineAccessDays - -# Cache the full label set once so we can match by DisplayName too. The IPPS -# Get-Label -Identity switch only resolves Name/ImmutableId/Guid -- not -# DisplayName -- so a tenant with a template label named "Personal" (a -# different internal Guid) would otherwise look "missing" to us and we'd -# call New-Label, which then fails the display-name uniqueness rule. -$allLabelsRaw = @(Get-Label -ErrorAction SilentlyContinue) - -# --------------------------------------------------------------------------- -# Soft-delete tombstone detection + auto-rename (internal Name only). -# -# Remove-Label SOFT-deletes labels: they stay in Get-Label results with -# Mode='PendingDeletion' for ~30 days. The internal NAME is reserved (New-Label -# with the same Name fails) but the DISPLAYNAME is NOT reserved -- you can create -# a new label with the same DisplayName while the old one is soft-deleted -# (verified 2026-07-01). Without this check the deploy silently ran Set-Label -# against tombstones, every cmdlet failed, and the summary still reported OK. -# -# When a tombstone blocks a create-Name, we vary ONLY the internal Name for this -# run (append the next free '-vN') and KEEP the user-visible DisplayName unchanged -# -- no ' v2' shown to users. With defa4170-create the internal Name is the -# BuiltInName signature, so the varied Name is invisible anyway. The mutation is -# IN-MEMORY ONLY -- PurviewConfig.psd1 on disk is not changed. For any CUSTOM -# label (no BuiltInName) whose config Name is varied, a remap is stashed on -# $Config.LabelNameRemap so Setup-DLP.ps1 can rewrite LabelPath references; -# built-in labels resolve via the BuiltInName signature and need no remap. -# --------------------------------------------------------------------------- -function Test-LabelSoftDeleted { - param($Label) - if (-not $Label) { return $false } - if ($Label.PSObject.Properties.Name -contains 'Mode' -and $Label.Mode -eq 'PendingDeletion') { return $true } - if ($Label.PSObject.Properties.Name -contains 'Disabled' -and $Label.Disabled -eq $true) { return $true } - return $false -} - -function Get-FreeVersionedName { - <# - Find the lowest N >= 2 such that the internal Name slot 'BaseName-vN' is - not held by a TOMBSTONED label. ONLY the Name is checked — a soft-deleted - label does NOT reserve its DisplayName (verified: you can create a new label - with the same DisplayName while the old one is in PendingDeletion), so the - user-visible DisplayName is always kept unchanged. - - An existing ACTIVE label at the candidate slot is fine: the downstream - label-creation flow will discover it via Get-Label, run Test-Owned, and - either update it in place (toolkit-managed) or abort (foreign). We only skip - slots blocked by a soft-delete tombstone. - #> - param( - [Parameter(Mandatory)] [string] $BaseName, - [Parameter(Mandatory)] [array] $AllLabels - ) - for ($n = 2; $n -le 99; $n++) { - $candName = "$BaseName-v$n" - $clash = $AllLabels | Where-Object { $_.Name -eq $candName } | Select-Object -First 1 - if (-not ($clash -and (Test-LabelSoftDeleted $clash))) { - return $candName - } - } - throw "Could not find a free '-vN' suffix for label Name '$BaseName' after 99 attempts. Edit PurviewConfig.psd1 manually." -} - -# The internal Name each configured label is CREATED with: the Microsoft -# 'defa4170-...' BuiltInName when present (so blank tenants match Microsoft), -# else the config Name. Tombstone detection/rename keys off this. -function Get-LabelCreateName { - param($LabelDef) - if ($LabelDef.BuiltInName) { return [string]$LabelDef.BuiltInName } - return [string]$LabelDef.Name -} - -# Build the list of config-defined names we will manage (parents + sub-labels). -$configuredLabelNames = @() -foreach ($_lbl in $Config.Labels) { - $configuredLabelNames += $_lbl.Name - foreach ($_sub in @($_lbl.SubLabels)) { - if ($_sub) { $configuredLabelNames += $_sub.Name } - } -} - -# The internal create-Names we manage (BuiltInName when set, else config Name) — -# this is what a tombstone actually blocks, since we CREATE with these. -$managedCreateNames = @() -foreach ($_lbl in $Config.Labels) { - $managedCreateNames += (Get-LabelCreateName $_lbl) - foreach ($_sub in @($_lbl.SubLabels)) { if ($_sub) { $managedCreateNames += (Get-LabelCreateName $_sub) } } -} - -# Find tombstoned labels whose Name matches a create-Name we manage. -$tombstoned = @($allLabelsRaw | Where-Object { - $managedCreateNames -contains $_.Name -and (Test-LabelSoftDeleted $_) -}) - -$labelNameRemap = @{} -if ($tombstoned.Count -gt 0) { - $tombstonedNames = @($tombstoned | ForEach-Object { $_.Name }) - - Write-Host '' - Write-Host " ! Detected $($tombstoned.Count) soft-deleted label tombstone(s) blocking deployment:" -ForegroundColor Yellow - foreach ($t in $tombstoned) { - $modeText = if ($t.PSObject.Properties.Name -contains 'Mode') { $t.Mode } else { 'Disabled' } - Write-Host " - '$($t.Name)' (Mode=$modeText)" -ForegroundColor Yellow - } - Write-Host ' Auto-renaming the internal Name only (next free -vN) for this run; the user-visible' -ForegroundColor Yellow - Write-Host ' DisplayName is kept UNCHANGED (a tombstone does not reserve the DisplayName).' -ForegroundColor DarkYellow - Write-Host ' PurviewConfig.psd1 on disk is NOT modified; the original Name is restored once the' -ForegroundColor DarkYellow - Write-Host ' ~30-day tombstone purges.' -ForegroundColor DarkYellow - - foreach ($lbl in $Config.Labels) { - $cn = Get-LabelCreateName $lbl - if ($tombstonedNames -contains $cn) { - $newName = Get-FreeVersionedName -BaseName $cn -AllLabels $allLabelsRaw - Write-Host " $cn -> $newName (DisplayName '$($lbl.DisplayName)' unchanged)" -ForegroundColor Yellow - if ($lbl.BuiltInName) { $lbl.BuiltInName = $newName } else { $lbl.Name = $newName; $labelNameRemap[$cn] = $newName } - } - foreach ($sub in @($lbl.SubLabels)) { - if (-not $sub) { continue } - $scn = Get-LabelCreateName $sub - if ($tombstonedNames -contains $scn) { - $newName = Get-FreeVersionedName -BaseName $scn -AllLabels $allLabelsRaw - Write-Host " $scn -> $newName (DisplayName '$($sub.DisplayName)' unchanged)" -ForegroundColor Yellow - if ($sub.BuiltInName) { $sub.BuiltInName = $newName } else { $sub.Name = $newName; $labelNameRemap[$scn] = $newName } - } - } - } - - # Bump LabelPolicy.DefaultLabel if it points to a renamed label. - if ($Config.LabelPolicy -and $Config.LabelPolicy.DefaultLabel ` - -and $labelNameRemap.ContainsKey($Config.LabelPolicy.DefaultLabel)) { - $newDefault = $labelNameRemap[$Config.LabelPolicy.DefaultLabel] - Write-Host " LabelPolicy.DefaultLabel: $($Config.LabelPolicy.DefaultLabel) -> $newDefault" -ForegroundColor Yellow - $Config.LabelPolicy.DefaultLabel = $newDefault - } - # Bump LabelPolicy.DefaultLabelForEmail if it points to a renamed label. - if ($Config.LabelPolicy -and $Config.LabelPolicy.DefaultLabelForEmail ` - -and $labelNameRemap.ContainsKey($Config.LabelPolicy.DefaultLabelForEmail)) { - $newEmailDefault = $labelNameRemap[$Config.LabelPolicy.DefaultLabelForEmail] - Write-Host " LabelPolicy.DefaultLabelForEmail: $($Config.LabelPolicy.DefaultLabelForEmail) -> $newEmailDefault" -ForegroundColor Yellow - $Config.LabelPolicy.DefaultLabelForEmail = $newEmailDefault - } - - # Surface the remap so Setup-DLP.ps1 can rewrite LabelPath segments. - $Config['LabelNameRemap'] = $labelNameRemap - Write-Host '' -} - -# Working cache: filter out tombstones so Get-LabelByName / DisplayName -# matching never returns one accidentally. -$allLabels = @($allLabelsRaw | Where-Object { -not (Test-LabelSoftDeleted $_) }) - -# --------------------------------------------------------------------------- -# Retention-tag (ComplianceTag) name-collision detection + auto-rename. -# -# Sensitivity labels and retention labels share the SAME underlying -# 'ComplianceTag' compliance-rule namespace server-side. A retention label -# named 'Confidential' silently blocks New-Label -Name 'Confidential' with: -# -# |Microsoft.Exchange.Management.UnifiedPolicy.ComplianceRuleAlreadyExistsInScenarioException| -# A compliance rule with name 'X' already exists in scenario(s) 'ComplianceTag'. -# -# Get-Label does NOT surface retention tags, so the tombstone check above -# never catches these. Here we query Get-ComplianceTag and apply the same -# -vN auto-rename logic to any colliding configured label name. -# --------------------------------------------------------------------------- -$retentionTags = @() -$retentionTagsLookupFailed = $false -try { - if (Get-Command Get-ComplianceTag -ErrorAction SilentlyContinue) { - $retentionTags = @(Get-ComplianceTag -ErrorAction Stop) - } else { - $retentionTagsLookupFailed = $true - Write-Warning " Get-ComplianceTag cmdlet is not available in this session. Skipping retention-tag collision pre-flight." - } -} catch { - $retentionTagsLookupFailed = $true - Write-Warning " Could not enumerate retention tags (Get-ComplianceTag failed: $($_.Exception.Message))." - Write-Warning " Skipping retention-tag collision pre-flight. If a sensitivity label fails to create with" - Write-Warning " 'ComplianceRuleAlreadyExistsInScenarioException', a retention tag is holding the name." -} - -$retentionCollisions = @() -if (-not $retentionTagsLookupFailed -and $retentionTags.Count -gt 0) { - $retentionTagNames = @($retentionTags | ForEach-Object { $_.Name }) - $retentionCollisions = @($retentionTagNames | Where-Object { $managedCreateNames -contains $_ }) -} - -if ($retentionCollisions.Count -gt 0) { - # An existing ACTIVE sensitivity label with the same configured Name or - # DisplayName means the label-creation flow will adopt/leave it in place - # instead of calling New-Label. In that case the retention-tag name - # collision is moot (we never try to allocate the colliding name in the - # ComplianceTag scenario), so renaming would produce a needless duplicate - # (e.g. a 'Public' Microsoft template already exists -> we'd otherwise - # create 'Public v2'). Filter the collision list down to entries that - # actually need a rename. - function Test-ActiveLabelMatch { - param([string] $LblName, [string] $LblDisplayName, [string] $ParentId, [array] $AllLabels) - foreach ($cand in $AllLabels) { - if (Test-LabelSoftDeleted $cand) { continue } - $parentMatch = if ($ParentId) { $cand.ParentId -eq $ParentId } else { -not $cand.ParentId } - if (-not $parentMatch) { continue } - if ($cand.Name -eq $LblName) { return $cand } - if ($LblDisplayName -and $cand.DisplayName -eq $LblDisplayName) { return $cand } - } - return $null - } - - $actionable = @() - foreach ($lbl in $Config.Labels) { - if ($retentionCollisions -contains $lbl.Name) { - $adopt = Test-ActiveLabelMatch -LblName $lbl.Name -LblDisplayName $lbl.DisplayName -ParentId $null -AllLabels $allLabelsRaw - if ($adopt) { - Write-Host " Retention tag named '$($lbl.Name)' exists, but an active sensitivity label '$($adopt.DisplayName)' (Id: $($adopt.Guid), Name: $($adopt.Name)) is already present — adopting it; no rename needed." -ForegroundColor DarkGray - } else { - $actionable += [pscustomobject]@{ Scope = 'Parent'; Lbl = $lbl; Sub = $null } - } - } - foreach ($sub in @($lbl.SubLabels)) { - if (-not $sub) { continue } - if ($retentionCollisions -contains $sub.Name) { - # ParentId is unknown at this point (label not yet created/looked up). - # Match on Name or DisplayName globally; correctness-wise it is safe - # because sub-label Names are server-globally unique compliance rules. - $adopt = Test-ActiveLabelMatch -LblName $sub.Name -LblDisplayName $sub.DisplayName -ParentId $null -AllLabels $allLabelsRaw - if (-not $adopt) { - # Try matching by DisplayName under a parent we recognise. - $adopt = $allLabelsRaw | Where-Object { - -not (Test-LabelSoftDeleted $_) -and - $_.DisplayName -eq $sub.DisplayName -and $_.ParentId - } | Select-Object -First 1 - } - if ($adopt) { - Write-Host " Retention tag named '$($sub.Name)' exists, but an active sensitivity sub-label '$($adopt.DisplayName)' (Id: $($adopt.Guid), Name: $($adopt.Name)) is already present — adopting it; no rename needed." -ForegroundColor DarkGray - } else { - $actionable += [pscustomobject]@{ Scope = 'Sub'; Lbl = $lbl; Sub = $sub } - } - } - } - } - - if ($actionable.Count -eq 0) { - Write-Host '' - Write-Host " i Detected $($retentionCollisions.Count) retention-tag name collision(s), but all matching sensitivity labels already exist on the tenant — no rename needed:" -ForegroundColor DarkGray - foreach ($n in $retentionCollisions) { - $tag = $retentionTags | Where-Object { $_.Name -eq $n } | Select-Object -First 1 - $created = if ($tag -and $tag.PSObject.Properties.Name -contains 'CreatedBy') { $tag.CreatedBy } else { '' } - Write-Host " - Retention tag '$n' (created by '$created') ignored: active sensitivity label with matching Name/DisplayName will be adopted." -ForegroundColor DarkGray - } - Write-Host '' - } else { - Write-Host '' - Write-Host " ! Detected $($actionable.Count) retention-tag (ComplianceTag) name collision(s) blocking deployment:" -ForegroundColor Yellow - foreach ($act in $actionable) { - $n = if ($act.Scope -eq 'Sub') { $act.Sub.Name } else { $act.Lbl.Name } - $tag = $retentionTags | Where-Object { $_.Name -eq $n } | Select-Object -First 1 - $created = if ($tag -and $tag.PSObject.Properties.Name -contains 'CreatedBy') { $tag.CreatedBy } else { '' } - $tagGuid = if ($tag -and $tag.PSObject.Properties.Name -contains 'Guid') { $tag.Guid } else { '' } - Write-Host " - Retention tag '$n' (Guid: $tagGuid, created by '$created') reserves this name in the ComplianceTag scenario." -ForegroundColor Yellow - } - Write-Host ' Sensitivity labels and retention labels share the same server-side name space, so' -ForegroundColor Yellow - Write-Host " creating a sensitivity label with this name would fail with 'ComplianceRuleAlreadyExistsInScenarioException'." -ForegroundColor Yellow - Write-Host ' No matching active sensitivity label exists to adopt, so auto-renaming with the next free -vN suffix for this run.' -ForegroundColor Yellow - Write-Host ' PurviewConfig.psd1 on disk is NOT modified. To restore the original name, remove or rename' -ForegroundColor DarkYellow - Write-Host ' the conflicting retention tag (e.g. via Remove-ComplianceTag) and re-run.' -ForegroundColor DarkYellow - - foreach ($act in $actionable) { - if ($act.Scope -eq 'Parent') { - $lbl = $act.Lbl - $cn = Get-LabelCreateName $lbl - $newName = Get-FreeVersionedName -BaseName $cn -AllLabels $allLabelsRaw - Write-Host " $cn -> $newName (DisplayName '$($lbl.DisplayName)' unchanged)" -ForegroundColor Yellow - if ($lbl.BuiltInName) { $lbl.BuiltInName = $newName } else { $lbl.Name = $newName; $labelNameRemap[$cn] = $newName } - } else { - $sub = $act.Sub - $scn = Get-LabelCreateName $sub - $newName = Get-FreeVersionedName -BaseName $scn -AllLabels $allLabelsRaw - Write-Host " $scn -> $newName (DisplayName '$($sub.DisplayName)' unchanged)" -ForegroundColor Yellow - if ($sub.BuiltInName) { $sub.BuiltInName = $newName } else { $sub.Name = $newName; $labelNameRemap[$scn] = $newName } - } - } - - if ($Config.LabelPolicy -and $Config.LabelPolicy.DefaultLabel ` - -and $labelNameRemap.ContainsKey($Config.LabelPolicy.DefaultLabel)) { - $newDefault = $labelNameRemap[$Config.LabelPolicy.DefaultLabel] - Write-Host " LabelPolicy.DefaultLabel: $($Config.LabelPolicy.DefaultLabel) -> $newDefault" -ForegroundColor Yellow - $Config.LabelPolicy.DefaultLabel = $newDefault - } - if ($Config.LabelPolicy -and $Config.LabelPolicy.DefaultLabelForEmail ` - -and $labelNameRemap.ContainsKey($Config.LabelPolicy.DefaultLabelForEmail)) { - $newEmailDefault = $labelNameRemap[$Config.LabelPolicy.DefaultLabelForEmail] - Write-Host " LabelPolicy.DefaultLabelForEmail: $($Config.LabelPolicy.DefaultLabelForEmail) -> $newEmailDefault" -ForegroundColor Yellow - $Config.LabelPolicy.DefaultLabelForEmail = $newEmailDefault - } - - # Surface remap so Setup-DLP.ps1 can rewrite LabelPath segments like 'Confidential/AllEmployees'. - $Config['LabelNameRemap'] = $labelNameRemap - Write-Host '' - } -} - -function Get-LabelByName { - param( - [string] $Name, - [string] $DisplayName, - [string] $ParentId, - # Scope filter for which labels to consider: - # 'Any' - legacy permissive match (default for back-compat). Name - # match is hierarchy-agnostic; DisplayName fallback honours - # the supplied $ParentId (null = top-level). - # 'TopLevel' - match only labels with no ParentId. - # 'Parent' - match only labels whose ParentId equals $ParentId. Requires $ParentId. - # Use 'TopLevel' for configured-parent lookups and 'Parent' for sub-label - # lookups under a known parent. Without an explicit scope a tenant that - # has a sub-label with the same internal Name as a configured parent - # (common on tenants migrated to the modern label scheme or with the - # Microsoft-default labels enabled) will return the sub-label and the - # caller will cache it as a "parent", which then breaks Pass 3 Phase A - # with InvalidSubLabelPriorityException. - [ValidateSet('Any','TopLevel','Parent')] - [string] $Scope = 'Any' - ) - if ($Scope -eq 'Parent' -and [string]::IsNullOrWhiteSpace($ParentId)) { return $null } - - $candidates = switch ($Scope) { - 'TopLevel' { $allLabels | Where-Object { -not $_.ParentId } } - 'Parent' { $allLabels | Where-Object { $_.ParentId -eq $ParentId } } - default { $allLabels } - } - - # Signature match (region/scheme-proof). If the configured label carries a - # BuiltInName (the Microsoft 'defa4170-0d19-0005-NNNN-bc88714345d2' default - # identifier), match the live label by that internal Name REGARDLESS of - # DisplayName localization (FR 'Confidentiel', DE 'Vertraulich', or even - # 'Specific' vs 'Specified People' across tenants). The modern-scheme migrated - # group appends 'Group' to the Name, so accept that suffixed form too. This - # runs before the Name/DisplayName match because it is the most reliable key. - $builtIn = $null - foreach ($_cl in $Config.Labels) { - if ($_cl.Name -eq $Name -and $_cl.BuiltInName) { $builtIn = [string]$_cl.BuiltInName; break } - if ($_cl.SubLabels) { - foreach ($_cs in $_cl.SubLabels) { - if ($_cs.Name -eq $Name -and $_cs.BuiltInName) { $builtIn = [string]$_cs.BuiltInName; break } - } - if ($builtIn) { break } - } - } - if ($builtIn) { - $bySig = $candidates | Where-Object { - $n = [string]$_.Name - ($n -eq $builtIn) -or ($n -eq ($builtIn + 'Group')) - } | Select-Object -First 1 - if ($bySig) { return $bySig } - } - - $byName = $candidates | Where-Object { $_.Name -eq $Name } | Select-Object -First 1 - if ($byName) { return $byName } - - if ($DisplayName) { - if ($Scope -eq 'Any') { - # Legacy 'Any' scope: DisplayName fallback uses the caller-supplied - # ParentId-or-null filter (DisplayName is not tenant-globally unique - # like Name is). - $byDisplay = $allLabels | Where-Object { - $_.DisplayName -eq $DisplayName -and ( - ($ParentId -and $_.ParentId -eq $ParentId) -or - (-not $ParentId -and -not $_.ParentId) - ) - } | Select-Object -First 1 - } else { - $byDisplay = $candidates | Where-Object { $_.DisplayName -eq $DisplayName } | Select-Object -First 1 - } - if ($byDisplay) { return $byDisplay } - } - return $null -} - -# Resolve a configured default-label Name (LabelPolicy.DefaultLabel / -# DefaultLabelForEmail) to its LIVE Purview label object. This MUST be -# parent-aware: when the default is a sub-label that was ADOPTED from a -# pre-existing label (e.g. a Microsoft-default 'All Employees' sub-label on a -# tenant that already had the default sensitivity labels), the live label's -# internal Name is a GUID, not the configured Name. A bare Name/DisplayName -# lookup at the default 'Any' scope then fails: the Name match misses (GUID), -# and the DisplayName fallback in 'Any' scope only considers TOP-LEVEL labels, -# so a sub-label default is never found -> "Default label '' was not found -# after creation." We therefore resolve the parent first and match the -# sub-label within the parent's scope, exactly as the published-labels -# collection loop in Pass 4 does. -function Resolve-ConfiguredDefaultLabel { - param([Parameter(Mandatory)] [string] $DefaultName) - - $def = $null - $parentDef = $null - foreach ($lbl in $Config.Labels) { - if ($lbl.Name -eq $DefaultName) { $def = $lbl; break } - foreach ($sub in @($lbl.SubLabels)) { - if ($sub -and $sub.Name -eq $DefaultName) { $def = $sub; $parentDef = $lbl; break } - } - if ($def) { break } - } - - $display = if ($def) { $def.DisplayName } else { $DefaultName } - - if ($parentDef) { - # Sub-label default: resolve its parent first (handles adopted - # MS-default parents whose live Name is a GUID via DisplayName), then - # match the sub-label within the parent's scope so adopted sub-labels - # match by DisplayName even though their internal Name is a GUID. - $parentObj = Get-LabelByName -Name $parentDef.Name -DisplayName $parentDef.DisplayName -Scope 'TopLevel' - $parentId = if ($parentObj) { $parentObj.Guid } else { $null } - $scope = if ($parentId) { 'Parent' } else { 'Any' } - return Get-LabelByName -Name $DefaultName -DisplayName $display -ParentId $parentId -Scope $scope - } - - # Top-level default (or a Name not found in config): resolve at top-level - # scope, which already matches adopted MS-default parents by DisplayName. - return Get-LabelByName -Name $DefaultName -DisplayName $display -Scope 'TopLevel' -} - -# True when a live label object is a modern-scheme "Label Group" — a -# non-assignable, non-publishable container. The signal is the `IsLabelGroup` -# property on the plain Get-Label object (NO -ReturnModernLabelScheme switch): -# * Modern scheme: a parent that has sub-labels reports IsLabelGroup = True -# and CANNOT be published directly (New-LabelPolicy throws -# "Label group(s) '' can not be published"); only its sub-labels are -# published and the group is auto-included by the service. -# * Classic scheme: a parent reports IsParent = True but IsLabelGroup = False -# and IS independently publishable — so this check leaves classic tenants -# untouched (no regression). -# Returns $false for placeholder objects (WhatIf) and older IPPS modules that -# don't surface the property, i.e. defaults to the legacy "publishable" path. -function Test-IsLabelGroup { - param($LabelObject) - if (-not $LabelObject) { return $false } - $prop = $LabelObject.PSObject.Properties['IsLabelGroup'] - return [bool]($prop -and $prop.Value -eq $true) -} - -# A Label Group cannot serve as a default applied label. Given a live Label -# Group object, return the child label to use as the default instead: prefer -# 'Anyone (unrestricted)' (the least-sensitive Microsoft-default child, e.g. -# General\Anyone (unrestricted)), otherwise the lowest-priority (least -# sensitive) child. Returns $null when the group has no children. -function Resolve-LabelGroupDefaultChild { - param([Parameter(Mandatory)] $GroupLabel) - $children = @($script:allLabels | Where-Object { $_.ParentId -and $GroupLabel.Guid -and ($_.ParentId -eq $GroupLabel.Guid) }) - if ($children.Count -eq 0) { return $null } - $preferred = $children | Where-Object { $_.DisplayName -eq 'Anyone (unrestricted)' } | Select-Object -First 1 - if ($preferred) { return $preferred } - return ($children | Sort-Object { [int]$_.Priority } | Select-Object -First 1) -} - -# Read a single advanced-setting key (e.g. 'color') off a live Get-Label -# object. IPPS surfaces these as a `Settings` collection whose ToString() -# renders each entry as `[key, value]` (e.g. `[color, #13A10E]`). Parsing -# the rendered form is the most reliable approach because the underlying -# CLR type is not stable across cmdlet versions. Returns $null when the -# label, the collection, or the key is missing. -function Get-LabelAdvancedSetting { - param( - $Label, - [Parameter(Mandatory)] [string] $Key - ) - if (-not $Label) { return $null } - $needle = $Key.Trim().ToLowerInvariant() - foreach ($propName in @('Settings','AdvancedSettings','LocaleSettings')) { - $prop = $Label.PSObject.Properties[$propName] - if (-not $prop) { continue } - $coll = $prop.Value - if (-not $coll) { continue } - # Try structured access first (Key/Value or Name/Value or dictionary) - # before falling back to regex on the rendered '[key, value]' form. The - # IPPS module version sometimes returns typed Setting objects, and a - # mismatch in ToString() rendering would otherwise cause repeat false - # drift detections on every run. - if ($coll -is [System.Collections.IDictionary]) { - foreach ($k in $coll.Keys) { - if (([string]$k).Trim().ToLowerInvariant() -eq $needle) { - return [string]$coll[$k] - } - } - continue - } - foreach ($item in @($coll)) { - if ($null -eq $item) { continue } - $keyProp = $null - foreach ($kp in 'Key','Name') { - $p = $item.PSObject.Properties[$kp] - if ($p) { $keyProp = $p; break } - } - $valProp = $item.PSObject.Properties['Value'] - if ($keyProp -and $valProp) { - if (([string]$keyProp.Value).Trim().ToLowerInvariant() -eq $needle) { - return [string]$valProp.Value - } - continue - } - $s = [string]$item - if ($s -match '^\s*\[\s*([^,\]]+?)\s*,\s*(.*?)\s*\]\s*$') { - if ($matches[1].Trim().ToLowerInvariant() -eq $needle) { - return $matches[2].Trim() - } - } - } - } - return $null -} - -# Resolve a configured parent label to its live Purview object. Prefers the -# Pass 1 cache ($script:createdParents) because that object came directly from -# Get-Label -Identity in New-OrUpdate-Label and is authoritative even for -# Microsoft-default labels (e.g. 'Public', 'Personal') that bulk Get-Label -# does not always surface reliably. Falls back to the bulk-cache lookup. -# -# The cached object MUST be top-level. If Pass 1 cached a sub-label (which can -# happen on tenants with name collisions between configured parents and live -# sub-labels), we return $null and let the caller surface the remediation. -# Without this guard, Phase A would push the sub-label to priority 0 and hit -# InvalidSubLabelPriorityException ("priority 0 is invalid for sub-label"). -function Resolve-ConfigParentLabel { - param([Parameter(Mandatory)] [hashtable] $LabelDef) - if ($script:createdParents -and $script:createdParents.ContainsKey($LabelDef.Name)) { - $cached = $script:createdParents[$LabelDef.Name] - if ($cached -and -not $cached.ParentId) { return $cached } - # Cached object is a sub-label - reject and fall through to bulk lookup - # (which is also scope-restricted) so the caller never blindly trusts a - # collision-tainted cache entry. - } - return Get-LabelByName -Name $LabelDef.Name -DisplayName $LabelDef.DisplayName -Scope 'TopLevel' -} - -# Return the most stable identity to pass to IPPS cmdlets. The label Guid is -# immutable and unambiguous; Name can be a GUID-string (MS-default labels) or -# a human-readable token (user-created labels). Both work with -Identity, but -# Guid is the safer choice for re-lookups after mutations. -function Get-StableLabelIdentity { - param([Parameter(Mandatory)] $LabelObject) - if ($LabelObject.PSObject.Properties.Name -contains 'Guid' -and $LabelObject.Guid) { - return [string]$LabelObject.Guid - } - return [string]$LabelObject.Name -} - -# True when a string is shaped like a canonical GUID. Used to detect labels -# whose Name property is actually a GUID (Microsoft-default labels), because -# Set-Label via that GUID-shaped Name is unreliable on some tenants. -function Test-IsGuidLikeString { - param([string] $Value) - if ([string]::IsNullOrWhiteSpace($Value)) { return $false } - return $Value -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' -} - -# Resolve a label to its CURRENT live state using a fresh bulk Get-Label (which -# is more reliable than Get-Label -Identity for Microsoft-default labels on -# some tenants). Matches by Guid, then Name, then unique top-level DisplayName. -# Returns $null if no match. Used by both the priority setter and the -# verification pass so they cannot disagree about a label's current priority. -function Get-LiveLabelByObject { - param([Parameter(Mandatory)] $LabelObject) - $bulk = $null - try { - Invoke-WithTransientRetry -Description "Get-Label (bulk fetch for live resolve)" -Action { - $script:_bulkLabelsForResolve = @(Get-Label -ErrorAction Stop | Where-Object { -not (Test-LabelSoftDeleted $_) }) - } - $bulk = $script:_bulkLabelsForResolve - Remove-Variable -Name '_bulkLabelsForResolve' -Scope Script -ErrorAction SilentlyContinue - } catch { - $bulk = @(Get-Label -ErrorAction SilentlyContinue | Where-Object { -not (Test-LabelSoftDeleted $_) }) - } - if (-not $bulk) { return $null } - - if ($LabelObject.Guid) { - try { - if (([guid]$LabelObject.Guid) -ne [guid]::Empty) { - $m = $bulk | Where-Object { $_.Guid -eq $LabelObject.Guid } | Select-Object -First 1 - if ($m) { return $m } - } - } catch {} - } - if ($LabelObject.Name) { - $m = $bulk | Where-Object { $_.Name -eq [string]$LabelObject.Name } | Select-Object -First 1 - if ($m) { return $m } - } - if ($LabelObject.DisplayName) { - $cands = @($bulk | Where-Object { $_.DisplayName -eq $LabelObject.DisplayName -and -not $_.ParentId }) - if ($cands.Count -eq 1) { return $cands[0] } - } - return $null -} - -# Update label priority and VERIFY the change persisted. Tries multiple Identity -# forms because IPPS Set-Label can be a silent no-op for Microsoft-default -# labels when called by Guid (or by Name when the Name is itself the GUID). -# -# Identity precedence: -# * Microsoft-default-shaped labels (Name == Guid OR Name looks like a GUID): -# DisplayName (if unique top-level) → Name → Guid -# * Everything else: -# Name → DisplayName (if unique top-level) → Guid -# -# DisplayName is only added as a candidate for TOP-LEVEL labels, and only when -# bulk Get-Label confirms exactly one top-level label has that DisplayName AND -# it matches our target Guid/Name. Sub-label DisplayNames are excluded because -# they can legitimately repeat across different parent labels. -# -# Returns a PSObject with: Success, UsedIdentity (Name/DisplayName/Guid), -# Attempts (string[] diagnostic trail), Live (fresh label object on success). -function Set-LabelPriorityWithVerify { - param( - [Parameter(Mandatory)] $LabelObject, - [Parameter(Mandatory)] [int] $TargetPriority, - [Parameter(Mandatory)] [string] $DisplayNameForLog, - [switch] $IsSubLabel - ) - - $nameStr = if ($LabelObject.Name) { [string]$LabelObject.Name } else { $null } - $displayStr = if ($LabelObject.DisplayName) { [string]$LabelObject.DisplayName } else { $null } - $guidStr = $null - if ($LabelObject.Guid) { - try { if (([guid]$LabelObject.Guid) -ne [guid]::Empty) { $guidStr = [string]$LabelObject.Guid } } catch {} - } - - $nameIsGuidLike = Test-IsGuidLikeString -Value $nameStr - $msDefaultLike = ($nameStr -and $guidStr -and ($nameStr -eq $guidStr)) -or $nameIsGuidLike - - # Confirm DisplayName uniqueness before allowing it as a Set-Label identity. - $displayIsSafe = $false - if (-not $IsSubLabel -and $displayStr) { - $peek = @(Get-Label -ErrorAction SilentlyContinue | Where-Object { -not (Test-LabelSoftDeleted $_) }) - $same = @($peek | Where-Object { $_.DisplayName -eq $displayStr -and -not $_.ParentId }) - if ($same.Count -eq 1) { - $hit = $same[0] - if (($guidStr -and ($hit.Guid -eq $LabelObject.Guid)) -or - ($nameStr -and ($hit.Name -eq $nameStr))) { - $displayIsSafe = $true - } - } - } - - $cands = New-Object System.Collections.Generic.List[object] - if ($msDefaultLike) { - if ($displayIsSafe) { [void]$cands.Add(@{ Kind='DisplayName'; Value=$displayStr }) } - if ($nameStr) { [void]$cands.Add(@{ Kind='Name'; Value=$nameStr }) } - if ($guidStr -and ($guidStr -ne $nameStr)) { - [void]$cands.Add(@{ Kind='Guid'; Value=$guidStr }) } - } else { - if ($nameStr) { [void]$cands.Add(@{ Kind='Name'; Value=$nameStr }) } - if ($displayIsSafe) { [void]$cands.Add(@{ Kind='DisplayName'; Value=$displayStr }) } - if ($guidStr -and ($guidStr -ne $nameStr)) { - [void]$cands.Add(@{ Kind='Guid'; Value=$guidStr }) } - } - - $attempts = New-Object System.Collections.Generic.List[string] - foreach ($c in $cands) { - try { - Invoke-WithTransientRetry -Description ("Set-Label priority=$TargetPriority '$DisplayNameForLog' via $($c.Kind)") -Action { - Set-Label -Identity $c.Value -Priority $TargetPriority ` - -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null - } - } catch { - $errMsg = Format-IPPSError $_ - [void]$attempts.Add("[$($c.Kind)='$($c.Value)'] threw: $errMsg") - # 'not a valid priority' / 'is not valid' is the IPPS no-op error we - # ignore in legacy code; treat as soft failure and try next identity. - continue - } - $live = Get-LiveLabelByObject -LabelObject $LabelObject - if ($live -and $live.Priority -eq $TargetPriority) { - [void]$attempts.Add("[$($c.Kind)='$($c.Value)'] success: priority=$($live.Priority)") - return [pscustomobject]@{ - Success = $true - UsedIdentity = $c.Kind - Attempts = @($attempts) - Live = $live - } - } - $obs = if ($live) { $live.Priority } else { 'not-found-in-bulk' } - [void]$attempts.Add("[$($c.Kind)='$($c.Value)'] no-op: observed priority=$obs after Set-Label.") - } - return [pscustomobject]@{ - Success = $false - UsedIdentity = $null - Attempts = @($attempts) - Live = $null - } -} - -function Test-Owned { - param($Object, [string] $Tag) - if (-not $Object) { return $false } - $desc = if ($Object.PSObject.Properties.Name -contains 'Comment') { $Object.Comment } - elseif ($Object.PSObject.Properties.Name -contains 'Description') { $Object.Description } - else { '' } - return ($desc -and $desc -like "*$Tag*") -} - -function Invoke-IPPSCmdlet { - <# - IPPS REST cmdlets bypass -ErrorAction Stop. Run with - -ErrorAction SilentlyContinue + -ErrorVariable, then re-throw any - captured error so the caller's try/catch (or our local err check) - sees a real terminating exception. - #> - param( - [Parameter(Mandatory)] [scriptblock] $ScriptBlock, - [string] $Description = '' - ) - $err = @() - & $ScriptBlock -ErrorVariable +err -ErrorAction SilentlyContinue 2>&1 | Out-Null - if ($err.Count -gt 0) { throw $err[0] } -} - -function Set-LabelEncryption { - <# - Apply encryption to a label. - - Two protection modes are supported: - - * 'Template' (default) — admin-defined template that grants the - configured rights ($rights) to AuthenticatedUsers. Used for the - "All Employees" sub-labels in Microsoft's default-labels spec. - - * 'UserDefined' — the user picks recipients and permissions at apply - time. Used for the "Trusted People" / "Specific People" sub-labels. - Office apps (Word/Excel/PowerPoint) prompt the user; Outlook - applies a fixed behaviour (Encrypt-Only or Do Not Forward) per - $UserDefinedOutlookBehavior. - #> - param( - [Parameter(Mandatory)] [string] $Identity, - [ValidateSet('Template','UserDefined')] - [string] $ProtectionType = 'Template', - [ValidateSet('EncryptOnly','DoNotForward','None')] - [string] $UserDefinedOutlookBehavior = 'None', - # Optional per-label override. When supplied, this rights string is - # passed to Set-Label instead of the module-level `$rights` default. - # Caller is responsible for resolving any `{TenantDomain}` token - # via Resolve-EncryptionRightsTokens before passing it in. - [string] $RightsDefinitions - ) - $effectiveRights = if ($PSBoundParameters.ContainsKey('RightsDefinitions') -and $RightsDefinitions) { - $RightsDefinitions - } else { - $rights - } - if ($ProtectionType -eq 'Template') { - Invoke-WithTransientRetry -Description ("Set-Label encryption (Template) '$Identity'") -Action { - Set-Label -Identity $Identity ` - -EncryptionEnabled $true ` - -EncryptionProtectionType 'Template' ` - -EncryptionRightsDefinitions $effectiveRights ` - -EncryptionContentExpiredOnDateInDaysOrNever 'Never' ` - -EncryptionOfflineAccessDays $offlineDays ` - -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null - } - } else { - $params = @{ - Identity = $Identity - EncryptionEnabled = $true - EncryptionProtectionType = 'UserDefined' - EncryptionPromptUser = $true # Word/Excel/PowerPoint: prompt user to assign permissions - EncryptionContentExpiredOnDateInDaysOrNever = 'Never' - EncryptionOfflineAccessDays = $offlineDays - } - switch ($UserDefinedOutlookBehavior) { - 'EncryptOnly' { $params['EncryptionEncryptOnly'] = $true } - 'DoNotForward' { $params['EncryptionDoNotForward'] = $true } - default { } # 'None' = no Outlook-specific behaviour - } - Invoke-WithTransientRetry -Description ("Set-Label encryption (UserDefined) '$Identity'") -Action { - Set-Label @params -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null - } - } -} - -function Set-LabelContentMarking { - param( - [Parameter(Mandatory)] [string] $Identity, - [string] $HeaderText, - [string] $FooterText, - [string] $WatermarkText - ) - $params = @{ - Identity = $Identity - ApplyContentMarkingHeaderEnabled = [bool]$HeaderText - ApplyContentMarkingFooterEnabled = [bool]$FooterText - ApplyWaterMarkingEnabled = [bool]$WatermarkText - } - if ($HeaderText) { - $params['ApplyContentMarkingHeaderText'] = $HeaderText - $params['ApplyContentMarkingHeaderAlignment'] = 'Center' - $params['ApplyContentMarkingHeaderFontColor'] = '#FF0000' - } - if ($FooterText) { - $params['ApplyContentMarkingFooterText'] = $FooterText - $params['ApplyContentMarkingFooterAlignment'] = 'Center' - $params['ApplyContentMarkingFooterFontColor'] = '#FF0000' - } - if ($WatermarkText) { - $params['ApplyWaterMarkingText'] = $WatermarkText - $params['ApplyWaterMarkingLayout'] = 'Diagonal' - $params['ApplyWaterMarkingFontColor'] = '#FF0000' - } - Invoke-WithTransientRetry -Description ("Set-Label content marking '$($params.Identity)'") -Action { - Set-Label @params -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null - } -} - -# --------------------------------------------------------------------------- -# ContentType (label scope) helpers — issue #24. -# -# `ContentType` is the IPPS `New-Label -ContentType` value: a comma-separated -# string from the set {File, Email, Site, UnifiedGroup, PurviewAssets, -# SchematizedDataAssets}. Three published labels (General, AllEmployees, -# HCAllEmps) ship with container scope ('Site, UnifiedGroup') so they appear -# in the Purview portal's container-label picker; the rest stay File/Email-only. -# -# Helpers below normalise the comma string to a canonical SORTED-UNIQUE array -# so we can: -# * compare config vs live without false drift from whitespace / ordering; -# * UNION live + desired on adoption so we never strip a manually-added -# scope (per issue #24 AC3 + AC6); -# * strip the container bits when -SkipContainerLabels is set without -# mutating the rest of the desired scope. -# --------------------------------------------------------------------------- -$script:ContainerContentTypeBits = @('Site', 'UnifiedGroup') - -function ConvertTo-ContentTypeSet { - [OutputType([string[]])] - param([string] $Value) - if ([string]::IsNullOrWhiteSpace($Value)) { return @() } - return @( - $Value -split ',' | - ForEach-Object { $_.Trim() } | - Where-Object { $_ } | - Sort-Object -Unique - ) -} - -function Compare-ContentTypeSets { - [OutputType([bool])] - param( - [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]] $A, - [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]] $B - ) - if ($A.Count -ne $B.Count) { return $false } - for ($i = 0; $i -lt $A.Count; $i++) { - if ($A[$i] -ne $B[$i]) { return $false } - } - return $true -} - -function Get-DesiredContentTypeSet { - <# - Resolve the desired ContentType set for a label, after the - -SkipContainerLabels gate. Returns: - * $null if the config did not specify a ContentType (caller - should leave the IPPS default in place); - * @() if every desired bit was stripped (caller still skips - the Set-Label so the IPPS default kicks in); - * sorted-unique string[] otherwise. - Emits a one-line info message + Skipped run-log entry per label - when container bits are stripped (issue #24 AC4). - #> - [OutputType([string[]])] - param( - [Parameter(Mandatory)] [hashtable] $LabelDef, - [Parameter(Mandatory)] [bool] $SkipContainerLabels - ) - if (-not $LabelDef.ContainsKey('ContentType') -or - [string]::IsNullOrWhiteSpace([string]$LabelDef.ContentType)) { - return $null - } - $configured = ConvertTo-ContentTypeSet -Value ([string]$LabelDef.ContentType) - if (-not $SkipContainerLabels) { return $configured } - - $stripped = @($configured | Where-Object { $_ -notin $script:ContainerContentTypeBits }) - $removed = @($configured | Where-Object { $_ -in $script:ContainerContentTypeBits }) - if ($removed.Count -gt 0) { - Write-Host (" [info] -SkipContainerLabels: stripped container scope bits ({0}) from label '{1}' ContentType (configured: {2}; will provision: {3}). Tenant-side Group.Unified EnableMIPLabels is not being set, so the container bits would have no effect." -f - ($removed -join ', '), - $LabelDef.DisplayName, - ($configured -join ', '), - $(if ($stripped.Count) { $stripped -join ', ' } else { '(none — IPPS default File, Email will apply)' })) -ForegroundColor DarkYellow - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Resolve ContentType' ` - -Target $LabelDef.DisplayName -Status 'Skipped' ` - -Detail ("Container-scope bits ({0}) stripped from configured ContentType '{1}' because -SkipContainerLabels is set. Provisioning '{2}'." -f - ($removed -join ', '), - ($configured -join ', '), - $(if ($stripped.Count) { $stripped -join ', ' } else { '(IPPS default)' })) - } - return $stripped -} - -function New-OrUpdate-Label { - param( - [Parameter(Mandatory)] [hashtable] $LabelDef, - [string] $ParentId - ) - - $existing = Get-LabelByName -Name $LabelDef.Name -DisplayName $LabelDef.DisplayName -ParentId $ParentId ` - -Scope $(if ($ParentId) { 'Parent' } else { 'TopLevel' }) - $owned = Test-Owned -Object $existing -Tag $tag - $matchedBy = $null - $justCreated = $false # New-Label succeeded inside this call - $idForOps = if ($existing) { $existing.Name } else { $LabelDef.Name } - - if ($existing) { - $matchedBy = if ($existing.Name -eq $LabelDef.Name) { 'Name' } - elseif ($LabelDef.BuiltInName -and ($existing.Name -eq [string]$LabelDef.BuiltInName -or $existing.Name -eq ([string]$LabelDef.BuiltInName + 'Group'))) { 'Name signature (defa4170)' } - else { 'DisplayName' } - if ($owned) { - Write-Host " = Found managed label '$($LabelDef.Name)' (matched by $matchedBy). Updating." -ForegroundColor DarkGray - } elseif ($AdoptExisting) { - Write-Host " ~ Found existing label '$($LabelDef.DisplayName)' (Id: $($existing.Guid), matched by $matchedBy). -AdoptExisting set; updating." -ForegroundColor Yellow - } else { - Write-Host " = Found existing label '$($LabelDef.DisplayName)' (Id: $($existing.Guid), matched by $matchedBy). Leaving as-is (use -AdoptExisting to update)." -ForegroundColor DarkGray - return $existing - } - } - - $tooltip = $LabelDef.Tooltip - $comment = "$tag $tooltip" - - if (-not $existing) { - # Create with the Microsoft built-in 'defa4170-...' Name when the configured - # label maps to a built-in default, so blank-tenant labels are byte-identical - # to Microsoft's defaults (and to adopted tenants) — multitenant tools that - # look up by the standard Name then work everywhere. Falls back to the config - # Name for any custom label without a BuiltInName. - $createName = if ($LabelDef.BuiltInName) { [string]$LabelDef.BuiltInName } else { [string]$LabelDef.Name } - - # Cross-scope Name-collision pre-check. IPPS enforces tenant-globally - # unique label Names, so if the Name collides with a live label in a - # DIFFERENT hierarchy scope (typically: configured top-level vs an existing - # sub-label of the same Name, common on tenants migrated to the modern label - # scheme or with Microsoft-default labels enabled), New-Label will fail with - # a cryptic message. Detect upfront and surface an actionable remediation, - # otherwise Pass 3 fails later with InvalidSubLabelPriorityException when the - # wrong object is reordered. - $callerWantsTopLevel = -not $ParentId - $nameCollision = $allLabels | Where-Object { $_.Name -eq $createName } | Select-Object -First 1 - if ($nameCollision) { - $collisionIsSubLabel = [bool] $nameCollision.ParentId - if ($callerWantsTopLevel -eq $collisionIsSubLabel) { - $callerScope = if ($callerWantsTopLevel) { 'top-level' } else { "child of parent Id $ParentId" } - $liveScope = if ($collisionIsSubLabel) { "sub-label of parent Id $($nameCollision.ParentId)" } else { 'top-level' } - $collisionGuid = if ($nameCollision.Guid) { [string]$nameCollision.Guid } else { 'unknown' } - $detail = @( - "Cannot create configured label '$($LabelDef.Name)' as ${callerScope}: a live label with the same Name already exists in this tenant as a $liveScope (Id: $collisionGuid)." - "Microsoft Purview enforces tenant-globally unique label Names, and the modern label scheme strictly enforces sub-label priority ranges, so this collision must be resolved." - "Remediation options:" - " 1. Rename the configured label in Products/Purview/Config/PurviewConfig.psd1 (change Labels.Name) so it no longer collides." - " 2. In Purview Admin Center (https://purview.microsoft.com), delete or un-nest the colliding live label (Id: $collisionGuid), then re-run." - " 3. If the collision is with a Microsoft-default sensitivity label, keep the default and update the configured Name to a tenant-unique value." - ) -join "`n " - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'New-Label' ` - -Target $LabelDef.DisplayName -Status 'Failed' -Detail $detail - Write-Warning " $detail" - return $null - } - } - - if (-not $PSCmdlet.ShouldProcess($LabelDef.Name, 'New-Label')) { return $null } - - $newArgs = @{ - Name = $createName - DisplayName = $LabelDef.DisplayName - Tooltip = $tooltip - Comment = $comment - } - if ($ParentId) { $newArgs['ParentId'] = $ParentId } - if (-not [string]::IsNullOrWhiteSpace([string]$LabelDef.Color)) { - $newArgs['AdvancedSettings'] = @{ color = [string]$LabelDef.Color } - } - - # Issue #24 — pass -ContentType through to New-Label when configured. - # We never UNION here (the label doesn't exist yet, so there is no - # live value to preserve); UNION applies only on the adopt/update - # path below. - $desiredContentTypeNew = Get-DesiredContentTypeSet -LabelDef $LabelDef -SkipContainerLabels:$SkipContainerLabels.IsPresent - if ($null -ne $desiredContentTypeNew -and $desiredContentTypeNew.Count -gt 0) { - $newArgs['ContentType'] = ($desiredContentTypeNew -join ', ') - } - - # IPPS cmdlets bypass -ErrorAction Stop. Capture errors via -ErrorVariable. - # PR4: wrap with Invoke-WithTransientRetry so 502/503/504/429 auto-retry. - # NOT using -AlreadyExistsIsSuccess here because the catch block below - # needs to inspect the message and extract the existing label's GUID for - # the "Duplicate display name" recovery path. - # Modern label scheme: a parent that will own sub-labels MUST be created as - # a label GROUP (-IsLabelGroup), or sub-label creation fails with - # InvalidParentLabelInModernLabelSchemeException. Classic scheme REJECTS - # -IsLabelGroup. Branch on the detected (cached) scheme; on the first such - # parent, try -IsLabelGroup and fall back to a classic parent if rejected. - $isParentWithSubs = (-not $ParentId) -and [bool]$LabelDef.SubLabels - $useLabelGroup = $isParentWithSubs -and ($script:tenantLabelScheme -ne 'Classic') - - $createArgs = $newArgs - if ($useLabelGroup) { - # A label group is a non-applicable container -> omit ContentType. - $createArgs = @{ - Name = $newArgs.Name - DisplayName = $newArgs.DisplayName - Tooltip = $newArgs.Tooltip - Comment = $newArgs.Comment - IsLabelGroup = $true - } - if ($newArgs.ContainsKey('AdvancedSettings')) { $createArgs['AdvancedSettings'] = $newArgs['AdvancedSettings'] } - } - - $err = @() - try { - Invoke-WithTransientRetry -Description ("New-Label '$($LabelDef.Name)'$(if ($useLabelGroup) { ' (label group)' })") -Action { - New-Label @createArgs -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null - } - if ($useLabelGroup) { $script:tenantLabelScheme = 'Modern' } - } catch { - $err = @($_) - # -IsLabelGroup rejected -> classic scheme (or a module that doesn't - # support the parameter). Remember the scheme and recreate as a classic - # parent so the run can continue. - if ($useLabelGroup -and ($err[0].Exception.Message -match 'IsLabelGroup')) { - $script:tenantLabelScheme = 'Classic' - Write-Host " ('$($LabelDef.Name)': tenant uses the classic label scheme; creating as a classic parent.)" -ForegroundColor DarkGray - $err = @() - try { - Invoke-WithTransientRetry -Description ("New-Label '$($LabelDef.Name)' (classic parent)") -Action { - New-Label @newArgs -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null - } - } catch { - $err = @($_) - } - } - } - - if ($err.Count -eq 0) { - $existing = Get-Label -Identity $createName -ErrorAction SilentlyContinue - if ($existing) { $script:allLabels += $existing } - $idForOps = $createName - $justCreated = $true - $createNote = if ($createName -ne $LabelDef.Name) { " (Name: $createName)" } else { '' } - Write-Host " + Created label '$($LabelDef.Name)'$createNote." -ForegroundColor Green - } else { - $msg = $(Format-IPPSError $err[0]) - - # Recoverable case: a label with the same DisplayName already - # exists (often a Microsoft template label that Get-Label - # didn't return). - if ($msg -match "Duplicate display name '[^']+' is already used by label '([0-9a-f-]{36}[^']*)'") { - $existingGuid = $Matches[1] - $found = Get-Label -Identity $existingGuid -ErrorAction SilentlyContinue - # Validate hierarchy before adopting. Without this check, adopting - # a sub-label as a configured top-level parent (or vice versa) - # would cache the wrong object in $createdParents and trigger - # InvalidSubLabelPriorityException in Phase A. - if ($found) { - $callerWantsTopLevel = -not $ParentId - $foundIsSubLabel = [bool] $found.ParentId - if ($callerWantsTopLevel -eq $foundIsSubLabel) { - $callerScope = if ($callerWantsTopLevel) { 'top-level' } else { "child of parent Id $ParentId" } - $liveScope = if ($foundIsSubLabel) { "sub-label of parent Id $($found.ParentId)" } else { 'top-level' } - $detail = "Duplicate-display-name adoption for '$($LabelDef.DisplayName)' resolved to live label (Id: $existingGuid) in a DIFFERENT hierarchy scope than configured. Configured scope: $callerScope. Live scope: $liveScope. Refusing to adopt to avoid mis-pinning priority (Phase A would then fail with InvalidSubLabelPriorityException). Rename the configured label or un-nest the live label, then re-run." - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'New-Label' ` - -Target $LabelDef.DisplayName -Status 'Failed' -Detail $detail - Write-Warning " $detail" - return $null - } - } - if (-not $found) { - $found = [pscustomobject]@{ - Name = $existingGuid - DisplayName = $LabelDef.DisplayName - Guid = [guid]$existingGuid - Priority = -1 - ParentId = $ParentId - Comment = '' - } - } - $existing = $found - $idForOps = $existingGuid - $matchedBy = 'DisplayName' - $script:allLabels += $found - - if ($AdoptExisting) { - Write-Host " ~ Label '$($LabelDef.DisplayName)' already exists (Id: $existingGuid). -AdoptExisting set; updating." -ForegroundColor Yellow - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'New-Label' -Target $LabelDef.DisplayName -Status 'Adopted' -Detail "Label already exists (Id: $existingGuid). -AdoptExisting set; will update." - # Fall through to update branch. - } else { - Write-Host " = Label '$($LabelDef.DisplayName)' already exists (Id: $existingGuid). Using existing without changes (re-run with -AdoptExisting to overwrite)." -ForegroundColor DarkGray - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'New-Label' -Target $LabelDef.DisplayName -Status 'Skipped' -Detail "Label already exists (Id: $existingGuid). Re-run with -AdoptExisting to overwrite." - return $found - } - } elseif ($msg -match 'LocalizedDisplayNameConflictException' -and - $msg -match "conflict with label '([0-9a-f-]{36}[^']*)'") { - # Recoverable case: New-Label was blocked because an existing - # label (often a Microsoft built-in) already uses a conflicting - # LOCALIZED display name under the same parent — e.g. configured - # 'Specific People' vs a built-in 'Specified People' whose en-US - # localized name is 'Specific People'. Get-LabelByName missed it - # (different primary DisplayName, GUID internal Name), so adopt - # the conflicting label by GUID instead of failing the whole run. - $existingGuid = $Matches[1] - $found = Get-Label -Identity $existingGuid -ErrorAction SilentlyContinue - - # Validate hierarchy scope before adopting: refuse a - # top-level/sub-label mismatch, and for a configured sub-label - # refuse adoption of a live label under a DIFFERENT parent - # (would mis-map the label and break DLP/priority). - if ($found) { - $callerWantsTopLevel = -not $ParentId - $foundIsSubLabel = [bool] $found.ParentId - if (($callerWantsTopLevel -eq $foundIsSubLabel) -or - ($ParentId -and $found.ParentId -and ($found.ParentId -ne $ParentId))) { - $detail = "Localized-display-name conflict for '$($LabelDef.DisplayName)' resolved to live label (Id: $existingGuid) in a DIFFERENT hierarchy scope than configured (configured parent Id: '$ParentId'; live parent Id: '$($found.ParentId)'). Refusing to adopt to avoid mis-mapping. Rename the configured label, or rename/remove the conflicting live label in Purview Admin, then re-run." - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'New-Label' ` - -Target $LabelDef.DisplayName -Status 'Failed' -Detail $detail - Write-Warning " $detail" - return $null - } - } - if (-not $found) { - $found = [pscustomobject]@{ - Name = $existingGuid - DisplayName = $LabelDef.DisplayName - Guid = [guid]$existingGuid - Priority = -1 - ParentId = $ParentId - Comment = '' - } - } - $existing = $found - $idForOps = $existingGuid - $matchedBy = 'DisplayName' - $script:allLabels += $found - - # Adopt as-is (reference by GUID). We deliberately do NOT update - # / rename the conflicting label even with -AdoptExisting: that - # would mutate a Microsoft built-in label and could re-trigger - # the localized-name conflict. The label is still fully usable by - # DLP (path resolution) and publishing (resolved Name). - Write-Host " ~ Label '$($LabelDef.DisplayName)' conflicts on a localized display name with existing label '$($found.DisplayName)' (Id: $existingGuid). Adopting it as-is (not renamed)." -ForegroundColor Yellow - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'New-Label' -Target $LabelDef.DisplayName -Status 'Adopted' -Detail "Localized-display-name conflict with label (Id: $existingGuid); adopted existing label as-is by GUID (not renamed)." - return $found - } elseif ($msg -match 'ComplianceRuleAlreadyExistsInScenarioException' -or - $msg -match "compliance rule with name '[^']+' already exists in scenario") { - # Safety net: the retention-tag pre-flight didn't catch this - # (e.g. Get-ComplianceTag was inaccessible at startup, or the - # tag was created between pre-flight and now). Fail with an - # actionable message instead of a cryptic 500. - Write-Warning " Failed to create label '$($LabelDef.Name)': the name is already reserved in the ComplianceTag scenario (a retention label is using it)." - Write-Warning " Remediation:" - Write-Warning " 1. Inspect the colliding retention tag:" - Write-Warning " Get-ComplianceTag | Where-Object Name -eq '$($LabelDef.Name)'" - Write-Warning " 2. Remove or rename it, OR change the Name in Products/Purview/Config/PurviewConfig.psd1, then re-run." - Write-Warning " (The pre-flight retention-tag collision check would normally auto-rename this — check earlier output for a Get-ComplianceTag warning.)" - return $null - } elseif ($msg -match 'InvalidParentLabelInModernLabelScheme' -or $msg -match 'is not a label group') { - # Modern scheme: the parent exists as a REGULAR label (not a label - # group), so sub-labels cannot be created under it. Happens when the - # parent was created on an earlier run before scheme-branching, or was - # pre-created manually. The parent must be recreated as a label group. - $parentHint = if ($ParentId) { - $pObj = $script:allLabels | Where-Object { $_.Guid -eq $ParentId } | Select-Object -First 1 - if ($pObj) { "'$($pObj.DisplayName)' (Name: $($pObj.Name), Id: $ParentId)" } else { "Id $ParentId" } - } else { '(unknown parent)' } - Write-Warning " Failed to create sub-label '$($LabelDef.Name)': its parent $parentHint exists as a regular label, but this tenant uses the MODERN label scheme, where sub-labels require a label GROUP parent." - Write-Warning " Remediation: in Purview Admin (https://purview.microsoft.com) delete the parent label $parentHint (it has no sub-labels yet), then re-run — the tool recreates it as a label group (-IsLabelGroup)." - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'New-Label' -Target $LabelDef.DisplayName -Status 'Failed' -Detail "Modern-scheme parent is a regular label, not a label group. Delete parent $parentHint and re-run." - return $null - } else { - Write-Warning " Failed to create label '$($LabelDef.Name)': $msg" - return $null - } - } - } - - # Update display / tooltip / comment for pre-existing labels we are - # adopting or already manage. Skip when we just created the label - # because New-Label already set those values. - if (-not $justCreated -and $existing -and ($owned -or $AdoptExisting)) { - if ($PSCmdlet.ShouldProcess($idForOps, 'Set-Label (display + tooltip)')) { - try { - Invoke-WithTransientRetry -Description ("Set-Label display/tooltip '$idForOps'") -Action { - Set-Label -Identity $idForOps ` - -DisplayName $LabelDef.DisplayName ` - -Tooltip $tooltip ` - -Comment $comment ` - -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null - } - Write-Host " ~ Updated label '$($LabelDef.DisplayName)'." -ForegroundColor Yellow - } catch { - Write-Warning " Failed to update label '$($LabelDef.DisplayName)': $(Format-IPPSError $_)" - } - } - } - - # Color drift correction for pre-existing labels we just adopted or - # already manage. New labels (-justCreated) already had the colour - # applied via New-Label -AdvancedSettings so we skip the round-trip. - # AdvancedSettings is merge-not-replace on the IPPS side, so this only - # touches the `color` key and leaves any other advanced settings alone. - if (-not $justCreated -and $existing -and ($owned -or $AdoptExisting) ` - -and -not [string]::IsNullOrWhiteSpace([string]$LabelDef.Color)) { - $configColor = ([string]$LabelDef.Color).Trim() - # Wrap the drift-read in transient retry so a 502/503/504/429 hiccup - # cannot masquerade as 'missing colour' and trigger an unnecessary - # Set-Label round-trip on the next pass. On hard failure, fall back - # to the already-cached $existing object before assuming drift. - $live = $null - try { - Invoke-WithTransientRetry -Description ("Get-Label colour read '$idForOps'") -Action { - $script:__colorReadResult = Get-Label -Identity $idForOps -ErrorAction Stop - } - $live = $script:__colorReadResult - } catch { - $live = $existing - } finally { - Remove-Variable -Scope script -Name '__colorReadResult' -ErrorAction SilentlyContinue - } - $currentColor = Get-LabelAdvancedSetting -Label $live -Key 'color' - $needsUpdate = (-not $currentColor) -or - ($currentColor.ToLowerInvariant() -ne $configColor.ToLowerInvariant()) - if ($needsUpdate) { - if ($PSCmdlet.ShouldProcess($idForOps, "Set-Label color $configColor")) { - try { - Invoke-WithTransientRetry -Description ("Set-Label color '$idForOps'") -Action { - Set-Label -Identity $idForOps ` - -AdvancedSettings @{ color = $configColor } ` - -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null - } - $detail = if ($currentColor) { - "Updated label '$($LabelDef.DisplayName)' colour from $currentColor to $configColor." - } else { - "Set label '$($LabelDef.DisplayName)' colour to $configColor." - } - Write-Host " ~ $detail" -ForegroundColor Yellow - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label color' -Target $LabelDef.DisplayName -Status 'Updated' -Detail $detail - } catch { - $msg = Format-IPPSError $_ - Write-Warning " Failed to update colour for label '$($LabelDef.DisplayName)': $msg" - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label color' -Target $LabelDef.DisplayName -Status 'Failed' -Detail $msg - } - } - } - } - - # ContentType drift correction (issue #24). - # - # Only runs on the adopt/update path — when a label is freshly created, - # New-Label above already applied the desired ContentType. For pre- - # existing labels we use UNION-not-replace: live ContentType UNION - # desired ContentType. This ensures: - # * We never strip a scope a customer manually added in the portal - # (issue #24 AC3 + AC6). - # * Re-running the deploy is a no-op when the live set already - # covers the desired set (sorted-set equality, no false 'Updated' - # log lines per issue #24 AC3). - # - # The -SkipContainerLabels gate runs inside Get-DesiredContentTypeSet, - # so the UNION operand already has 'Site'/'UnifiedGroup' stripped when - # the operator opted out — but we still preserve any container bits - # the live label already has. We only ADD scope, never remove it. - if (-not $justCreated -and $existing -and ($owned -or $AdoptExisting)) { - $desiredCt = Get-DesiredContentTypeSet -LabelDef $LabelDef -SkipContainerLabels:$SkipContainerLabels.IsPresent - if ($null -ne $desiredCt -and $desiredCt.Count -gt 0) { - # Drift-read on a fresh Get-Label so we don't compare against a - # potentially-stale cached object (mirrors the colour block above). - $liveCt = $null - try { - Invoke-WithTransientRetry -Description ("Get-Label ContentType read '$idForOps'") -Action { - $script:__ctReadResult = Get-Label -Identity $idForOps -ErrorAction Stop - } - $liveCt = $script:__ctReadResult - } catch { - $liveCt = $existing - } finally { - Remove-Variable -Scope script -Name '__ctReadResult' -ErrorAction SilentlyContinue - } - $liveSet = ConvertTo-ContentTypeSet -Value ([string]$liveCt.ContentType) - $unionSet = @(@($liveSet + $desiredCt) | Sort-Object -Unique) - $isNoOp = Compare-ContentTypeSets -A $liveSet -B $unionSet - if (-not $isNoOp) { - if ($PSCmdlet.ShouldProcess($idForOps, "Set-Label ContentType $($unionSet -join ', ')")) { - try { - Invoke-WithTransientRetry -Description ("Set-Label ContentType '$idForOps'") -Action { - Set-Label -Identity $idForOps ` - -ContentType ($unionSet -join ', ') ` - -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null - } - $added = @($unionSet | Where-Object { $_ -notin $liveSet }) - $detail = if ($liveSet.Count -eq 0) { - "Set label '$($LabelDef.DisplayName)' ContentType to '$($unionSet -join ', ')'." - } else { - "Extended label '$($LabelDef.DisplayName)' ContentType from '$($liveSet -join ', ')' to '$($unionSet -join ', ')' (added: $($added -join ', ')). Live scope bits not in config were preserved (UNION-not-replace)." - } - Write-Host " ~ $detail" -ForegroundColor Yellow - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label ContentType' -Target $LabelDef.DisplayName -Status 'Updated' -Detail $detail - } catch { - $msg = Format-IPPSError $_ - Write-Warning " Failed to update ContentType for label '$($LabelDef.DisplayName)': $msg" - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label ContentType' -Target $LabelDef.DisplayName -Status 'Failed' -Detail $msg - } - } - } - } - } - - # Encryption (newly created OR managed OR -AdoptExisting) - if ($LabelDef.Encrypt -and $existing -and ($justCreated -or $owned -or $AdoptExisting)) { - $protType = if ($LabelDef.ProtectionType) { $LabelDef.ProtectionType } else { 'Template' } - $outlookBehavior = if ($LabelDef.UserDefinedOutlookBehavior) { $LabelDef.UserDefinedOutlookBehavior } else { 'None' } - # Per-label rights bundle override. When the label config carries its own - # `EncryptionRightsDefinitions`, resolve {TenantDomain} against the same - # TenantIdentity and pass to Set-LabelEncryption. Otherwise the function - # falls back to the module-level `$rights` default. Used by HCAllEmps - # (Co-Author bundle: View/Edit/Save/Copy/Print/Allow-Macros/Reply...) - # without affecting siblings that should stay on the Reviewer default. - $labelRights = $null - if ($LabelDef.EncryptionRightsDefinitions) { - $labelRights = Resolve-EncryptionRightsTokens ` - -RightsDefinitions $LabelDef.EncryptionRightsDefinitions ` - -TenantIdentity $TenantIdentity ` - -Source "Labels.$($LabelDef.Name).EncryptionRightsDefinitions" - } - $shouldProcessTarget = if ($protType -eq 'Template') { - $bundleNote = if ($labelRights) { 'custom rights' } else { "$rightsBundleName rights" } - "Apply encryption (Template / $bundleNote)" - } else { - "Apply encryption (UserDefined / Outlook=$outlookBehavior)" - } - if ($PSCmdlet.ShouldProcess($idForOps, $shouldProcessTarget)) { - try { - $encParams = @{ - Identity = $idForOps - ProtectionType = $protType - UserDefinedOutlookBehavior = $outlookBehavior - } - if ($labelRights) { $encParams['RightsDefinitions'] = $labelRights } - Set-LabelEncryption @encParams - $effectiveRights = if ($labelRights) { $labelRights } else { $rights } - $encDetail = if ($protType -eq 'Template') { - "Applied Template encryption to label '$($LabelDef.DisplayName)' with rights: $effectiveRights." - } else { - "Applied UserDefined encryption to label '$($LabelDef.DisplayName)' (Outlook=$outlookBehavior)." - } - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label encryption' -Target $LabelDef.DisplayName -Status 'Updated' -Detail $encDetail - } - catch { - $encMsg = Format-IPPSError $_ - Write-Warning " Encryption update failed for '$($LabelDef.DisplayName)': $encMsg" - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label encryption' -Target $LabelDef.DisplayName -Status 'Failed' -Detail $encMsg - } - } - } - - # Content marking (newly created OR managed OR -AdoptExisting) - # Skipped entirely when $Config.EnableContentMarking is $false (the - # toolkit-wide master switch). Per-label `ContentMark = $true` settings - # are honoured only when the master switch is on. - $contentMarkingEnabled = $true - if ($Config -and $Config.PSObject.Properties.Name -contains 'EnableContentMarking' -and $null -ne $Config.EnableContentMarking) { - $contentMarkingEnabled = [bool] $Config.EnableContentMarking - } elseif ($Config -is [hashtable] -and $Config.ContainsKey('EnableContentMarking')) { - $contentMarkingEnabled = [bool] $Config['EnableContentMarking'] - } - if ($contentMarkingEnabled -and $LabelDef.ContentMark -and $existing -and ($justCreated -or $owned -or $AdoptExisting) ` - -and $PSCmdlet.ShouldProcess($idForOps, 'Apply content marking')) { - try { - Set-LabelContentMarking -Identity $idForOps ` - -HeaderText $LabelDef.HeaderText ` - -FooterText $LabelDef.FooterText ` - -WatermarkText $LabelDef.WatermarkText - } catch { Write-Warning " Content-marking update failed for '$($LabelDef.DisplayName)': $($_.Exception.Message)" } - } - - $final = Get-Label -Identity $idForOps -ErrorAction SilentlyContinue - if ($final) { return $final } else { return $existing } -} - -# --------------------------------------------------------------------------- -# Pass 0 — upfront validation (name length + colour hex) -# -# Purview enforces a 64-character maximum on label, sub-label, and label-policy -# names. The IPPS backend rejects longer names with an empty/cryptic error, so -# we validate upfront to give partners a clear, actionable diagnostic. -# -# Label colours are passed through to `New-Label/Set-Label -AdvancedSettings -# @{color=...}` and must be valid 6-digit hex triplets (`#RRGGBB`). Anything -# else (3-digit shorthand, named colours, missing `#`) is rejected upfront -# because the backend silently drops malformed values and the colour silently -# fails to appear. -# --------------------------------------------------------------------------- -foreach ($lbl in $Config.Labels) { - if ($lbl.Name.Length -gt 64) { - throw "Sensitivity label name '$($lbl.Name)' is $($lbl.Name.Length) characters; Microsoft Purview enforces a 64-character maximum. Edit PurviewConfig.psd1 and shorten the Labels.Name field." - } - if ($lbl.DisplayName -and $lbl.DisplayName.Length -gt 64) { - throw "Sensitivity label DisplayName '$($lbl.DisplayName)' is $($lbl.DisplayName.Length) characters; Microsoft Purview enforces a 64-character maximum. Edit PurviewConfig.psd1 and shorten the Labels.DisplayName field." - } - if ($lbl.PSObject.Properties.Match('Color').Count -gt 0 -or ($lbl -is [hashtable] -and $lbl.ContainsKey('Color'))) { - $c = [string]$lbl.Color - if (-not [string]::IsNullOrWhiteSpace($c) -and $c -notmatch '^#[0-9A-Fa-f]{6}$') { - throw "Sensitivity label '$($lbl.Name)' has invalid Color '$c'; must be a 6-digit hex triplet like '#13A10E'. Edit PurviewConfig.psd1." - } - } - foreach ($sub in @($lbl.SubLabels)) { - if (-not $sub) { continue } - if ($sub.Name.Length -gt 64) { - throw "Sub-label name '$($sub.Name)' (under parent '$($lbl.Name)') is $($sub.Name.Length) characters; Microsoft Purview enforces a 64-character maximum. Edit PurviewConfig.psd1." - } - if ($sub.DisplayName -and $sub.DisplayName.Length -gt 64) { - throw "Sub-label DisplayName '$($sub.DisplayName)' is $($sub.DisplayName.Length) characters; Microsoft Purview enforces a 64-character maximum. Edit PurviewConfig.psd1." - } - if ($sub.PSObject.Properties.Match('Color').Count -gt 0 -or ($sub -is [hashtable] -and $sub.ContainsKey('Color'))) { - $sc = [string]$sub.Color - if (-not [string]::IsNullOrWhiteSpace($sc) -and $sc -notmatch '^#[0-9A-Fa-f]{6}$') { - throw "Sub-label '$($sub.Name)' (under parent '$($lbl.Name)') has invalid Color '$sc'; must be a 6-digit hex triplet like '#EAA300'. Edit PurviewConfig.psd1." - } - } - } -} -if ($Config.LabelPolicy -and $Config.LabelPolicy.Name -and $Config.LabelPolicy.Name.Length -gt 64) { - throw "Label policy name '$($Config.LabelPolicy.Name)' is $($Config.LabelPolicy.Name.Length) characters; Microsoft Purview enforces a 64-character maximum. Edit PurviewConfig.psd1 and shorten the LabelPolicy.Name field." -} - -# --------------------------------------------------------------------------- -# Pre-flight — container-scope sanity check (issue #24). -# -# When the operator passed -SkipContainerLabels (or license auto-detect did -# so for them), Setup-TenantSettings.ps1 step [5/5] skips the -# Group.Unified `EnableMIPLabels = $true` toggle. Without that tenant-side -# toggle on, the 'Site' / 'UnifiedGroup' bits we'd otherwise stamp on -# selected labels have no effect — Teams / Groups / SharePoint sites do -# not surface the labels in their picker. -# -# We strip those bits per-label inside Get-DesiredContentTypeSet (logged at -# Skipped status). The pre-flight below surfaces ONE roll-up info line so -# the operator sees the trade-off upfront in the console, even when the -# affected labels are deep in the config tree. -# --------------------------------------------------------------------------- -if ($SkipContainerLabels.IsPresent) { - $configuredContainerLabels = New-Object System.Collections.Generic.List[string] - foreach ($lbl in $Config.Labels) { - if ($lbl.ContainsKey('ContentType') -and - (ConvertTo-ContentTypeSet -Value ([string]$lbl.ContentType) | Where-Object { $_ -in $script:ContainerContentTypeBits })) { - [void]$configuredContainerLabels.Add($lbl.DisplayName) - } - if ($lbl.SubLabels) { - foreach ($s in $lbl.SubLabels) { - if ($s.ContainsKey('ContentType') -and - (ConvertTo-ContentTypeSet -Value ([string]$s.ContentType) | Where-Object { $_ -in $script:ContainerContentTypeBits })) { - [void]$configuredContainerLabels.Add("$($lbl.DisplayName)\$($s.DisplayName)") - } - } - } - } - if ($configuredContainerLabels.Count -gt 0) { - Write-Host ("[info] -SkipContainerLabels is set. Container-scope bits (Site, UnifiedGroup) will be stripped from these label(s): {0}. The labels will still be provisioned with the remaining scope (File, Email). Re-run without -SkipContainerLabels (and ensure Setup-TenantSettings.ps1 enables Group.Unified EnableMIPLabels) to make these labels selectable in Teams / Microsoft 365 Groups / SharePoint sites." -f ($configuredContainerLabels -join ', ')) -ForegroundColor DarkYellow - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Pre-flight ContentType' ` - -Target '(container-scope summary)' -Status 'Info' ` - -Detail ("-SkipContainerLabels is set; container-scope bits (Site, UnifiedGroup) will be stripped from {0} configured label(s): {1}." -f - $configuredContainerLabels.Count, ($configuredContainerLabels -join ', ')) - } -} - -# --------------------------------------------------------------------------- -# Pass 1 — create / update parent labels -# --------------------------------------------------------------------------- -Write-Host "Creating sensitivity labels..." -ForegroundColor Cyan -$script:createdParents = @{} -foreach ($lbl in $Config.Labels) { - Write-Host " Label: $($lbl.Name)" -ForegroundColor White - $parent = New-OrUpdate-Label -LabelDef $lbl - $script:createdParents[$lbl.Name] = $parent -} - -# --------------------------------------------------------------------------- -# Pass 1.5 - preflight: enforce that every cached parent is actually a -# TOP-LEVEL live label. Pass 1's New-OrUpdate-Label already surfaces a Warning -# + null cache entry when a cross-scope Name collision is detected upfront, -# but the duplicate-display-name recovery path can also unmask a sub-label. -# This unified throw gives the operator a single actionable error before -# Pass 2 wires sub-labels under the wrong object and Pass 3 Phase A explodes -# with InvalidSubLabelPriorityException on a modern-scheme tenant. -# --------------------------------------------------------------------------- -$parentScopeCollisions = @() -foreach ($lbl in $Config.Labels) { - $cached = $script:createdParents[$lbl.Name] - if (-not $cached) { continue } # Pass 1 creation failed; warning already emitted. - if ($cached.PSObject.Properties.Name -contains 'ParentId' -and $cached.ParentId) { - $parentScopeCollisions += [pscustomobject]@{ - ConfigName = $lbl.Name - DisplayName = $lbl.DisplayName - LiveGuid = if ($cached.Guid) { [string]$cached.Guid } else { 'unknown' } - LiveParentId = [string]$cached.ParentId - } - } -} -if ($parentScopeCollisions.Count -gt 0) { - $lines = foreach ($c in $parentScopeCollisions) { - " - Configured parent label '$($c.ConfigName)' (DisplayName '$($c.DisplayName)') resolved to LIVE label (Id: $($c.LiveGuid)) that is a SUB-LABEL of parent (Id: $($c.LiveParentId))." - } - $detail = @( - "Parent label scope collision detected after Pass 1. The modern label scheme strictly enforces sub-label priority ranges, so a configured top-level label that resolves to an existing sub-label cannot be reordered to priority 0." - ($lines -join "`n") - "Remediation options:" - " 1. Rename the configured label in Products/Purview/Config/PurviewConfig.psd1 (Labels.Name and Labels.DisplayName) so it no longer collides with the existing sub-label." - " 2. In Purview Admin Center (https://purview.microsoft.com), edit the colliding live sub-label to move it out from under its parent (or delete it if unused), then re-run." - " 3. If the collision is with a Microsoft-default sensitivity label, update the configured Name to a tenant-unique value." - ) -join "`n" - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Preflight-ParentScope' ` - -Target 'configured parents' -Status 'Failed' -Detail $detail - throw $detail -} - -# --------------------------------------------------------------------------- -# Pass 2 — create / update sub-labels -# --------------------------------------------------------------------------- -Write-Host "Creating sub-labels..." -ForegroundColor Cyan -foreach ($lbl in $Config.Labels) { - if (-not $lbl.SubLabels) { continue } - foreach ($sub in $lbl.SubLabels) { - Write-Host " Sub-label: $($lbl.Name)/$($sub.Name)" -ForegroundColor White - $parentObj = $script:createdParents[$lbl.Name] - if (-not $parentObj) { - if ($WhatIfPreference) { - Write-Host " What if: parent '$($lbl.Name)' does not yet exist; would create sub-label '$($sub.Name)' under it." -ForegroundColor DarkYellow - } else { - Write-Warning "Parent label '$($lbl.Name)' not available; skipping sub-label." - } - continue - } - $null = New-OrUpdate-Label -LabelDef $sub -ParentId $parentObj.Guid - } -} - -# --------------------------------------------------------------------------- -# Pass 3 — set label priority (least sensitive first → priority 0) -# -# Purview enforces two ordering constraints that break a naive -# "Set-Label -Priority $i" loop with a flat global counter: -# (1) a top-level label can only swap with another top-level slot, and -# (2) a sub-label can only move within its parent's contiguous block. -# Trying to set a child to a top-level slot (or vice versa) fails with -# InvalidLabelPriorityException / InvalidSubLabelPriorityException. -# -# Workaround — the standard Purview reorder pattern: -# Phase A (top-level): iterate $Config.Labels in REVERSE order and push -# each parent to priority 0. Sub-blocks move with their parents. -# This shoves any unmanaged top-level labels (e.g. the MS-managed -# "Personal") to the back automatically. -# Phase B (sub-labels): for each parent, iterate its sub-labels in -# REVERSE order and push each to (parent.Priority + 1). Within the -# parent's block this rotates them into config order. -# Both phases pre-check whether the order is already correct so re-runs -# are no-ops (and avoid the "0 is not a valid priority for this label" -# error you get when Set-Label asks for a label's current slot). -# --------------------------------------------------------------------------- -Write-Host "Setting label priority order..." -ForegroundColor Cyan - -# Identify unmanaged top-level labels (e.g. tenant-provisioned 'Personal' -# from the Microsoft default sensitivity labels policy). 'Personal' is -# pinned at priority 0 (the lowest) so it stays out of the way of users -# but is still available; other unmanaged labels follow it. -$configuredDisplayNamesEarly = @() -foreach ($lbl in $Config.Labels) { - $configuredDisplayNamesEarly += $lbl.DisplayName - if ($lbl.SubLabels) { $configuredDisplayNamesEarly += $lbl.SubLabels.DisplayName } -} - -# Refresh the $allLabels cache with LIVE state from Purview BEFORE slot -# accounting and Phase A. A single fresh snapshot is reused for unmanaged -# top-level detection AND live sub-label counts so both views agree (and -# downstream Resolve-ConfigParentLabel / Get-LabelByName see the same data). -# The initial snapshot at the top of the script was taken before Pass 2 -# created labels (and before any prior Pass 3 run shuffled priorities); -# reading priorities from a stale cache causes Phase A to skip labels that -# look "already correct" but aren't, which is how 'Public' gets stranded -# at the wrong end of the priority list on multi-run tenants. -$script:allLabels = @(Get-Label -ErrorAction SilentlyContinue | Where-Object { -not (Test-LabelSoftDeleted $_) }) -$allTopLevel = @($script:allLabels | Where-Object { -not $_.ParentId }) -$unmanagedTopLevel = @($allTopLevel | Where-Object { $configuredDisplayNamesEarly -notcontains $_.DisplayName }) - -# Total slot footprint of unmanaged top-level labels = each unmanaged parent -# PLUS its live direct children. Personal has no children in practice, but -# counting children defensively handles arbitrary unmanaged hierarchies and -# keeps configured-label slot accounting correct regardless. -$unmanagedFootprint = 0 -foreach ($u in $unmanagedTopLevel) { - $unmanagedFootprint++ - if ($u.Guid) { - $unmanagedFootprint += @($script:allLabels | Where-Object { $_.ParentId -eq $u.Guid }).Count - } -} - -# Build pin-order: 'Personal' FIRST (so it gets pushed LAST in reverse -# iteration and lands at slot 0), then any other unmanaged labels. -$personalLbl = @($unmanagedTopLevel | Where-Object { $_.DisplayName -eq 'Personal' }) -$otherUnmanaged = @($unmanagedTopLevel | Where-Object { $_.DisplayName -ne 'Personal' }) -$pinOrder = @() -if ($personalLbl.Count -gt 0) { $pinOrder += $personalLbl[0] } -if ($otherUnmanaged.Count -gt 0) { $pinOrder += $otherUnmanaged } - -# Compute the expected GLOBAL priority for each configured top-level label. -# Slots 0..unmanagedFootprint-1 are reserved for unmanaged top-level labels -# and their children (Personal at slot 0); configured labels start at -# $unmanagedFootprint. -# -# Each managed parent's slot footprint is 1 (parent) + LIVE direct sub-label -# count. Using LIVE count (not config count) is critical when a managed -# parent has extra "phantom" sub-labels left over from a previous failed -# run: those extras still occupy slots in the parent's block, so a config- -# only count under-reserves and downstream parents land at the wrong slot. -# Use MAX(live, config) so we don't under-reserve when the live snapshot is -# missing newly-created children due to eventual consistency. -$expectedParentPriority = @{} -$cursor = $unmanagedFootprint -foreach ($lbl in $Config.Labels) { - $expectedParentPriority[$lbl.DisplayName] = $cursor - $cursor++ - $parentObjForCount = Resolve-ConfigParentLabel -LabelDef $lbl - $configChildCount = if ($lbl.SubLabels) { $lbl.SubLabels.Count } else { 0 } - $liveChildren = @() - if ($parentObjForCount -and $parentObjForCount.Guid) { - $liveChildren = @($script:allLabels | Where-Object { $_.ParentId -eq $parentObjForCount.Guid }) - } - $effectiveChildCount = if ($liveChildren.Count -gt $configChildCount) { $liveChildren.Count } else { $configChildCount } - $cursor += $effectiveChildCount - - # Identity-based phantom detection: warn about any live sub-label whose - # Name AND DisplayName don't match any configured sub-label under this - # parent. They occupy slot(s) inside the parent's block but won't be - # reordered by Phase B; admin should rename or remove them in Purview UI. - if ($parentObjForCount -and $liveChildren.Count -gt 0) { - $configChildNames = @() - $configChildDisplayNames = @() - if ($lbl.SubLabels) { - $configChildNames = @($lbl.SubLabels | ForEach-Object { $_.Name }) - $configChildDisplayNames = @($lbl.SubLabels | ForEach-Object { $_.DisplayName }) - } - $extras = @($liveChildren | Where-Object { - ($configChildNames -notcontains $_.Name) -and ($configChildDisplayNames -notcontains $_.DisplayName) - }) - if ($extras.Count -gt 0) { - $extrasDesc = ($extras | ForEach-Object { "'$($_.DisplayName)' [Name=$($_.Name); Guid=$($_.Guid)]" }) -join ', ' - Write-Warning (" Managed parent '{0}' has {1} unmanaged live sub-label(s): {2}. They occupy slot(s) in the parent's block; rename or remove them in Purview Admin if not intentional." -f $lbl.DisplayName, $extras.Count, $extrasDesc) - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Detect-PhantomSubLabel' ` - -Target $lbl.DisplayName -Status 'Info' ` - -Detail "Live sub-label count ($($liveChildren.Count)) exceeds configured ($configChildCount). Extras: $extrasDesc" - } - } -} - -# Pre-check: are config parents already in the right slots AND is Personal at 0? -# Use Get-LiveLabelByObject (bulk-fetch fallback) so the precheck succeeds even -# for Microsoft-default labels where Get-Label -Identity by Guid returns null. -$parentsAlreadyCorrect = $true -foreach ($lbl in $Config.Labels) { - $obj = Resolve-ConfigParentLabel -LabelDef $lbl - if (-not $obj) { - $parentsAlreadyCorrect = $false - break - } - $live = Get-LiveLabelByObject -LabelObject $obj - if (-not $live) { - $parentsAlreadyCorrect = $false - break - } - if ($live.Priority -ne $expectedParentPriority[$lbl.DisplayName]) { - $parentsAlreadyCorrect = $false - break - } -} -$personalAlreadyAtZero = $true -if ($personalLbl.Count -gt 0) { - $pNow = Get-LiveLabelByObject -LabelObject $personalLbl[0] - if ($pNow -and $pNow.Priority -ne 0) { $personalAlreadyAtZero = $false } -} - -if (-not $parentsAlreadyCorrect -or -not $personalAlreadyAtZero) { - # Phase A — config top-levels: reverse-push to 0. Final config order: - # Config.Labels[0] at slot 0, Config.Labels[1] at slot 1, ... - # - # Each Set-Label is verified via a fresh bulk Get-Label and retried with - # alternate Identity forms if the priority did not actually change. This - # is the fix for the classic "Public stuck at the bottom" bug — IPPS - # Set-Label silently no-ops on Microsoft-default labels when called with - # a Guid identity, but accepts DisplayName for the same label. - for ($i = $Config.Labels.Count - 1; $i -ge 0; $i--) { - $lbl = $Config.Labels[$i] - $obj = Resolve-ConfigParentLabel -LabelDef $lbl - if (-not $obj) { - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label priority=0' ` - -Target $lbl.DisplayName -Status 'Skipped' ` - -Detail "Configured label could not be resolved in createdParents cache or via Get-LabelByName. Bulk Get-Label may not be surfacing it (common for Microsoft-default labels)." - continue - } - $identityForShouldProcess = Get-StableLabelIdentity -LabelObject $obj - if ($PSCmdlet.ShouldProcess($identityForShouldProcess, "Set priority=0 (top-level reorder)")) { - $r = Set-LabelPriorityWithVerify -LabelObject $obj -TargetPriority 0 -DisplayNameForLog $lbl.DisplayName - if ($r.Success) { - # Refresh cache so later phases / verification see the new state. - $script:createdParents[$lbl.Name] = $r.Live - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label priority=0' ` - -Target $lbl.DisplayName -Status 'Succeeded' ` - -Detail ("Used identity '{0}'. Attempts: {1}" -f $r.UsedIdentity, ($r.Attempts -join ' | ')) - } else { - $detail = "All identity forms failed to update priority. Attempts: " + ($r.Attempts -join ' | ') - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label priority=0' ` - -Target $lbl.DisplayName -Status 'Failed' -Detail $detail - throw "Label priority reorder failed for '$($lbl.DisplayName)'. $detail. Re-run after addressing the underlying error." - } - } - } - - # Phase A2 — pin unmanaged top-levels (Personal first in $pinOrder so - # it gets pushed LAST and ends up at slot 0). Uses the same verified - # helper so MS-default unmanaged labels (e.g. 'Personal') also benefit - # from identity-form fallback. - for ($i = $pinOrder.Count - 1; $i -ge 0; $i--) { - $u = $pinOrder[$i] - $uLive = Get-LiveLabelByObject -LabelObject $u - if (-not $uLive) { - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label priority=0 (unmanaged)' ` - -Target $u.DisplayName -Status 'Skipped' ` - -Detail "Bulk Get-Label could not resolve unmanaged top-level label '$($u.DisplayName)'. May have been deleted between Phase A and Phase A2." - continue - } - if ($PSCmdlet.ShouldProcess($uLive.Name, "Set priority=0 (pin unmanaged label)")) { - $r = Set-LabelPriorityWithVerify -LabelObject $uLive -TargetPriority 0 -DisplayNameForLog $u.DisplayName - if (-not $r.Success) { - $detail = "All identity forms failed to pin unmanaged label. Attempts: " + ($r.Attempts -join ' | ') - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label priority=0 (unmanaged)' ` - -Target $u.DisplayName -Status 'Failed' -Detail $detail - throw "Label priority reorder failed for unmanaged label '$($u.DisplayName)'. $detail. Re-run after addressing the underlying error." - } - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label priority=0 (unmanaged)' ` - -Target $u.DisplayName -Status 'Succeeded' ` - -Detail ("Used identity '{0}'." -f $r.UsedIdentity) - } - } -} - -# Phase B — sub-label reorder within each parent's block -# -# Sub-label priorities are GLOBAL (not relative to the parent), but Purview -# enforces that a sub-label can only move within its parent's contiguous block. -# Derive the first child slot from the LIVE parent priority (refreshed via -# Get-LiveLabelByObject) and sanity-check against the expected slot so we -# never target a slot outside the parent's actual live block — which causes -# InvalidSubLabelPriorityException when Phase A's expected slot calculation -# is off (e.g. tenant has phantom unmanaged sub-labels under a managed parent). -foreach ($lbl in $Config.Labels) { - if (-not $lbl.SubLabels -or $lbl.SubLabels.Count -eq 0) { continue } - $parentMeta = Resolve-ConfigParentLabel -LabelDef $lbl - if (-not $parentMeta) { - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Phase B parent resolve' ` - -Target $lbl.DisplayName -Status 'Skipped' ` - -Detail "Parent label could not be resolved; sub-label reorder skipped for '$($lbl.DisplayName)'." - continue - } - $parentObj = Get-LiveLabelByObject -LabelObject $parentMeta - if (-not $parentObj) { $parentObj = $parentMeta } - if (-not $parentObj.Guid) { - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Phase B parent resolve' ` - -Target $lbl.DisplayName -Status 'Skipped' ` - -Detail "Parent label resolved but has no Guid; cannot match sub-labels by ParentId. Skipping." - continue - } - - # Phase B derives child slots from the LIVE parent priority (not from the - # statically computed expected) so it stays correct even if the parent - # ended up at a slightly different slot than expected (e.g. IPPS eventual - # consistency right after Phase A). Sanity-check live vs expected first; - # if they disagree after one refresh, defer Phase B for this parent so - # the verifier/recovery can fix the parent on a subsequent pass. - $expectedParentSlot = [int]$expectedParentPriority[$lbl.DisplayName] - if ($parentObj.Priority -ne $expectedParentSlot) { - Start-Sleep -Seconds 2 - $refreshed = Get-LiveLabelByObject -LabelObject $parentMeta - if ($refreshed) { $parentObj = $refreshed } - } - if ($parentObj.Priority -ne $expectedParentSlot) { - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Phase B sub-label reorder' ` - -Target $lbl.DisplayName -Status 'Skipped' ` - -Detail "Parent priority $($parentObj.Priority) does not match expected $expectedParentSlot after refresh; deferring sub-label reorder so verifier can recover parent first." - Write-Warning " Sub-label reorder deferred for '$($lbl.DisplayName)' (parent at slot $($parentObj.Priority), expected $expectedParentSlot)." - continue - } - $firstChildSlot = [int]$parentObj.Priority + 1 - - # Pre-check: are the sub-labels already in config order? - $childrenCorrect = $true - $expectedSlot = $firstChildSlot - foreach ($sub in $lbl.SubLabels) { - $subMeta = Get-LabelByName -Name $sub.Name -DisplayName $sub.DisplayName -ParentId $parentObj.Guid -Scope 'Parent' - if (-not $subMeta) { $childrenCorrect = $false; break } - $subLive = Get-LiveLabelByObject -LabelObject $subMeta - if (-not $subLive -or $subLive.Priority -ne $expectedSlot) { $childrenCorrect = $false; break } - $expectedSlot++ - } - if ($childrenCorrect) { continue } - - for ($i = $lbl.SubLabels.Count - 1; $i -ge 0; $i--) { - $sub = $lbl.SubLabels[$i] - $subMeta = Get-LabelByName -Name $sub.Name -DisplayName $sub.DisplayName -ParentId $parentObj.Guid -Scope 'Parent' - if (-not $subMeta) { - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action "Set-Label priority=$firstChildSlot" ` - -Target $sub.DisplayName -Status 'Skipped' ` - -Detail "Sub-label could not be resolved under parent '$($lbl.DisplayName)'." - continue - } - $subIdentForShouldProcess = Get-StableLabelIdentity -LabelObject $subMeta - if ($PSCmdlet.ShouldProcess($subIdentForShouldProcess, "Set priority=$firstChildSlot (sub-label reorder)")) { - $r = Set-LabelPriorityWithVerify -LabelObject $subMeta -TargetPriority $firstChildSlot ` - -DisplayNameForLog ("{0}/{1}" -f $lbl.DisplayName, $sub.DisplayName) -IsSubLabel - if (-not $r.Success) { - $detail = "All identity forms failed to update sub-label priority. Attempts: " + ($r.Attempts -join ' | ') - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action "Set-Label priority=$firstChildSlot" ` - -Target $sub.DisplayName -Status 'Failed' -Detail $detail - # Sub-label failures are warned but don't throw — the post-reorder - # verification covers top-level priorities; sub-label ordering - # within a block is best-effort and recoverable in Purview UI. - Write-Warning " Sub-label priority update failed for '$($sub.DisplayName)'. See run log for details." - } - } - } -} - -# --------------------------------------------------------------------------- -# Pass 3 verification — confirm every configured top-level label is at its -# expected priority. If not, attempt ONE recovery push, then re-verify and -# throw with a diagnostic detail if still wrong. -# -# This catches silent skips in Phase A (e.g. a Microsoft-default label that -# bulk Get-Label fails to surface), which is how 'Public' previously got -# stranded at the wrong priority without any error in the run log. -# --------------------------------------------------------------------------- -$script:allLabels = @(Get-Label -ErrorAction SilentlyContinue | Where-Object { -not (Test-LabelSoftDeleted $_) }) - -function Test-ParentPrioritiesCorrect { - $mismatches = @() - foreach ($lbl in $Config.Labels) { - $obj = Resolve-ConfigParentLabel -LabelDef $lbl - if (-not $obj) { - $mismatches += [pscustomobject]@{ - Label = $lbl - Reason = 'unresolved' - Identity = $null - Actual = $null - Expected = $expectedParentPriority[$lbl.DisplayName] - } - continue - } - # Use the robust bulk-fetch resolver instead of Get-Label -Identity, - # because Get-Label -Identity by Guid returns null for some - # Microsoft-default labels even when the label clearly exists in the - # tenant. That false-null is what previously made the verifier - # report "live-lookup-failed" for Public. - $live = Get-LiveLabelByObject -LabelObject $obj - $actual = if ($live) { $live.Priority } else { $obj.Priority } - $expected = $expectedParentPriority[$lbl.DisplayName] - if ($actual -ne $expected) { - $mismatches += [pscustomobject]@{ - Label = $lbl - Reason = if ($live) { 'wrong-priority' } else { 'live-lookup-failed' } - Identity = if ($obj.Guid) { [string]$obj.Guid } else { [string]$obj.Name } - Actual = $actual - Expected = $expected - } - } - } - return ,$mismatches -} - -$mismatches = Test-ParentPrioritiesCorrect -if ($mismatches.Count -gt 0) { - Write-Host " ! Priority order off after primary reorder; attempting recovery..." -ForegroundColor Yellow - # Reverse-iterate Config.Labels (same direction as Phase A) and push any - # mismatched label to slot 0 using the same verified helper. The reverse - # direction preserves intent: the LAST push wins slot 0, so we need to - # push every label above the lowest mismatched label as well. - $mismatchedNames = @{} - foreach ($m in $mismatches) { $mismatchedNames[$m.Label.Name] = $true } - for ($i = $Config.Labels.Count - 1; $i -ge 0; $i--) { - $lbl = $Config.Labels[$i] - if (-not $mismatchedNames.ContainsKey($lbl.Name)) { continue } - $obj = Resolve-ConfigParentLabel -LabelDef $lbl - if (-not $obj) { - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label priority=0 (recovery)' ` - -Target $lbl.DisplayName -Status 'Failed' ` - -Detail "Cannot resolve label for recovery push." - continue - } - $idForShouldProcess = Get-StableLabelIdentity -LabelObject $obj - if ($PSCmdlet.ShouldProcess($idForShouldProcess, "Set priority=0 (priority recovery)")) { - $r = Set-LabelPriorityWithVerify -LabelObject $obj -TargetPriority 0 -DisplayNameForLog $lbl.DisplayName - if ($r.Success) { - $script:createdParents[$lbl.Name] = $r.Live - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label priority=0 (recovery)' ` - -Target $lbl.DisplayName -Status 'Succeeded' ` - -Detail ("Used identity '{0}'. Attempts: {1}" -f $r.UsedIdentity, ($r.Attempts -join ' | ')) - } else { - $detail = "All identity forms failed during recovery. Attempts: " + ($r.Attempts -join ' | ') - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label priority=0 (recovery)' ` - -Target $lbl.DisplayName -Status 'Failed' -Detail $detail - Write-Warning " Recovery Set-Label failed for '$($lbl.DisplayName)': $detail" - } - } - } - # Re-pin unmanaged labels (Personal last so it lands at 0) after the recovery push. - for ($i = $pinOrder.Count - 1; $i -ge 0; $i--) { - $u = $pinOrder[$i] - $uLive = Get-LiveLabelByObject -LabelObject $u - if (-not $uLive) { continue } - if ($PSCmdlet.ShouldProcess($uLive.Name, "Set priority=0 (recovery pin unmanaged)")) { - $r = Set-LabelPriorityWithVerify -LabelObject $uLive -TargetPriority 0 -DisplayNameForLog $u.DisplayName - if (-not $r.Success) { - Write-Warning " Recovery Set-Label failed for unmanaged label '$($u.DisplayName)'. See run log for diagnostic detail." - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label priority=0 (recovery unmanaged)' ` - -Target $u.DisplayName -Status 'Failed' ` - -Detail ("All identity forms failed. Attempts: " + ($r.Attempts -join ' | ')) - } - } - } - $script:allLabels = @(Get-Label -ErrorAction SilentlyContinue | Where-Object { -not (Test-LabelSoftDeleted $_) }) - $mismatches = Test-ParentPrioritiesCorrect -} -if ($mismatches.Count -gt 0) { - $lines = foreach ($m in $mismatches) { - if ($m.Reason -eq 'unresolved') { - " - '$($m.Label.DisplayName)' could not be resolved as a TOP-LEVEL live label (expected priority $($m.Expected)). Likely a scope collision (a sub-label with the same Name exists in this tenant). See earlier Pass 1 warnings or the Preflight-ParentScope log entry; rename the configured label or un-nest the live label, then re-run." - } else { - " - '$($m.Label.DisplayName)' (Id: $($m.Identity)) is at priority $($m.Actual); expected $($m.Expected) [reason: $($m.Reason)]" - } - } - $detail = "Sensitivity label priority order is incorrect after reorder + recovery:`n" + ($lines -join "`n") - Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Verify-LabelPriority' ` - -Target 'all top-level labels' -Status 'Failed' -Detail $detail - throw $detail -} - -# Surface MS-managed labels (e.g. tenant-provisioned 'Personal') that are -# not in our config. They've been pinned to the lowest priority slots -# above (Personal at 0). The warning below is informational only — they -# remain in the tenant and won't appear to users because they aren't in -# our PublishedLabels filter. -foreach ($tl in $allTopLevel) { - if ($configuredDisplayNamesEarly -notcontains $tl.DisplayName) { - $now = Get-Label -Identity $tl.Name -ErrorAction SilentlyContinue - $curPri = if ($now) { $now.Priority } else { $tl.Priority } - Write-Host " Unmanaged top-level label '$($tl.DisplayName)' is at priority $curPri (created by '$($tl.CreatedBy)'). Not in SMBTool config — left in place." -ForegroundColor DarkGray - } -} - -# --------------------------------------------------------------------------- -# Pass 4 — publish via label policy -# --------------------------------------------------------------------------- -Write-Host "Publishing label policy..." -ForegroundColor Cyan - -$policyCfg = $Config.LabelPolicy -$existingPolicy = Get-LabelPolicy -Identity $policyCfg.Name -ErrorAction SilentlyContinue -$policyOwned = Test-Owned -Object $existingPolicy -Tag $tag - -if ($existingPolicy -and -not $policyOwned) { - Write-Host " Label policy '$($policyCfg.Name)' exists but is not tagged as toolkit-managed. Updating in place (will be re-tagged)." -ForegroundColor DarkYellow -} - -# Resolve default label by name OR display name (handles adopted labels). -# Parent-aware: the default applied label may be a sub-label, and on tenants -# that already had the label it was adopted from (so its live internal Name is -# a GUID), a top-level-only lookup would miss it. Resolve-ConfiguredDefaultLabel -# matches sub-label defaults within their parent's scope. -$defaultLabelObj = Resolve-ConfiguredDefaultLabel -DefaultName $policyCfg.DefaultLabel -if (-not $defaultLabelObj) { - if ($WhatIfPreference) { - Write-Host " What if: default label '$($policyCfg.DefaultLabel)' would be created earlier in this run; previewing publish step with placeholder GUID." -ForegroundColor DarkYellow - $defaultLabelObj = [pscustomobject]@{ - Name = $policyCfg.DefaultLabel - Guid = [guid]::Empty - } - } else { - throw "Default label '$($policyCfg.DefaultLabel)' was not found after creation." - } -} - -# Resolve the optional Outlook-only default. When set, this becomes a -# separate per-app default for email; documents continue to use DefaultLabel. -$emailDefaultLabelObj = $null -if ($policyCfg.DefaultLabelForEmail) { - $emailDefaultLabelObj = Resolve-ConfiguredDefaultLabel -DefaultName $policyCfg.DefaultLabelForEmail - if (-not $emailDefaultLabelObj) { - if ($WhatIfPreference) { - Write-Host " What if: email default label '$($policyCfg.DefaultLabelForEmail)' would be created earlier in this run; previewing publish step with placeholder GUID." -ForegroundColor DarkYellow - $emailDefaultLabelObj = [pscustomobject]@{ - Name = $policyCfg.DefaultLabelForEmail - Guid = [guid]::Empty - } - } else { - throw "Email default label '$($policyCfg.DefaultLabelForEmail)' was not found after creation." - } - } -} - -# A modern-scheme Label Group cannot be a default applied label (DefaultLabelId -# / OutlookDefaultLabel must point at an assignable label). If a resolved -# default or email-default is a Label Group, substitute its appropriate child -# (prefer 'Anyone (unrestricted)') and force-publish that child so it is a valid -# default. On classic tenants / flat labels this is a no-op (Test-IsLabelGroup -# is False), so behaviour there is unchanged. -$forcePublishNames = @() -if ($defaultLabelObj -and (Test-IsLabelGroup $defaultLabelObj)) { - $child = Resolve-LabelGroupDefaultChild -GroupLabel $defaultLabelObj - if (-not $child) { - throw "Default label '$($policyCfg.DefaultLabel)' is a Label Group with no child to act as the default applied label. Set LabelPolicy.DefaultLabel to a sub-label." - } - Write-Host " Document default '$($policyCfg.DefaultLabel)' is a Label Group; using its child '$($child.DisplayName)' as the default applied label." -ForegroundColor DarkYellow - $defaultLabelObj = $child - $forcePublishNames += $child.Name -} -if ($emailDefaultLabelObj -and (Test-IsLabelGroup $emailDefaultLabelObj)) { - $child = Resolve-LabelGroupDefaultChild -GroupLabel $emailDefaultLabelObj - if ($child) { - Write-Host " Email default '$($policyCfg.DefaultLabelForEmail)' is a Label Group; using its child '$($child.DisplayName)' as the Outlook default." -ForegroundColor DarkYellow - $emailDefaultLabelObj = $child - $forcePublishNames += $child.Name - } else { - Write-Warning " Email default '$($policyCfg.DefaultLabelForEmail)' is a Label Group with no child to use; omitting the Outlook-specific default." - $emailDefaultLabelObj = $null - } -} - -# Build the published-labels filter set (if configured). When sub-labels are -# listed, their parents are auto-included so the Purview hierarchy stays -# valid (Purview rejects publishing a sub-label without its parent). -$publishFilter = $null -if ($policyCfg.PublishedLabels -and @($policyCfg.PublishedLabels).Count -gt 0) { - $publishFilter = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) - foreach ($n in $policyCfg.PublishedLabels) { if ($n) { [void] $publishFilter.Add($n) } } - foreach ($lbl in $Config.Labels) { - foreach ($s in @($lbl.SubLabels)) { - if ($s -and $publishFilter.Contains($s.Name)) { [void] $publishFilter.Add($lbl.Name) } - } - } - Write-Host " Publishing only: $([string]::Join(', ', @($publishFilter)))" -ForegroundColor Cyan -} - -# Collect every label (parents + subs) for the policy. We resolve each one -# to its actual internal Name -- which may differ from the config Name when -# matched by DisplayName on an adopted label. -$allLabelNames = @() -foreach ($lbl in $Config.Labels) { - $resolved = Get-LabelByName -Name $lbl.Name -DisplayName $lbl.DisplayName -Scope 'TopLevel' - $includeParent = (-not $publishFilter) -or $publishFilter.Contains($lbl.Name) - if ($includeParent) { - if ($resolved) { - if (Test-IsLabelGroup $resolved) { - # Modern-scheme Label Group: publishing the parent directly fails - # ("Label group(s) '' can not be published"). Skip it — the - # service auto-includes the group when a sub-label is published. - Write-Host " Skipping parent '$($lbl.DisplayName)' from the publish set: it is a modern Label Group (auto-included via its sub-labels)." -ForegroundColor DarkGray - } else { - $allLabelNames += $resolved.Name - } - } elseif ($WhatIfPreference) { - $allLabelNames += $lbl.Name - } - } - if ($lbl.SubLabels) { - $parentId = if ($resolved) { $resolved.Guid } else { $null } - foreach ($s in $lbl.SubLabels) { - $subScope = if ($parentId) { 'Parent' } else { 'Any' } - $rs = Get-LabelByName -Name $s.Name -DisplayName $s.DisplayName -ParentId $parentId -Scope $subScope - $includeSub = (-not $publishFilter) -or $publishFilter.Contains($s.Name) - if (-not $includeSub) { continue } - if ($rs) { - $allLabelNames += $rs.Name - } elseif ($WhatIfPreference) { - $allLabelNames += $s.Name - } - } - } -} - -# Force-publish any child substituted in for a Label-Group default/email-default -# above (e.g. General\Anyone (unrestricted)) so the default points at a label -# that is actually in the policy. -foreach ($fn in $forcePublishNames) { - if ($fn -and ($allLabelNames -notcontains $fn)) { $allLabelNames += $fn } -} - -$advanced = @{ - DefaultLabelId = $defaultLabelObj.Guid.ToString() -} -if ($emailDefaultLabelObj) { - # IPPS advanced setting that overrides the document default just for - # Outlook (email). With this set, new docs use DefaultLabelId and new - # mails use OutlookDefaultLabel. - $advanced['OutlookDefaultLabel'] = $emailDefaultLabelObj.Guid.ToString() -} -if ($policyCfg.MandatoryLabelling) { $advanced['Mandatory'] = 'True' } -if ($policyCfg.DowngradeJustification) { $advanced['RequireDowngradeJustification'] = 'True' } -# "Inherit label from attachments": when an email has labelled attachments, apply -# the highest-priority attachment label to the email. 'Automatic' applies it -# silently; 'Recommended' prompts the user. Requires labels scoped to Files + Emails -# (our published labels are). Omitted/empty in config leaves the feature off. -if ($policyCfg.AttachmentAction) { $advanced['AttachmentAction'] = [string]$policyCfg.AttachmentAction } - -if (-not $existingPolicy) { - if ($PSCmdlet.ShouldProcess($policyCfg.Name, 'New-LabelPolicy (publish to All)')) { - try { - Invoke-WithTransientRetry -Description ("New-LabelPolicy '$($policyCfg.Name)'") -AlreadyExistsIsSuccess -Action { - New-LabelPolicy ` - -Name $policyCfg.Name ` - -Comment "$tag $($policyCfg.Comment)" ` - -Labels $allLabelNames ` - -ExchangeLocation 'All' ` - -AdvancedSettings $advanced ` - -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null - } - Write-Host " + Created label policy '$($policyCfg.Name)'." -ForegroundColor Green - } catch { - Write-Warning " Failed to create label policy '$($policyCfg.Name)': $(Format-IPPSError $_)" - } - } -} else { - # Diff existing published labels vs target so we can REMOVE any that - # used to be published but are no longer in PublishedLabels, AND only - # ADD labels that aren't already published. Purview's - # Set-LabelPolicy -AddLabels rejects the entire call with - # LabelAlreadyPublishedException if any AddLabels entry is already - # in the policy, so the diff is required for idempotency. - $labelsToRemove = @() - $labelsToAdd = @() - if ($existingPolicy.Labels) { - # Internal Names of all live Label Groups. When a sub-label is published, - # the service auto-adds its parent group to the stored policy (e.g. - # 'ConfidentialGroup'). We must never try to remove those — they are - # auto-managed — or every re-run would churn. We also never ADD a group - # (the publish builder above already excludes groups from $allLabelNames). - $groupNames = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) - foreach ($l in $allLabels) { - if ((Test-IsLabelGroup $l) -and $l.Name) { [void] $groupNames.Add([string]$l.Name) } - } - $targetSet = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) - foreach ($n in $allLabelNames) { if ($n) { [void] $targetSet.Add($n) } } - $existingSet = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) - foreach ($n in @($existingPolicy.Labels)) { - if ($n) { [void] $existingSet.Add($n) } - if ($n -and -not $targetSet.Contains($n) -and -not $groupNames.Contains($n)) { $labelsToRemove += $n } - } - foreach ($n in $allLabelNames) { - if ($n -and -not $existingSet.Contains($n)) { $labelsToAdd += $n } - } - } else { - $labelsToAdd = $allLabelNames - } - if ($PSCmdlet.ShouldProcess($policyCfg.Name, 'Set-LabelPolicy (refresh labels + advanced settings)')) { - $setArgs = @{ - Identity = $policyCfg.Name - Comment = "$tag $($policyCfg.Comment)" - AdvancedSettings = $advanced - ErrorAction = 'Stop' - WarningAction = 'SilentlyContinue' - Confirm = $false - } - if ($labelsToAdd.Count -gt 0) { - $setArgs['AddLabels'] = $labelsToAdd - Write-Host " Publishing newly-added labels: $([string]::Join(', ', $labelsToAdd))" -ForegroundColor DarkGray - } - if ($labelsToRemove.Count -gt 0) { - $setArgs['RemoveLabels'] = $labelsToRemove - Write-Host " Unpublishing previously-published labels: $([string]::Join(', ', $labelsToRemove))" -ForegroundColor DarkYellow - } - try { - Invoke-WithTransientRetry -Description ("Set-LabelPolicy '$($policyCfg.Name)'") -Action { - Set-LabelPolicy @setArgs | Out-Null - } - if ($labelsToAdd.Count -eq 0 -and $labelsToRemove.Count -eq 0) { - Write-Host " ~ Refreshed label policy '$($policyCfg.Name)' (label set already in sync)." -ForegroundColor Yellow - } else { - Write-Host " ~ Updated label policy '$($policyCfg.Name)'." -ForegroundColor Yellow - } - } catch { - Write-Warning " Failed to update label policy '$($policyCfg.Name)': $(Format-IPPSError $_)" - } - } -} - -Write-Host "Sensitivity labels and policy complete." -ForegroundColor Green -Write-Host "NOTE: Label policy changes can take up to 24 hours to propagate to clients." -ForegroundColor DarkYellow +#requires -Version 7.0 +<# +.SYNOPSIS + Creates and publishes Microsoft Purview sensitivity labels (SMB profile). + +.DESCRIPTION + SMB-tuned subset of the Microsoft default-sensitivity-labels-policies + spec (https://learn.microsoft.com/en-us/purview/default-sensitivity-labels-policies). + Trims the full 10-label MS spec down to 8 labels that fit a typical + small/mid-business deployment, with encryption only at the top tier: + + * Public — no protection + * General — no protection (DEFAULT for email) + * Confidential — no protection (parent) + * Confidential \ All Employees — Footer "Classified as Confidential" (DEFAULT for documents) + * Confidential \ Specific People — Footer + UserDefined encryption (Outlook: Do Not Forward; prompt in Word/PPT/Excel) + * Confidential \ Internal Exception — Footer "Classified as Confidential" + * Highly Confidential — Watermark "HIGHLY CONFIDENTIAL" + * Highly Confidential \ All Employees — Footer + Template encryption (all users in your tenant only) + * Highly Confidential \ Specific People — Footer + UserDefined encryption (Outlook: Do Not Forward; prompt in Word/PPT/Excel) + * Highly Confidential \ Internal Exception — Footer + Template encryption (all users in your tenant only) + + Encryption is applied to the two Highly Confidential Template-protected + sub-labels and to both "Specific People" sub-labels (UserDefined). + Confidential \ All Employees / Internal Exception carry visual markings + (footer) but no encryption — the SMB-friendly profile (encryption on + these breaks too many third-party integrations and external + collaboration scenarios for typical SMB customers). + + Template encryption is scoped to the tenant via the `{TenantDomain}` + token in `EncryptionRightsDefinitions`. The token resolves at runtime + against the auto-discovered tenant identity (default verified domain, + or initial `*.onmicrosoft.com` domain as fallback). Per Entra + rights-management semantics, ANY verified domain expands to ALL + verified domains in the tenant, so this grants encrypted-label rights + to "all users in this tenant only" — NOT external `AuthenticatedUsers` + from other M365 tenants. To opt in to the broad scope (e.g. you + intentionally collaborate cross-tenant via the label), edit the config + to use `{AuthenticatedUsers}` or the literal `AuthenticatedUsers`. + + The default rights bundle is Microsoft's "Reviewer" set (View, Edit + Content, Save, Reply/Reply-All/Forward). If you need a wider bundle + (e.g. the "Co-Author" set adding Copy, Print, Allow Macros for Office + co-authoring or third-party tools that read doc metadata via the + Office object model), edit `EncryptionRightsDefinitions` in + PurviewConfig.psd1 directly. Validate in a pilot tenant before + promoting — OBJMODEL access is the right that most often breaks + third-party integrations. + + The label policy publishes all 8 labels with separate defaults for + documents and email: + * Default for DOCUMENTS = Confidential \ All Employees + * Default for EMAIL = General + The email default is applied via the IPPS `OutlookDefaultLabel` + advanced setting. + + Auto-labeling (client-side and service-side) is intentionally NOT + configured. Add it deliberately via the Purview portal once the team + has reviewed false-positive risk and user-impact trade-offs. + + Idempotency: + * Labels are matched by Name (not DisplayName). Existing labels managed + by this toolkit (description contains the ManagedByTag) are updated + in place. Existing labels NOT managed by this toolkit cause the script + to ABORT unless -AdoptExisting is passed, to prevent silently mutating + a customer's existing baseline. + +.PARAMETER Config + Hashtable from PurviewConfig.psd1. + +.PARAMETER AdoptExisting + Update labels and policies that already exist but were not created by this + toolkit. Use only after auditing the existing configuration. + +.PARAMETER TenantIdentity + Tenant-identity object from `Get-ActualTenantIdentity` in the driver + script (pscustomobject with `DisplayName`, `DefaultDomain`, + `InitialDomain`, `TenantId`). Used to resolve the `{TenantDomain}` + token in `EncryptionRightsDefinitions` so encrypted labels grant + rights to all users in the tenant only (not external + `AuthenticatedUsers`). If the token is present and cannot be + resolved, the script aborts the labels module with an actionable + error — fall-back to broad `AuthenticatedUsers` would silently + reintroduce the exact scope this token was added to remove. + +.PARAMETER SkipContainerLabels + When set, strip the container-scope bits (`Site`, `UnifiedGroup`) from + each label's `ContentType` before calling `New-Label` / `Set-Label`. + The labels still get the `File, Email` scope. Used by the driver + script when license auto-detect cannot identify a Microsoft 365 BP + / E5 / Purview Suite SKU, OR when the operator passes + `-SkipContainerLabels` to `Deploy-PurviewBestPractice.ps1` to opt out. + + Rationale: container scope (Groups & sites) requires the tenant-side + `Group.Unified EnableMIPLabels = $true` toggle, which is set by + `Setup-TenantSettings.ps1` step [5/5]. When that step is skipped, the + label's container bits would have no effect — so we drop them here too + and log a one-line info message per affected label. This keeps the + Purview portal `Edit sensitivity label -> Scope` page honest about + what the toolkit actually provisioned. + +.NOTES + Label and policy changes can take up to 24 hours to fully propagate to + all users and clients. +#> +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +param( + [Parameter(Mandatory)] + [hashtable] $Config, + + [Parameter()] + [switch] $AdoptExisting, + + # [object] (not [hashtable]) so we accept both the pscustomobject from + # Get-ActualTenantIdentity AND a plain hashtable from a partner-driver + # wrapper. Property access via `.DefaultDomain` etc. works for both. + [Parameter()] + [object] $TenantIdentity, + + [Parameter()] + [switch] $SkipContainerLabels +) + +$ErrorActionPreference = 'Stop' +# Auto-confirm: this toolkit is designed for unattended/scripted runs. Use -WhatIf for dry-run. +$ConfirmPreference = 'None' + +# Shared retry helper for transient IPPS errors (502, 503, 504, 429, timeouts). +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') +. (Join-Path $PSScriptRoot 'PurviewConfigurationContract.ps1') + +Assert-PurviewLabelIdentityConfiguration -Config $Config + +# Extract a meaningful error message from an IPPS ErrorRecord. IPPS REST cmdlets +# sometimes leave Exception.Message empty and only populate ErrorDetails or +# FullyQualifiedErrorId, so we walk through several properties in priority order. +function Format-IPPSError { + param([Parameter(Mandatory)] $ErrorRecord) + if (-not $ErrorRecord) { return '(no error record)' } + $candidates = @( + $ErrorRecord.Exception.Message, + $ErrorRecord.ErrorDetails.Message, + $ErrorRecord.CategoryInfo.Reason, + $ErrorRecord.FullyQualifiedErrorId, + $ErrorRecord.ToString() + ) + foreach ($c in $candidates) { + if ($c -and $c.ToString().Trim().Length -gt 0) { return $c.ToString().Trim() } + } + return '(IPPS returned an empty error - check Audit logs in Purview portal)' +} + +$tag = $Config.ManagedByTag +# Encryption rights bundle. Defined once in PurviewConfig.psd1 as +# `EncryptionRightsDefinitions`. Default is Microsoft's "Reviewer" set +# (View/Edit/Save/Reply/ReplyAll/Forward). Partners who need a wider +# bundle (e.g. Copy, Print, OBJMODEL for third-party integrations) edit +# the config string directly. OBJMODEL is the right that most often +# breaks third-party apps reading Office doc metadata, so the default +# is conservative. +# +# Token resolution (case-insensitive): +# * `{TenantDomain}` -> $TenantIdentity.DefaultDomain (preferred) +# or .InitialDomain (fallback). Per Entra +# rights-management semantics, ANY verified +# domain in the tenant expands to ALL +# verified domains in that tenant, so this +# scopes the rights bundle to "all users in +# this tenant only" (not external +# authenticated users from other M365 +# tenants). +# If neither domain is available, the +# module ABORTS with an actionable error. +# Silent fall-back to `AuthenticatedUsers` +# would reintroduce the exact broad scope +# this token was added to remove — partners +# who want broad scope must opt-in via +# `{AuthenticatedUsers}` (or the literal +# `AuthenticatedUsers`) in config. +# * `{AuthenticatedUsers}` -> the literal `AuthenticatedUsers` keyword, +# for explicit broad scope. +# * Any other identity (email / `AuthenticatedUsers` already in the +# string) passes through unchanged. +# +# A label may also override the global default by setting its own +# `EncryptionRightsDefinitions` field — same token semantics apply. +# Used to grant the wider Microsoft "Co-Author" rights bundle +# (adds EXTRACT, PRINT, OBJMODEL) to specific labels like +# `Highly Confidential\All Employees` without affecting siblings. +function Resolve-EncryptionRightsTokens { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $RightsDefinitions, + [object] $TenantIdentity, + [string] $Source = 'EncryptionRightsDefinitions' + ) + $resolved = [string] $RightsDefinitions + if ($resolved -match '\{TenantDomain\}') { + $tenantDom = $null + if ($TenantIdentity) { + # Works for both pscustomobject and hashtable shapes. + if ($TenantIdentity.DefaultDomain) { $tenantDom = [string] $TenantIdentity.DefaultDomain } + elseif ($TenantIdentity.InitialDomain) { $tenantDom = [string] $TenantIdentity.InitialDomain } + } + if ($tenantDom) { + $resolved = $resolved -replace '\{TenantDomain\}', $tenantDom + } else { + $msg = "Cannot resolve '{TenantDomain}' token in $Source : TenantIdentity was not supplied or has no DefaultDomain/InitialDomain. " + + "Refusing to fall back to 'AuthenticatedUsers' because that would silently grant encrypted-label access to ALL Microsoft 365 tenants (the exact scope this token was added to remove). " + + "Remediation: re-run via Deploy-PurviewBestPractice.ps1 so tenant identity is auto-discovered, OR (if you intentionally want broad scope) replace '{TenantDomain}' with '{AuthenticatedUsers}' in the affected config field." + if (Get-Command Add-RunLogEntry -ErrorAction SilentlyContinue) { + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action "Resolve {TenantDomain} ($Source)" ` + -Target '(unresolved)' -Status 'Failed' -Detail $msg + } + throw $msg + } + } + if ($resolved -match '\{AuthenticatedUsers\}') { + $resolved = $resolved -replace '\{AuthenticatedUsers\}', 'AuthenticatedUsers' + } + return $resolved +} + +$rights = Resolve-EncryptionRightsTokens -RightsDefinitions $Config.EncryptionRightsDefinitions ` + -TenantIdentity $TenantIdentity ` + -Source 'Config.EncryptionRightsDefinitions (global default)' +if ($Config.EncryptionRightsDefinitions -match '\{TenantDomain\}') { + $tenantDom = if ($TenantIdentity.DefaultDomain) { $TenantIdentity.DefaultDomain } else { $TenantIdentity.InitialDomain } + Write-Host "Encryption rights bundle scoped to tenant domain '$tenantDom' (all users in this tenant only)." -ForegroundColor DarkGray + if (Get-Command Add-RunLogEntry -ErrorAction SilentlyContinue) { + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Resolve {TenantDomain}' ` + -Target $tenantDom -Status 'Info' ` + -Detail "Resolved {TenantDomain} token in EncryptionRightsDefinitions (global default) to '$tenantDom' (all users in this tenant only; excludes external AuthenticatedUsers)." + } +} +$offlineDays = $Config.EncryptionOfflineAccessDays +$contentExpiration = if ($Config.EncryptionContentExpiredOnDateInDaysOrNever) { + [string]$Config.EncryptionContentExpiredOnDateInDaysOrNever +} else { + 'Never' +} + +# Cache the full label set once so we can match by DisplayName too. The IPPS +# Get-Label -Identity switch only resolves Name/ImmutableId/Guid -- not +# DisplayName -- so a tenant with a template label named "Personal" (a +# different internal Guid) would otherwise look "missing" to us and we'd +# call New-Label, which then fails the display-name uniqueness rule. +$allLabelsRaw = @(Get-Label -ErrorAction SilentlyContinue) + +# --------------------------------------------------------------------------- +# Soft-delete tombstone detection + auto-rename (internal Name only). +# +# Remove-Label SOFT-deletes labels: they stay in Get-Label results with +# Mode='PendingDeletion' for ~30 days. The internal NAME is reserved (New-Label +# with the same Name fails) but the DISPLAYNAME is NOT reserved -- you can create +# a new label with the same DisplayName while the old one is soft-deleted +# (verified 2026-07-01). Without this check the deploy silently ran Set-Label +# against tombstones, every cmdlet failed, and the summary still reported OK. +# +# When a tombstone blocks a create-Name, we vary ONLY the internal Name for this +# run (append the next free '-vN') and KEEP the user-visible DisplayName unchanged +# -- no ' v2' shown to users. With defa4170-create the internal Name is the +# BuiltInName signature, so the varied Name is invisible anyway. The mutation is +# IN-MEMORY ONLY -- PurviewConfig.psd1 on disk is not changed. For any CUSTOM +# label (no BuiltInName) whose config Name is varied, a remap is stashed on +# $Config.LabelNameRemap so Setup-DLP.ps1 can rewrite LabelPath references; +# built-in labels resolve via the BuiltInName signature and need no remap. +# --------------------------------------------------------------------------- +function Test-LabelSoftDeleted { + param($Label) + if (-not $Label) { return $false } + if ($Label.PSObject.Properties.Name -contains 'Mode' -and $Label.Mode -eq 'PendingDeletion') { return $true } + if ($Label.PSObject.Properties.Name -contains 'Disabled' -and $Label.Disabled -eq $true) { return $true } + return $false +} + +function Get-FreeVersionedName { + <# + Find the lowest N >= 2 such that the internal Name slot 'BaseName-vN' is + not held by a TOMBSTONED label. ONLY the Name is checked — a soft-deleted + label does NOT reserve its DisplayName (verified: you can create a new label + with the same DisplayName while the old one is in PendingDeletion), so the + user-visible DisplayName is always kept unchanged. + + An existing ACTIVE label at the candidate slot is fine: the downstream + label-creation flow will discover it via Get-Label, run Test-Owned, and + either update it in place (toolkit-managed) or abort (foreign). We only skip + slots blocked by a soft-delete tombstone. + #> + param( + [Parameter(Mandatory)] [string] $BaseName, + [Parameter(Mandatory)] [array] $AllLabels + ) + for ($n = 2; $n -le 99; $n++) { + $candName = "$BaseName-v$n" + $clash = $AllLabels | Where-Object { $_.Name -eq $candName } | Select-Object -First 1 + if (-not ($clash -and (Test-LabelSoftDeleted $clash))) { + return $candName + } + } + throw "Could not find a free '-vN' suffix for label Name '$BaseName' after 99 attempts. Edit PurviewConfig.psd1 manually." +} + +# The internal Name each configured label is CREATED with: the Microsoft +# 'defa4170-...' BuiltInName when present (so blank tenants match Microsoft), +# else the config Name. Tombstone detection/rename keys off this. +function Get-LabelCreateName { + param($LabelDef) + if ($LabelDef.BuiltInName) { return [string]$LabelDef.BuiltInName } + return [string]$LabelDef.Name +} + +# Build the list of config-defined names we will manage (parents + sub-labels). +$configuredLabelNames = @() +foreach ($_lbl in $Config.Labels) { + $configuredLabelNames += $_lbl.Name + foreach ($_sub in @($_lbl.SubLabels)) { + if ($_sub) { $configuredLabelNames += $_sub.Name } + } +} + +# The internal create-Names we manage (BuiltInName when set, else config Name) — +# this is what a tombstone actually blocks, since we CREATE with these. +$managedCreateNames = @() +foreach ($_lbl in $Config.Labels) { + $managedCreateNames += (Get-LabelCreateName $_lbl) + foreach ($_sub in @($_lbl.SubLabels)) { if ($_sub) { $managedCreateNames += (Get-LabelCreateName $_sub) } } +} + +# Find tombstoned labels whose Name matches a create-Name we manage. +$tombstoned = @($allLabelsRaw | Where-Object { + $managedCreateNames -contains $_.Name -and (Test-LabelSoftDeleted $_) +}) + +$labelNameRemap = @{} +if ($tombstoned.Count -gt 0) { + $tombstonedNames = @($tombstoned | ForEach-Object { $_.Name }) + + Write-Host '' + Write-Host " ! Detected $($tombstoned.Count) soft-deleted label tombstone(s) blocking deployment:" -ForegroundColor Yellow + foreach ($t in $tombstoned) { + $modeText = if ($t.PSObject.Properties.Name -contains 'Mode') { $t.Mode } else { 'Disabled' } + Write-Host " - '$($t.Name)' (Mode=$modeText)" -ForegroundColor Yellow + } + Write-Host ' Auto-renaming the internal Name only (next free -vN) for this run; the user-visible' -ForegroundColor Yellow + Write-Host ' DisplayName is kept UNCHANGED (a tombstone does not reserve the DisplayName).' -ForegroundColor DarkYellow + Write-Host ' PurviewConfig.psd1 on disk is NOT modified; the original Name is restored once the' -ForegroundColor DarkYellow + Write-Host ' ~30-day tombstone purges.' -ForegroundColor DarkYellow + + foreach ($lbl in $Config.Labels) { + $cn = Get-LabelCreateName $lbl + if ($tombstonedNames -contains $cn) { + $newName = Get-FreeVersionedName -BaseName $cn -AllLabels $allLabelsRaw + Write-Host " $cn -> $newName (DisplayName '$($lbl.DisplayName)' unchanged)" -ForegroundColor Yellow + if ($lbl.BuiltInName) { $lbl.BuiltInName = $newName } else { $lbl.Name = $newName; $labelNameRemap[$cn] = $newName } + } + foreach ($sub in @($lbl.SubLabels)) { + if (-not $sub) { continue } + $scn = Get-LabelCreateName $sub + if ($tombstonedNames -contains $scn) { + $newName = Get-FreeVersionedName -BaseName $scn -AllLabels $allLabelsRaw + Write-Host " $scn -> $newName (DisplayName '$($sub.DisplayName)' unchanged)" -ForegroundColor Yellow + if ($sub.BuiltInName) { $sub.BuiltInName = $newName } else { $sub.Name = $newName; $labelNameRemap[$scn] = $newName } + } + } + } + + # Bump LabelPolicy.DefaultLabel if it points to a renamed label. + if ($Config.LabelPolicy -and $Config.LabelPolicy.DefaultLabel ` + -and $labelNameRemap.ContainsKey($Config.LabelPolicy.DefaultLabel)) { + $newDefault = $labelNameRemap[$Config.LabelPolicy.DefaultLabel] + Write-Host " LabelPolicy.DefaultLabel: $($Config.LabelPolicy.DefaultLabel) -> $newDefault" -ForegroundColor Yellow + $Config.LabelPolicy.DefaultLabel = $newDefault + } + # Bump LabelPolicy.DefaultLabelForEmail if it points to a renamed label. + if ($Config.LabelPolicy -and $Config.LabelPolicy.DefaultLabelForEmail ` + -and $labelNameRemap.ContainsKey($Config.LabelPolicy.DefaultLabelForEmail)) { + $newEmailDefault = $labelNameRemap[$Config.LabelPolicy.DefaultLabelForEmail] + Write-Host " LabelPolicy.DefaultLabelForEmail: $($Config.LabelPolicy.DefaultLabelForEmail) -> $newEmailDefault" -ForegroundColor Yellow + $Config.LabelPolicy.DefaultLabelForEmail = $newEmailDefault + } + + # Surface the remap so Setup-DLP.ps1 can rewrite LabelPath segments. + $Config['LabelNameRemap'] = $labelNameRemap + Write-Host '' +} + +# Working cache: filter out tombstones so Get-LabelByName / DisplayName +# matching never returns one accidentally. +$allLabels = @($allLabelsRaw | Where-Object { -not (Test-LabelSoftDeleted $_) }) + +# --------------------------------------------------------------------------- +# Retention-tag (ComplianceTag) name-collision detection + auto-rename. +# +# Sensitivity labels and retention labels share the SAME underlying +# 'ComplianceTag' compliance-rule namespace server-side. A retention label +# named 'Confidential' silently blocks New-Label -Name 'Confidential' with: +# +# |Microsoft.Exchange.Management.UnifiedPolicy.ComplianceRuleAlreadyExistsInScenarioException| +# A compliance rule with name 'X' already exists in scenario(s) 'ComplianceTag'. +# +# Get-Label does NOT surface retention tags, so the tombstone check above +# never catches these. Here we query Get-ComplianceTag and apply the same +# -vN auto-rename logic to any colliding configured label name. +# --------------------------------------------------------------------------- +$retentionTags = @() +$retentionTagsLookupFailed = $false +try { + if (Get-Command Get-ComplianceTag -ErrorAction SilentlyContinue) { + $retentionTags = @(Get-ComplianceTag -ErrorAction Stop) + } else { + $retentionTagsLookupFailed = $true + Write-Warning " Get-ComplianceTag cmdlet is not available in this session. Skipping retention-tag collision pre-flight." + } +} catch { + $retentionTagsLookupFailed = $true + Write-Warning " Could not enumerate retention tags (Get-ComplianceTag failed: $($_.Exception.Message))." + Write-Warning " Skipping retention-tag collision pre-flight. If a sensitivity label fails to create with" + Write-Warning " 'ComplianceRuleAlreadyExistsInScenarioException', a retention tag is holding the name." +} + +$retentionCollisions = @() +if (-not $retentionTagsLookupFailed -and $retentionTags.Count -gt 0) { + $retentionTagNames = @($retentionTags | ForEach-Object { $_.Name }) + $retentionCollisions = @($retentionTagNames | Where-Object { $managedCreateNames -contains $_ }) +} + +if ($retentionCollisions.Count -gt 0) { + # An existing ACTIVE sensitivity label with the same configured Name or + # DisplayName means the label-creation flow will adopt/leave it in place + # instead of calling New-Label. In that case the retention-tag name + # collision is moot (we never try to allocate the colliding name in the + # ComplianceTag scenario), so renaming would produce a needless duplicate + # (e.g. a 'Public' Microsoft template already exists -> we'd otherwise + # create 'Public v2'). Filter the collision list down to entries that + # actually need a rename. + function Test-ActiveLabelMatch { + param([string] $LblName, [string] $LblDisplayName, [string] $ParentId, [array] $AllLabels) + foreach ($cand in $AllLabels) { + if (Test-LabelSoftDeleted $cand) { continue } + $parentMatch = if ($ParentId) { $cand.ParentId -eq $ParentId } else { -not $cand.ParentId } + if (-not $parentMatch) { continue } + if ($cand.Name -eq $LblName) { return $cand } + if ($LblDisplayName -and $cand.DisplayName -eq $LblDisplayName) { return $cand } + } + return $null + } + + $actionable = @() + foreach ($lbl in $Config.Labels) { + if ($retentionCollisions -contains $lbl.Name) { + $adopt = Test-ActiveLabelMatch -LblName $lbl.Name -LblDisplayName $lbl.DisplayName -ParentId $null -AllLabels $allLabelsRaw + if ($adopt) { + Write-Host " Retention tag named '$($lbl.Name)' exists, but an active sensitivity label '$($adopt.DisplayName)' (Id: $($adopt.Guid), Name: $($adopt.Name)) is already present — adopting it; no rename needed." -ForegroundColor DarkGray + } else { + $actionable += [pscustomobject]@{ Scope = 'Parent'; Lbl = $lbl; Sub = $null } + } + } + foreach ($sub in @($lbl.SubLabels)) { + if (-not $sub) { continue } + if ($retentionCollisions -contains $sub.Name) { + # ParentId is unknown at this point (label not yet created/looked up). + # Match on Name or DisplayName globally; correctness-wise it is safe + # because sub-label Names are server-globally unique compliance rules. + $adopt = Test-ActiveLabelMatch -LblName $sub.Name -LblDisplayName $sub.DisplayName -ParentId $null -AllLabels $allLabelsRaw + if (-not $adopt) { + # Try matching by DisplayName under a parent we recognise. + $adopt = $allLabelsRaw | Where-Object { + -not (Test-LabelSoftDeleted $_) -and + $_.DisplayName -eq $sub.DisplayName -and $_.ParentId + } | Select-Object -First 1 + } + if ($adopt) { + Write-Host " Retention tag named '$($sub.Name)' exists, but an active sensitivity sub-label '$($adopt.DisplayName)' (Id: $($adopt.Guid), Name: $($adopt.Name)) is already present — adopting it; no rename needed." -ForegroundColor DarkGray + } else { + $actionable += [pscustomobject]@{ Scope = 'Sub'; Lbl = $lbl; Sub = $sub } + } + } + } + } + + if ($actionable.Count -eq 0) { + Write-Host '' + Write-Host " i Detected $($retentionCollisions.Count) retention-tag name collision(s), but all matching sensitivity labels already exist on the tenant — no rename needed:" -ForegroundColor DarkGray + foreach ($n in $retentionCollisions) { + $tag = $retentionTags | Where-Object { $_.Name -eq $n } | Select-Object -First 1 + $created = if ($tag -and $tag.PSObject.Properties.Name -contains 'CreatedBy') { $tag.CreatedBy } else { '' } + Write-Host " - Retention tag '$n' (created by '$created') ignored: active sensitivity label with matching Name/DisplayName will be adopted." -ForegroundColor DarkGray + } + Write-Host '' + } else { + Write-Host '' + Write-Host " ! Detected $($actionable.Count) retention-tag (ComplianceTag) name collision(s) blocking deployment:" -ForegroundColor Yellow + foreach ($act in $actionable) { + $n = if ($act.Scope -eq 'Sub') { $act.Sub.Name } else { $act.Lbl.Name } + $tag = $retentionTags | Where-Object { $_.Name -eq $n } | Select-Object -First 1 + $created = if ($tag -and $tag.PSObject.Properties.Name -contains 'CreatedBy') { $tag.CreatedBy } else { '' } + $tagGuid = if ($tag -and $tag.PSObject.Properties.Name -contains 'Guid') { $tag.Guid } else { '' } + Write-Host " - Retention tag '$n' (Guid: $tagGuid, created by '$created') reserves this name in the ComplianceTag scenario." -ForegroundColor Yellow + } + Write-Host ' Sensitivity labels and retention labels share the same server-side name space, so' -ForegroundColor Yellow + Write-Host " creating a sensitivity label with this name would fail with 'ComplianceRuleAlreadyExistsInScenarioException'." -ForegroundColor Yellow + Write-Host ' No matching active sensitivity label exists to adopt, so auto-renaming with the next free -vN suffix for this run.' -ForegroundColor Yellow + Write-Host ' PurviewConfig.psd1 on disk is NOT modified. To restore the original name, remove or rename' -ForegroundColor DarkYellow + Write-Host ' the conflicting retention tag (e.g. via Remove-ComplianceTag) and re-run.' -ForegroundColor DarkYellow + + foreach ($act in $actionable) { + if ($act.Scope -eq 'Parent') { + $lbl = $act.Lbl + $cn = Get-LabelCreateName $lbl + $newName = Get-FreeVersionedName -BaseName $cn -AllLabels $allLabelsRaw + Write-Host " $cn -> $newName (DisplayName '$($lbl.DisplayName)' unchanged)" -ForegroundColor Yellow + if ($lbl.BuiltInName) { $lbl.BuiltInName = $newName } else { $lbl.Name = $newName; $labelNameRemap[$cn] = $newName } + } else { + $sub = $act.Sub + $scn = Get-LabelCreateName $sub + $newName = Get-FreeVersionedName -BaseName $scn -AllLabels $allLabelsRaw + Write-Host " $scn -> $newName (DisplayName '$($sub.DisplayName)' unchanged)" -ForegroundColor Yellow + if ($sub.BuiltInName) { $sub.BuiltInName = $newName } else { $sub.Name = $newName; $labelNameRemap[$scn] = $newName } + } + } + + if ($Config.LabelPolicy -and $Config.LabelPolicy.DefaultLabel ` + -and $labelNameRemap.ContainsKey($Config.LabelPolicy.DefaultLabel)) { + $newDefault = $labelNameRemap[$Config.LabelPolicy.DefaultLabel] + Write-Host " LabelPolicy.DefaultLabel: $($Config.LabelPolicy.DefaultLabel) -> $newDefault" -ForegroundColor Yellow + $Config.LabelPolicy.DefaultLabel = $newDefault + } + if ($Config.LabelPolicy -and $Config.LabelPolicy.DefaultLabelForEmail ` + -and $labelNameRemap.ContainsKey($Config.LabelPolicy.DefaultLabelForEmail)) { + $newEmailDefault = $labelNameRemap[$Config.LabelPolicy.DefaultLabelForEmail] + Write-Host " LabelPolicy.DefaultLabelForEmail: $($Config.LabelPolicy.DefaultLabelForEmail) -> $newEmailDefault" -ForegroundColor Yellow + $Config.LabelPolicy.DefaultLabelForEmail = $newEmailDefault + } + + # Surface remap so Setup-DLP.ps1 can rewrite LabelPath segments like 'Confidential/AllEmployees'. + $Config['LabelNameRemap'] = $labelNameRemap + Write-Host '' + } +} + +function Get-LabelByName { + param( + [string] $Name, + [string] $DisplayName, + [string] $ParentId, + # Scope filter for which labels to consider: + # 'Any' - legacy permissive match (default for back-compat). Name + # match is hierarchy-agnostic; DisplayName fallback honours + # the supplied $ParentId (null = top-level). + # 'TopLevel' - match only labels with no ParentId. + # 'Parent' - match only labels whose ParentId equals $ParentId. Requires $ParentId. + # Use 'TopLevel' for configured-parent lookups and 'Parent' for sub-label + # lookups under a known parent. Without an explicit scope a tenant that + # has a sub-label with the same internal Name as a configured parent + # (common on tenants migrated to the modern label scheme or with the + # Microsoft-default labels enabled) will return the sub-label and the + # caller will cache it as a "parent", which then breaks Pass 3 Phase A + # with InvalidSubLabelPriorityException. + [ValidateSet('Any','TopLevel','Parent')] + [string] $Scope = 'Any' + ) + if ($Scope -eq 'Parent' -and [string]::IsNullOrWhiteSpace($ParentId)) { return $null } + + $candidates = switch ($Scope) { + 'TopLevel' { $allLabels | Where-Object { -not $_.ParentId } } + 'Parent' { $allLabels | Where-Object { $_.ParentId -eq $ParentId } } + default { $allLabels } + } + + # Signature match (region/scheme-proof). If the configured label carries a + # BuiltInName (the Microsoft 'defa4170-0d19-0005-NNNN-bc88714345d2' default + # identifier), match the live label by that internal Name REGARDLESS of + # DisplayName localization (FR 'Confidentiel', DE 'Vertraulich', or even + # 'Specific' vs 'Specified People' across tenants). The modern-scheme migrated + # group appends 'Group' to the Name, so accept that suffixed form too. This + # runs before the Name/DisplayName match because it is the most reliable key. + $builtIn = $null + foreach ($_cl in $Config.Labels) { + if ($_cl.Name -eq $Name -and $_cl.BuiltInName) { $builtIn = [string]$_cl.BuiltInName; break } + if ($_cl.SubLabels) { + foreach ($_cs in $_cl.SubLabels) { + if ($_cs.Name -eq $Name -and $_cs.BuiltInName) { $builtIn = [string]$_cs.BuiltInName; break } + } + if ($builtIn) { break } + } + } + if ($builtIn) { + $bySig = $candidates | Where-Object { + $n = [string]$_.Name + ($n -eq $builtIn) -or ($n -eq ($builtIn + 'Group')) + } | Select-Object -First 1 + if ($bySig) { return $bySig } + } + + $byName = $candidates | Where-Object { $_.Name -eq $Name } | Select-Object -First 1 + if ($byName) { return $byName } + + if ($DisplayName) { + if ($Scope -eq 'Any') { + # Legacy 'Any' scope: DisplayName fallback uses the caller-supplied + # ParentId-or-null filter (DisplayName is not tenant-globally unique + # like Name is). + $byDisplay = $allLabels | Where-Object { + $_.DisplayName -eq $DisplayName -and ( + ($ParentId -and $_.ParentId -eq $ParentId) -or + (-not $ParentId -and -not $_.ParentId) + ) + } | Select-Object -First 1 + } else { + $byDisplay = $candidates | Where-Object { $_.DisplayName -eq $DisplayName } | Select-Object -First 1 + } + if ($byDisplay) { return $byDisplay } + } + return $null +} + +# Resolve a configured default-label Name (LabelPolicy.DefaultLabel / +# DefaultLabelForEmail) to its LIVE Purview label object. This MUST be +# parent-aware: when the default is a sub-label that was ADOPTED from a +# pre-existing label (e.g. a Microsoft-default 'All Employees' sub-label on a +# tenant that already had the default sensitivity labels), the live label's +# internal Name is a GUID, not the configured Name. A bare Name/DisplayName +# lookup at the default 'Any' scope then fails: the Name match misses (GUID), +# and the DisplayName fallback in 'Any' scope only considers TOP-LEVEL labels, +# so a sub-label default is never found -> "Default label '' was not found +# after creation." We therefore resolve the parent first and match the +# sub-label within the parent's scope, exactly as the published-labels +# collection loop in Pass 4 does. +function Resolve-ConfiguredDefaultLabel { + param([Parameter(Mandatory)] [string] $DefaultName) + + $def = $null + $parentDef = $null + foreach ($lbl in $Config.Labels) { + if ($lbl.Name -eq $DefaultName) { $def = $lbl; break } + foreach ($sub in @($lbl.SubLabels)) { + if ($sub -and $sub.Name -eq $DefaultName) { $def = $sub; $parentDef = $lbl; break } + } + if ($def) { break } + } + + $display = if ($def) { $def.DisplayName } else { $DefaultName } + + if ($parentDef) { + # Sub-label default: resolve its parent first (handles adopted + # MS-default parents whose live Name is a GUID via DisplayName), then + # match the sub-label within the parent's scope so adopted sub-labels + # match by DisplayName even though their internal Name is a GUID. + $parentObj = Get-LabelByName -Name $parentDef.Name -DisplayName $parentDef.DisplayName -Scope 'TopLevel' + $parentId = if ($parentObj) { $parentObj.Guid } else { $null } + $scope = if ($parentId) { 'Parent' } else { 'Any' } + return Get-LabelByName -Name $DefaultName -DisplayName $display -ParentId $parentId -Scope $scope + } + + # Top-level default (or a Name not found in config): resolve at top-level + # scope, which already matches adopted MS-default parents by DisplayName. + return Get-LabelByName -Name $DefaultName -DisplayName $display -Scope 'TopLevel' +} + +# True when a live label object is a modern-scheme "Label Group" — a +# non-assignable, non-publishable container. The signal is the `IsLabelGroup` +# property on the plain Get-Label object (NO -ReturnModernLabelScheme switch): +# * Modern scheme: a parent that has sub-labels reports IsLabelGroup = True +# and CANNOT be published directly (New-LabelPolicy throws +# "Label group(s) '' can not be published"); only its sub-labels are +# published and the group is auto-included by the service. +# * Classic scheme: a parent reports IsParent = True but IsLabelGroup = False +# and IS independently publishable — so this check leaves classic tenants +# untouched (no regression). +# Returns $false for placeholder objects (WhatIf) and older IPPS modules that +# don't surface the property, i.e. defaults to the legacy "publishable" path. +function Test-IsLabelGroup { + param($LabelObject) + if (-not $LabelObject) { return $false } + $prop = $LabelObject.PSObject.Properties['IsLabelGroup'] + return [bool]($prop -and $prop.Value -eq $true) +} + +# A Label Group cannot serve as a default applied label. Given a live Label +# Group object, return the child label to use as the default instead: prefer +# 'Anyone (unrestricted)' (the least-sensitive Microsoft-default child, e.g. +# General\Anyone (unrestricted)), otherwise the lowest-priority (least +# sensitive) child. Returns $null when the group has no children. +function Resolve-LabelGroupDefaultChild { + param([Parameter(Mandatory)] $GroupLabel) + $children = @($script:allLabels | Where-Object { $_.ParentId -and $GroupLabel.Guid -and ($_.ParentId -eq $GroupLabel.Guid) }) + if ($children.Count -eq 0) { return $null } + $preferred = $children | Where-Object { $_.DisplayName -eq 'Anyone (unrestricted)' } | Select-Object -First 1 + if ($preferred) { return $preferred } + return ($children | Sort-Object { [int]$_.Priority } | Select-Object -First 1) +} + +# Read a single advanced-setting key (e.g. 'color') off a live Get-Label +# object. IPPS surfaces these as a `Settings` collection whose ToString() +# renders each entry as `[key, value]` (e.g. `[color, #13A10E]`). Parsing +# the rendered form is the most reliable approach because the underlying +# CLR type is not stable across cmdlet versions. Returns $null when the +# label, the collection, or the key is missing. +function Get-LabelAdvancedSetting { + param( + $Label, + [Parameter(Mandatory)] [string] $Key + ) + if (-not $Label) { return $null } + $needle = $Key.Trim().ToLowerInvariant() + foreach ($propName in @('Settings','AdvancedSettings','LocaleSettings')) { + $prop = $Label.PSObject.Properties[$propName] + if (-not $prop) { continue } + $coll = $prop.Value + if (-not $coll) { continue } + # Try structured access first (Key/Value or Name/Value or dictionary) + # before falling back to regex on the rendered '[key, value]' form. The + # IPPS module version sometimes returns typed Setting objects, and a + # mismatch in ToString() rendering would otherwise cause repeat false + # drift detections on every run. + if ($coll -is [System.Collections.IDictionary]) { + foreach ($k in $coll.Keys) { + if (([string]$k).Trim().ToLowerInvariant() -eq $needle) { + return [string]$coll[$k] + } + } + continue + } + foreach ($item in @($coll)) { + if ($null -eq $item) { continue } + $keyProp = $null + foreach ($kp in 'Key','Name') { + $p = $item.PSObject.Properties[$kp] + if ($p) { $keyProp = $p; break } + } + $valProp = $item.PSObject.Properties['Value'] + if ($keyProp -and $valProp) { + if (([string]$keyProp.Value).Trim().ToLowerInvariant() -eq $needle) { + return [string]$valProp.Value + } + continue + } + $s = [string]$item + if ($s -match '^\s*\[\s*([^,\]]+?)\s*,\s*(.*?)\s*\]\s*$') { + if ($matches[1].Trim().ToLowerInvariant() -eq $needle) { + return $matches[2].Trim() + } + } + } + } + return $null +} + +# Resolve a configured parent label to its live Purview object. Prefers the +# Pass 1 cache ($script:createdParents) because that object came directly from +# Get-Label -Identity in New-OrUpdate-Label and is authoritative even for +# Microsoft-default labels (e.g. 'Public', 'Personal') that bulk Get-Label +# does not always surface reliably. Falls back to the bulk-cache lookup. +# +# The cached object MUST be top-level. If Pass 1 cached a sub-label (which can +# happen on tenants with name collisions between configured parents and live +# sub-labels), we return $null and let the caller surface the remediation. +# Without this guard, Phase A would push the sub-label to priority 0 and hit +# InvalidSubLabelPriorityException ("priority 0 is invalid for sub-label"). +function Resolve-ConfigParentLabel { + param([Parameter(Mandatory)] [hashtable] $LabelDef) + if ($script:createdParents -and $script:createdParents.ContainsKey($LabelDef.Name)) { + $cached = $script:createdParents[$LabelDef.Name] + if ($cached -and -not $cached.ParentId) { return $cached } + # Cached object is a sub-label - reject and fall through to bulk lookup + # (which is also scope-restricted) so the caller never blindly trusts a + # collision-tainted cache entry. + } + return Get-LabelByName -Name $LabelDef.Name -DisplayName $LabelDef.DisplayName -Scope 'TopLevel' +} + +# Return the most stable identity to pass to IPPS cmdlets. The label Guid is +# immutable and unambiguous; Name can be a GUID-string (MS-default labels) or +# a human-readable token (user-created labels). Both work with -Identity, but +# Guid is the safer choice for re-lookups after mutations. +function Get-StableLabelIdentity { + param([Parameter(Mandatory)] $LabelObject) + if ($LabelObject.PSObject.Properties.Name -contains 'Guid' -and $LabelObject.Guid) { + return [string]$LabelObject.Guid + } + return [string]$LabelObject.Name +} + +# True when a string is shaped like a canonical GUID. Used to detect labels +# whose Name property is actually a GUID (Microsoft-default labels), because +# Set-Label via that GUID-shaped Name is unreliable on some tenants. +function Test-IsGuidLikeString { + param([string] $Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return $false } + return $Value -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' +} + +# Resolve a label to its CURRENT live state using a fresh bulk Get-Label (which +# is more reliable than Get-Label -Identity for Microsoft-default labels on +# some tenants). Matches by Guid, then Name, then unique top-level DisplayName. +# Returns $null if no match. Used by both the priority setter and the +# verification pass so they cannot disagree about a label's current priority. +function Get-LiveLabelByObject { + param([Parameter(Mandatory)] $LabelObject) + $bulk = $null + try { + Invoke-WithTransientRetry -Description "Get-Label (bulk fetch for live resolve)" -Action { + $script:_bulkLabelsForResolve = @(Get-Label -ErrorAction Stop | Where-Object { -not (Test-LabelSoftDeleted $_) }) + } + $bulk = $script:_bulkLabelsForResolve + Remove-Variable -Name '_bulkLabelsForResolve' -Scope Script -ErrorAction SilentlyContinue + } catch { + $bulk = @(Get-Label -ErrorAction SilentlyContinue | Where-Object { -not (Test-LabelSoftDeleted $_) }) + } + if (-not $bulk) { return $null } + + if ($LabelObject.Guid) { + try { + if (([guid]$LabelObject.Guid) -ne [guid]::Empty) { + $m = $bulk | Where-Object { $_.Guid -eq $LabelObject.Guid } | Select-Object -First 1 + if ($m) { return $m } + } + } catch {} + } + if ($LabelObject.Name) { + $m = $bulk | Where-Object { $_.Name -eq [string]$LabelObject.Name } | Select-Object -First 1 + if ($m) { return $m } + } + if ($LabelObject.DisplayName) { + $cands = @($bulk | Where-Object { $_.DisplayName -eq $LabelObject.DisplayName -and -not $_.ParentId }) + if ($cands.Count -eq 1) { return $cands[0] } + } + return $null +} + +# Update label priority and VERIFY the change persisted. Tries multiple Identity +# forms because IPPS Set-Label can be a silent no-op for Microsoft-default +# labels when called by Guid (or by Name when the Name is itself the GUID). +# +# Identity precedence: +# * Microsoft-default-shaped labels (Name == Guid OR Name looks like a GUID): +# DisplayName (if unique top-level) → Name → Guid +# * Everything else: +# Name → DisplayName (if unique top-level) → Guid +# +# DisplayName is only added as a candidate for TOP-LEVEL labels, and only when +# bulk Get-Label confirms exactly one top-level label has that DisplayName AND +# it matches our target Guid/Name. Sub-label DisplayNames are excluded because +# they can legitimately repeat across different parent labels. +# +# Returns a PSObject with: Success, UsedIdentity (Name/DisplayName/Guid), +# Attempts (string[] diagnostic trail), Live (fresh label object on success). +function Set-LabelPriorityWithVerify { + param( + [Parameter(Mandatory)] $LabelObject, + [Parameter(Mandatory)] [int] $TargetPriority, + [Parameter(Mandatory)] [string] $DisplayNameForLog, + [switch] $IsSubLabel + ) + + $nameStr = if ($LabelObject.Name) { [string]$LabelObject.Name } else { $null } + $displayStr = if ($LabelObject.DisplayName) { [string]$LabelObject.DisplayName } else { $null } + $guidStr = $null + if ($LabelObject.Guid) { + try { if (([guid]$LabelObject.Guid) -ne [guid]::Empty) { $guidStr = [string]$LabelObject.Guid } } catch {} + } + + $nameIsGuidLike = Test-IsGuidLikeString -Value $nameStr + $msDefaultLike = ($nameStr -and $guidStr -and ($nameStr -eq $guidStr)) -or $nameIsGuidLike + + # Confirm DisplayName uniqueness before allowing it as a Set-Label identity. + $displayIsSafe = $false + if (-not $IsSubLabel -and $displayStr) { + $peek = @(Get-Label -ErrorAction SilentlyContinue | Where-Object { -not (Test-LabelSoftDeleted $_) }) + $same = @($peek | Where-Object { $_.DisplayName -eq $displayStr -and -not $_.ParentId }) + if ($same.Count -eq 1) { + $hit = $same[0] + if (($guidStr -and ($hit.Guid -eq $LabelObject.Guid)) -or + ($nameStr -and ($hit.Name -eq $nameStr))) { + $displayIsSafe = $true + } + } + } + + $cands = New-Object System.Collections.Generic.List[object] + if ($msDefaultLike) { + if ($displayIsSafe) { [void]$cands.Add(@{ Kind='DisplayName'; Value=$displayStr }) } + if ($nameStr) { [void]$cands.Add(@{ Kind='Name'; Value=$nameStr }) } + if ($guidStr -and ($guidStr -ne $nameStr)) { + [void]$cands.Add(@{ Kind='Guid'; Value=$guidStr }) } + } else { + if ($nameStr) { [void]$cands.Add(@{ Kind='Name'; Value=$nameStr }) } + if ($displayIsSafe) { [void]$cands.Add(@{ Kind='DisplayName'; Value=$displayStr }) } + if ($guidStr -and ($guidStr -ne $nameStr)) { + [void]$cands.Add(@{ Kind='Guid'; Value=$guidStr }) } + } + + $attempts = New-Object System.Collections.Generic.List[string] + foreach ($c in $cands) { + try { + Invoke-WithTransientRetry -Description ("Set-Label priority=$TargetPriority '$DisplayNameForLog' via $($c.Kind)") -Action { + Set-Label -Identity $c.Value -Priority $TargetPriority ` + -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null + } + } catch { + $errMsg = Format-IPPSError $_ + [void]$attempts.Add("[$($c.Kind)='$($c.Value)'] threw: $errMsg") + # 'not a valid priority' / 'is not valid' is the IPPS no-op error we + # ignore in legacy code; treat as soft failure and try next identity. + continue + } + $live = Get-LiveLabelByObject -LabelObject $LabelObject + if ($live -and $live.Priority -eq $TargetPriority) { + [void]$attempts.Add("[$($c.Kind)='$($c.Value)'] success: priority=$($live.Priority)") + return [pscustomobject]@{ + Success = $true + UsedIdentity = $c.Kind + Attempts = @($attempts) + Live = $live + } + } + $obs = if ($live) { $live.Priority } else { 'not-found-in-bulk' } + [void]$attempts.Add("[$($c.Kind)='$($c.Value)'] no-op: observed priority=$obs after Set-Label.") + } + return [pscustomobject]@{ + Success = $false + UsedIdentity = $null + Attempts = @($attempts) + Live = $null + } +} + +function Test-Owned { + param($Object, [string] $Tag) + if (-not $Object) { return $false } + $desc = if ($Object.PSObject.Properties.Name -contains 'Comment') { $Object.Comment } + elseif ($Object.PSObject.Properties.Name -contains 'Description') { $Object.Description } + else { '' } + return ($desc -and $desc -like "*$Tag*") +} + +function Invoke-IPPSCmdlet { + <# + IPPS REST cmdlets bypass -ErrorAction Stop. Run with + -ErrorAction SilentlyContinue + -ErrorVariable, then re-throw any + captured error so the caller's try/catch (or our local err check) + sees a real terminating exception. + #> + param( + [Parameter(Mandatory)] [scriptblock] $ScriptBlock, + [string] $Description = '' + ) + $err = @() + & $ScriptBlock -ErrorVariable +err -ErrorAction SilentlyContinue 2>&1 | Out-Null + if ($err.Count -gt 0) { throw $err[0] } +} + +function Set-LabelEncryption { + <# + Apply encryption to a label. + + Two protection modes are supported: + + * 'Template' (default) — admin-defined template that grants the + configured rights ($rights) to AuthenticatedUsers. Used for the + "All Employees" sub-labels in Microsoft's default-labels spec. + + * 'UserDefined' — the user picks recipients and permissions at apply + time. Used for the "Trusted People" / "Specific People" sub-labels. + Office apps (Word/Excel/PowerPoint) prompt the user; Outlook + applies a fixed behaviour (Encrypt-Only or Do Not Forward) per + $UserDefinedOutlookBehavior. + #> + param( + [Parameter(Mandatory)] [string] $Identity, + [ValidateSet('Template','UserDefined')] + [string] $ProtectionType = 'Template', + [ValidateSet('EncryptOnly','DoNotForward','None')] + [string] $UserDefinedOutlookBehavior = 'None', + # Optional per-label override. When supplied, this rights string is + # passed to Set-Label instead of the module-level `$rights` default. + # Caller is responsible for resolving any `{TenantDomain}` token + # via Resolve-EncryptionRightsTokens before passing it in. + [string] $RightsDefinitions + ) + $effectiveRights = if ($PSBoundParameters.ContainsKey('RightsDefinitions') -and $RightsDefinitions) { + $RightsDefinitions + } else { + $rights + } + if ($ProtectionType -eq 'Template') { + Invoke-WithTransientRetry -Description ("Set-Label encryption (Template) '$Identity'") -Action { + Set-Label -Identity $Identity ` + -EncryptionEnabled $true ` + -EncryptionProtectionType 'Template' ` + -EncryptionRightsDefinitions $effectiveRights ` + -EncryptionContentExpiredOnDateInDaysOrNever $contentExpiration ` + -EncryptionOfflineAccessDays $offlineDays ` + -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null + } + } else { + $params = @{ + Identity = $Identity + EncryptionEnabled = $true + EncryptionProtectionType = 'UserDefined' + EncryptionPromptUser = $true # Word/Excel/PowerPoint: prompt user to assign permissions + EncryptionContentExpiredOnDateInDaysOrNever = $contentExpiration + EncryptionOfflineAccessDays = $offlineDays + } + switch ($UserDefinedOutlookBehavior) { + 'EncryptOnly' { $params['EncryptionEncryptOnly'] = $true } + 'DoNotForward' { $params['EncryptionDoNotForward'] = $true } + default { } # 'None' = no Outlook-specific behaviour + } + Invoke-WithTransientRetry -Description ("Set-Label encryption (UserDefined) '$Identity'") -Action { + Set-Label @params -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null + } + } +} + +function Set-LabelContentMarking { + param( + [Parameter(Mandatory)] [string] $Identity, + [string] $HeaderText, + [string] $FooterText, + [string] $WatermarkText + ) + $params = @{ + Identity = $Identity + ApplyContentMarkingHeaderEnabled = [bool]$HeaderText + ApplyContentMarkingFooterEnabled = [bool]$FooterText + ApplyWaterMarkingEnabled = [bool]$WatermarkText + } + if ($HeaderText) { + $params['ApplyContentMarkingHeaderText'] = $HeaderText + $params['ApplyContentMarkingHeaderAlignment'] = 'Center' + $params['ApplyContentMarkingHeaderFontColor'] = '#FF0000' + } + if ($FooterText) { + $params['ApplyContentMarkingFooterText'] = $FooterText + $params['ApplyContentMarkingFooterAlignment'] = 'Center' + $params['ApplyContentMarkingFooterFontColor'] = '#FF0000' + } + if ($WatermarkText) { + $params['ApplyWaterMarkingText'] = $WatermarkText + $params['ApplyWaterMarkingLayout'] = 'Diagonal' + $params['ApplyWaterMarkingFontColor'] = '#FF0000' + } + Invoke-WithTransientRetry -Description ("Set-Label content marking '$($params.Identity)'") -Action { + Set-Label @params -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null + } +} + +# --------------------------------------------------------------------------- +# ContentType (label scope) helpers. +# +# `ContentType` is the IPPS `New-Label -ContentType` value: a comma-separated +# string from the set {File, Email, Site, UnifiedGroup, PurviewAssets, +# SchematizedDataAssets}. Three published labels (General, AllEmployees, +# HCAllEmps) ship with container scope ('Site, UnifiedGroup') so they appear +# in the Purview portal's container-label picker; the rest stay File/Email-only. +# +# Helpers below normalise the comma string to a canonical SORTED-UNIQUE array +# so we can: +# * compare config vs live without false drift from whitespace / ordering; +# * UNION live + desired on adoption so we never strip a manually-added +# scope; +# * strip the container bits when -SkipContainerLabels is set without +# mutating the rest of the desired scope. +# --------------------------------------------------------------------------- +$script:ContainerContentTypeBits = @('Site', 'UnifiedGroup') + +function ConvertTo-ContentTypeSet { + [OutputType([string[]])] + param([string] $Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return @() } + return @( + $Value -split ',' | + ForEach-Object { $_.Trim() } | + Where-Object { $_ } | + Sort-Object -Unique + ) +} + +function Compare-ContentTypeSets { + [OutputType([bool])] + param( + [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]] $A, + [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]] $B + ) + if ($A.Count -ne $B.Count) { return $false } + for ($i = 0; $i -lt $A.Count; $i++) { + if ($A[$i] -ne $B[$i]) { return $false } + } + return $true +} + +function Get-DesiredContentTypeSet { + <# + Resolve the desired ContentType set for a label, after the + -SkipContainerLabels gate. Returns: + * $null if the config did not specify a ContentType (caller + should leave the IPPS default in place); + * @() if every desired bit was stripped (caller still skips + the Set-Label so the IPPS default kicks in); + * sorted-unique string[] otherwise. + Emits a one-line info message + Skipped run-log entry per label + when container bits are stripped. + #> + [OutputType([string[]])] + param( + [Parameter(Mandatory)] [hashtable] $LabelDef, + [Parameter(Mandatory)] [bool] $SkipContainerLabels + ) + if (-not $LabelDef.ContainsKey('ContentType') -or + [string]::IsNullOrWhiteSpace([string]$LabelDef.ContentType)) { + return $null + } + $configured = ConvertTo-ContentTypeSet -Value ([string]$LabelDef.ContentType) + if (-not $SkipContainerLabels) { return $configured } + + $stripped = @($configured | Where-Object { $_ -notin $script:ContainerContentTypeBits }) + $removed = @($configured | Where-Object { $_ -in $script:ContainerContentTypeBits }) + if ($removed.Count -gt 0) { + Write-Host (" [info] -SkipContainerLabels: stripped container scope bits ({0}) from label '{1}' ContentType (configured: {2}; will provision: {3}). Tenant-side Group.Unified EnableMIPLabels is not being set, so the container bits would have no effect." -f + ($removed -join ', '), + $LabelDef.DisplayName, + ($configured -join ', '), + $(if ($stripped.Count) { $stripped -join ', ' } else { '(none — IPPS default File, Email will apply)' })) -ForegroundColor DarkYellow + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Resolve ContentType' ` + -Target $LabelDef.DisplayName -Status 'Skipped' ` + -Detail ("Container-scope bits ({0}) stripped from configured ContentType '{1}' because -SkipContainerLabels is set. Provisioning '{2}'." -f + ($removed -join ', '), + ($configured -join ', '), + $(if ($stripped.Count) { $stripped -join ', ' } else { '(IPPS default)' })) + } + return $stripped +} + +function New-OrUpdate-Label { + param( + [Parameter(Mandatory)] [hashtable] $LabelDef, + [string] $ParentId + ) + + $existing = Get-LabelByName -Name $LabelDef.Name -DisplayName $LabelDef.DisplayName -ParentId $ParentId ` + -Scope $(if ($ParentId) { 'Parent' } else { 'TopLevel' }) + $owned = Test-Owned -Object $existing -Tag $tag + $matchedBy = $null + $justCreated = $false # New-Label succeeded inside this call + $idForOps = if ($existing) { $existing.Name } else { $LabelDef.Name } + + if ($existing) { + $matchedBy = if ($existing.Name -eq $LabelDef.Name) { 'Name' } + elseif ($LabelDef.BuiltInName -and ($existing.Name -eq [string]$LabelDef.BuiltInName -or $existing.Name -eq ([string]$LabelDef.BuiltInName + 'Group'))) { 'Name signature (defa4170)' } + else { 'DisplayName' } + if ($owned) { + Write-Host " = Found managed label '$($LabelDef.Name)' (matched by $matchedBy). Updating." -ForegroundColor DarkGray + } elseif ($AdoptExisting) { + Write-Host " ~ Found existing label '$($LabelDef.DisplayName)' (Id: $($existing.Guid), matched by $matchedBy). -AdoptExisting set; updating." -ForegroundColor Yellow + } else { + Write-Host " = Found existing label '$($LabelDef.DisplayName)' (Id: $($existing.Guid), matched by $matchedBy). Leaving as-is (use -AdoptExisting to update)." -ForegroundColor DarkGray + return $existing + } + } + + $tooltip = $LabelDef.Tooltip + $comment = "$tag $tooltip" + + if (-not $existing) { + # Create with the Microsoft built-in 'defa4170-...' Name when the configured + # label maps to a built-in default, so blank-tenant labels are byte-identical + # to Microsoft's defaults (and to adopted tenants) — multitenant tools that + # look up by the standard Name then work everywhere. Falls back to the config + # Name for any custom label without a BuiltInName. + $createName = if ($LabelDef.BuiltInName) { [string]$LabelDef.BuiltInName } else { [string]$LabelDef.Name } + + # Cross-scope Name-collision pre-check. IPPS enforces tenant-globally + # unique label Names, so if the Name collides with a live label in a + # DIFFERENT hierarchy scope (typically: configured top-level vs an existing + # sub-label of the same Name, common on tenants migrated to the modern label + # scheme or with Microsoft-default labels enabled), New-Label will fail with + # a cryptic message. Detect upfront and surface an actionable remediation, + # otherwise Pass 3 fails later with InvalidSubLabelPriorityException when the + # wrong object is reordered. + $callerWantsTopLevel = -not $ParentId + $nameCollision = $allLabels | Where-Object { $_.Name -eq $createName } | Select-Object -First 1 + if ($nameCollision) { + $collisionIsSubLabel = [bool] $nameCollision.ParentId + if ($callerWantsTopLevel -eq $collisionIsSubLabel) { + $callerScope = if ($callerWantsTopLevel) { 'top-level' } else { "child of parent Id $ParentId" } + $liveScope = if ($collisionIsSubLabel) { "sub-label of parent Id $($nameCollision.ParentId)" } else { 'top-level' } + $collisionGuid = if ($nameCollision.Guid) { [string]$nameCollision.Guid } else { 'unknown' } + $detail = @( + "Cannot create configured label '$($LabelDef.Name)' as ${callerScope}: a live label with the same Name already exists in this tenant as a $liveScope (Id: $collisionGuid)." + "Microsoft Purview enforces tenant-globally unique label Names, and the modern label scheme strictly enforces sub-label priority ranges, so this collision must be resolved." + "Remediation options:" + " 1. Rename the configured label in Products/Purview/Config/PurviewConfig.psd1 (change Labels.Name) so it no longer collides." + " 2. In Purview Admin Center (https://purview.microsoft.com), delete or un-nest the colliding live label (Id: $collisionGuid), then re-run." + " 3. If the collision is with a Microsoft-default sensitivity label, keep the default and update the configured Name to a tenant-unique value." + ) -join "`n " + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'New-Label' ` + -Target $LabelDef.DisplayName -Status 'Failed' -Detail $detail + Write-Warning " $detail" + return $null + } + } + + if (-not $PSCmdlet.ShouldProcess($LabelDef.Name, 'New-Label')) { return $null } + + $newArgs = @{ + Name = $createName + DisplayName = $LabelDef.DisplayName + Tooltip = $tooltip + Comment = $comment + } + if ($ParentId) { $newArgs['ParentId'] = $ParentId } + if (-not [string]::IsNullOrWhiteSpace([string]$LabelDef.Color)) { + $newArgs['AdvancedSettings'] = @{ color = [string]$LabelDef.Color } + } + + # Pass -ContentType through to New-Label when configured. + # We never UNION here (the label doesn't exist yet, so there is no + # live value to preserve); UNION applies only on the adopt/update + # path below. + $desiredContentTypeNew = Get-DesiredContentTypeSet -LabelDef $LabelDef -SkipContainerLabels:$SkipContainerLabels.IsPresent + if ($null -ne $desiredContentTypeNew -and $desiredContentTypeNew.Count -gt 0) { + $newArgs['ContentType'] = ($desiredContentTypeNew -join ', ') + } + + # IPPS cmdlets bypass -ErrorAction Stop. Capture errors via -ErrorVariable. + # PR4: wrap with Invoke-WithTransientRetry so 502/503/504/429 auto-retry. + # NOT using -AlreadyExistsIsSuccess here because the catch block below + # needs to inspect the message and extract the existing label's GUID for + # the "Duplicate display name" recovery path. + # Modern label scheme: a parent that will own sub-labels MUST be created as + # a label GROUP (-IsLabelGroup), or sub-label creation fails with + # InvalidParentLabelInModernLabelSchemeException. Classic scheme REJECTS + # -IsLabelGroup. Branch on the detected (cached) scheme; on the first such + # parent, try -IsLabelGroup and fall back to a classic parent if rejected. + $isParentWithSubs = (-not $ParentId) -and [bool]$LabelDef.SubLabels + $useLabelGroup = $isParentWithSubs -and ($script:tenantLabelScheme -ne 'Classic') + + $createArgs = $newArgs + if ($useLabelGroup) { + # A label group is a non-applicable container -> omit ContentType. + $createArgs = @{ + Name = $newArgs.Name + DisplayName = $newArgs.DisplayName + Tooltip = $newArgs.Tooltip + Comment = $newArgs.Comment + IsLabelGroup = $true + } + if ($newArgs.ContainsKey('AdvancedSettings')) { $createArgs['AdvancedSettings'] = $newArgs['AdvancedSettings'] } + } + + $err = @() + try { + Invoke-WithTransientRetry -Description ("New-Label '$($LabelDef.Name)'$(if ($useLabelGroup) { ' (label group)' })") -Action { + New-Label @createArgs -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null + } + if ($useLabelGroup) { $script:tenantLabelScheme = 'Modern' } + } catch { + $err = @($_) + # -IsLabelGroup rejected -> classic scheme (or a module that doesn't + # support the parameter). Remember the scheme and recreate as a classic + # parent so the run can continue. + if ($useLabelGroup -and ($err[0].Exception.Message -match 'IsLabelGroup')) { + $script:tenantLabelScheme = 'Classic' + Write-Host " ('$($LabelDef.Name)': tenant uses the classic label scheme; creating as a classic parent.)" -ForegroundColor DarkGray + $err = @() + try { + Invoke-WithTransientRetry -Description ("New-Label '$($LabelDef.Name)' (classic parent)") -Action { + New-Label @newArgs -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null + } + } catch { + $err = @($_) + } + } + } + + if ($err.Count -eq 0) { + $existing = Get-Label -Identity $createName -ErrorAction SilentlyContinue + if ($existing) { $script:allLabels += $existing } + $idForOps = $createName + $justCreated = $true + $createNote = if ($createName -ne $LabelDef.Name) { " (Name: $createName)" } else { '' } + Write-Host " + Created label '$($LabelDef.Name)'$createNote." -ForegroundColor Green + } else { + $msg = $(Format-IPPSError $err[0]) + + # Recoverable case: a label with the same DisplayName already + # exists (often a Microsoft template label that Get-Label + # didn't return). + if ($msg -match "Duplicate display name '[^']+' is already used by label '([0-9a-f-]{36}[^']*)'") { + $existingGuid = $Matches[1] + $found = Get-Label -Identity $existingGuid -ErrorAction SilentlyContinue + # Validate hierarchy before adopting. Without this check, adopting + # a sub-label as a configured top-level parent (or vice versa) + # would cache the wrong object in $createdParents and trigger + # InvalidSubLabelPriorityException in Phase A. + if ($found) { + $callerWantsTopLevel = -not $ParentId + $foundIsSubLabel = [bool] $found.ParentId + if ($callerWantsTopLevel -eq $foundIsSubLabel) { + $callerScope = if ($callerWantsTopLevel) { 'top-level' } else { "child of parent Id $ParentId" } + $liveScope = if ($foundIsSubLabel) { "sub-label of parent Id $($found.ParentId)" } else { 'top-level' } + $detail = "Duplicate-display-name adoption for '$($LabelDef.DisplayName)' resolved to live label (Id: $existingGuid) in a DIFFERENT hierarchy scope than configured. Configured scope: $callerScope. Live scope: $liveScope. Refusing to adopt to avoid mis-pinning priority (Phase A would then fail with InvalidSubLabelPriorityException). Rename the configured label or un-nest the live label, then re-run." + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'New-Label' ` + -Target $LabelDef.DisplayName -Status 'Failed' -Detail $detail + Write-Warning " $detail" + return $null + } + } + if (-not $found) { + $found = [pscustomobject]@{ + Name = $existingGuid + DisplayName = $LabelDef.DisplayName + Guid = [guid]$existingGuid + Priority = -1 + ParentId = $ParentId + Comment = '' + } + } + $existing = $found + $idForOps = $existingGuid + $matchedBy = 'DisplayName' + $script:allLabels += $found + + if ($AdoptExisting) { + Write-Host " ~ Label '$($LabelDef.DisplayName)' already exists (Id: $existingGuid). -AdoptExisting set; updating." -ForegroundColor Yellow + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'New-Label' -Target $LabelDef.DisplayName -Status 'Adopted' -Detail "Label already exists (Id: $existingGuid). -AdoptExisting set; will update." + # Fall through to update branch. + } else { + Write-Host " = Label '$($LabelDef.DisplayName)' already exists (Id: $existingGuid). Using existing without changes (re-run with -AdoptExisting to overwrite)." -ForegroundColor DarkGray + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'New-Label' -Target $LabelDef.DisplayName -Status 'Skipped' -Detail "Label already exists (Id: $existingGuid). Re-run with -AdoptExisting to overwrite." + return $found + } + } elseif ($msg -match 'LocalizedDisplayNameConflictException' -and + $msg -match "conflict with label '([0-9a-f-]{36}[^']*)'") { + # Recoverable case: New-Label was blocked because an existing + # label (often a Microsoft built-in) already uses a conflicting + # LOCALIZED display name under the same parent — e.g. configured + # 'Specific People' vs a built-in 'Specified People' whose en-US + # localized name is 'Specific People'. Get-LabelByName missed it + # (different primary DisplayName, GUID internal Name), so adopt + # the conflicting label by GUID instead of failing the whole run. + $existingGuid = $Matches[1] + $found = Get-Label -Identity $existingGuid -ErrorAction SilentlyContinue + + # Validate hierarchy scope before adopting: refuse a + # top-level/sub-label mismatch, and for a configured sub-label + # refuse adoption of a live label under a DIFFERENT parent + # (would mis-map the label and break DLP/priority). + if ($found) { + $callerWantsTopLevel = -not $ParentId + $foundIsSubLabel = [bool] $found.ParentId + if (($callerWantsTopLevel -eq $foundIsSubLabel) -or + ($ParentId -and $found.ParentId -and ($found.ParentId -ne $ParentId))) { + $detail = "Localized-display-name conflict for '$($LabelDef.DisplayName)' resolved to live label (Id: $existingGuid) in a DIFFERENT hierarchy scope than configured (configured parent Id: '$ParentId'; live parent Id: '$($found.ParentId)'). Refusing to adopt to avoid mis-mapping. Rename the configured label, or rename/remove the conflicting live label in Purview Admin, then re-run." + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'New-Label' ` + -Target $LabelDef.DisplayName -Status 'Failed' -Detail $detail + Write-Warning " $detail" + return $null + } + } + if (-not $found) { + $found = [pscustomobject]@{ + Name = $existingGuid + DisplayName = $LabelDef.DisplayName + Guid = [guid]$existingGuid + Priority = -1 + ParentId = $ParentId + Comment = '' + } + } + $existing = $found + $idForOps = $existingGuid + $matchedBy = 'DisplayName' + $script:allLabels += $found + + # Adopt as-is (reference by GUID). We deliberately do NOT update + # / rename the conflicting label even with -AdoptExisting: that + # would mutate a Microsoft built-in label and could re-trigger + # the localized-name conflict. The label is still fully usable by + # DLP (path resolution) and publishing (resolved Name). + Write-Host " ~ Label '$($LabelDef.DisplayName)' conflicts on a localized display name with existing label '$($found.DisplayName)' (Id: $existingGuid). Adopting it as-is (not renamed)." -ForegroundColor Yellow + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'New-Label' -Target $LabelDef.DisplayName -Status 'Adopted' -Detail "Localized-display-name conflict with label (Id: $existingGuid); adopted existing label as-is by GUID (not renamed)." + return $found + } elseif ($msg -match 'ComplianceRuleAlreadyExistsInScenarioException' -or + $msg -match "compliance rule with name '[^']+' already exists in scenario") { + # Safety net: the retention-tag pre-flight didn't catch this + # (e.g. Get-ComplianceTag was inaccessible at startup, or the + # tag was created between pre-flight and now). Fail with an + # actionable message instead of a cryptic 500. + Write-Warning " Failed to create label '$($LabelDef.Name)': the name is already reserved in the ComplianceTag scenario (a retention label is using it)." + Write-Warning " Remediation:" + Write-Warning " 1. Inspect the colliding retention tag:" + Write-Warning " Get-ComplianceTag | Where-Object Name -eq '$($LabelDef.Name)'" + Write-Warning " 2. Remove or rename it, OR change the Name in Products/Purview/Config/PurviewConfig.psd1, then re-run." + Write-Warning " (The pre-flight retention-tag collision check would normally auto-rename this — check earlier output for a Get-ComplianceTag warning.)" + return $null + } elseif ($msg -match 'InvalidParentLabelInModernLabelScheme' -or $msg -match 'is not a label group') { + # Modern scheme: the parent exists as a REGULAR label (not a label + # group), so sub-labels cannot be created under it. Happens when the + # parent was created on an earlier run before scheme-branching, or was + # pre-created manually. The parent must be recreated as a label group. + $parentHint = if ($ParentId) { + $pObj = $script:allLabels | Where-Object { $_.Guid -eq $ParentId } | Select-Object -First 1 + if ($pObj) { "'$($pObj.DisplayName)' (Name: $($pObj.Name), Id: $ParentId)" } else { "Id $ParentId" } + } else { '(unknown parent)' } + Write-Warning " Failed to create sub-label '$($LabelDef.Name)': its parent $parentHint exists as a regular label, but this tenant uses the MODERN label scheme, where sub-labels require a label GROUP parent." + Write-Warning " Remediation: in Purview Admin (https://purview.microsoft.com) delete the parent label $parentHint (it has no sub-labels yet), then re-run — the tool recreates it as a label group (-IsLabelGroup)." + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'New-Label' -Target $LabelDef.DisplayName -Status 'Failed' -Detail "Modern-scheme parent is a regular label, not a label group. Delete parent $parentHint and re-run." + return $null + } else { + Write-Warning " Failed to create label '$($LabelDef.Name)': $msg" + return $null + } + } + } + + # Update display / tooltip / comment for pre-existing labels we are + # adopting or already manage. Skip when we just created the label + # because New-Label already set those values. + if (-not $justCreated -and $existing -and ($owned -or $AdoptExisting)) { + if ($PSCmdlet.ShouldProcess($idForOps, 'Set-Label (display + tooltip)')) { + try { + Invoke-WithTransientRetry -Description ("Set-Label display/tooltip '$idForOps'") -Action { + Set-Label -Identity $idForOps ` + -DisplayName $LabelDef.DisplayName ` + -Tooltip $tooltip ` + -Comment $comment ` + -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null + } + Write-Host " ~ Updated label '$($LabelDef.DisplayName)'." -ForegroundColor Yellow + } catch { + Write-Warning " Failed to update label '$($LabelDef.DisplayName)': $(Format-IPPSError $_)" + } + } + } + + # Color drift correction for pre-existing labels we just adopted or + # already manage. New labels (-justCreated) already had the colour + # applied via New-Label -AdvancedSettings so we skip the round-trip. + # AdvancedSettings is merge-not-replace on the IPPS side, so this only + # touches the `color` key and leaves any other advanced settings alone. + if (-not $justCreated -and $existing -and ($owned -or $AdoptExisting) ` + -and -not [string]::IsNullOrWhiteSpace([string]$LabelDef.Color)) { + $configColor = ([string]$LabelDef.Color).Trim() + # Wrap the drift-read in transient retry so a 502/503/504/429 hiccup + # cannot masquerade as 'missing colour' and trigger an unnecessary + # Set-Label round-trip on the next pass. On hard failure, fall back + # to the already-cached $existing object before assuming drift. + $live = $null + try { + Invoke-WithTransientRetry -Description ("Get-Label colour read '$idForOps'") -Action { + $script:__colorReadResult = Get-Label -Identity $idForOps -ErrorAction Stop + } + $live = $script:__colorReadResult + } catch { + $live = $existing + } finally { + Remove-Variable -Scope script -Name '__colorReadResult' -ErrorAction SilentlyContinue + } + $currentColor = Get-LabelAdvancedSetting -Label $live -Key 'color' + $needsUpdate = (-not $currentColor) -or + ($currentColor.ToLowerInvariant() -ne $configColor.ToLowerInvariant()) + if ($needsUpdate) { + if ($PSCmdlet.ShouldProcess($idForOps, "Set-Label color $configColor")) { + try { + Invoke-WithTransientRetry -Description ("Set-Label color '$idForOps'") -Action { + Set-Label -Identity $idForOps ` + -AdvancedSettings @{ color = $configColor } ` + -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null + } + $detail = if ($currentColor) { + "Updated label '$($LabelDef.DisplayName)' colour from $currentColor to $configColor." + } else { + "Set label '$($LabelDef.DisplayName)' colour to $configColor." + } + Write-Host " ~ $detail" -ForegroundColor Yellow + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label color' -Target $LabelDef.DisplayName -Status 'Updated' -Detail $detail + } catch { + $msg = Format-IPPSError $_ + Write-Warning " Failed to update colour for label '$($LabelDef.DisplayName)': $msg" + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label color' -Target $LabelDef.DisplayName -Status 'Failed' -Detail $msg + } + } + } + } + + # ContentType drift correction. + # + # Only runs on the adopt/update path — when a label is freshly created, + # New-Label above already applied the desired ContentType. For pre- + # existing labels we use UNION-not-replace: live ContentType UNION + # desired ContentType. This ensures: + # * We never strip a scope a customer manually added in the portal + # when merging the desired scope. + # * Re-running the deploy is a no-op when the live set already + # covers the desired set (sorted-set equality, no false 'Updated' + # log lines). + # + # The -SkipContainerLabels gate runs inside Get-DesiredContentTypeSet, + # so the UNION operand already has 'Site'/'UnifiedGroup' stripped when + # the operator opted out — but we still preserve any container bits + # the live label already has. We only ADD scope, never remove it. + if (-not $justCreated -and $existing -and ($owned -or $AdoptExisting)) { + $desiredCt = Get-DesiredContentTypeSet -LabelDef $LabelDef -SkipContainerLabels:$SkipContainerLabels.IsPresent + if ($null -ne $desiredCt -and $desiredCt.Count -gt 0) { + # Drift-read on a fresh Get-Label so we don't compare against a + # potentially-stale cached object (mirrors the colour block above). + $liveCt = $null + try { + Invoke-WithTransientRetry -Description ("Get-Label ContentType read '$idForOps'") -Action { + $script:__ctReadResult = Get-Label -Identity $idForOps -ErrorAction Stop + } + $liveCt = $script:__ctReadResult + } catch { + $liveCt = $existing + } finally { + Remove-Variable -Scope script -Name '__ctReadResult' -ErrorAction SilentlyContinue + } + $liveSet = ConvertTo-ContentTypeSet -Value ([string]$liveCt.ContentType) + $unionSet = @(@($liveSet + $desiredCt) | Sort-Object -Unique) + $isNoOp = Compare-ContentTypeSets -A $liveSet -B $unionSet + if (-not $isNoOp) { + if ($PSCmdlet.ShouldProcess($idForOps, "Set-Label ContentType $($unionSet -join ', ')")) { + try { + Invoke-WithTransientRetry -Description ("Set-Label ContentType '$idForOps'") -Action { + Set-Label -Identity $idForOps ` + -ContentType ($unionSet -join ', ') ` + -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null + } + $added = @($unionSet | Where-Object { $_ -notin $liveSet }) + $detail = if ($liveSet.Count -eq 0) { + "Set label '$($LabelDef.DisplayName)' ContentType to '$($unionSet -join ', ')'." + } else { + "Extended label '$($LabelDef.DisplayName)' ContentType from '$($liveSet -join ', ')' to '$($unionSet -join ', ')' (added: $($added -join ', ')). Live scope bits not in config were preserved (UNION-not-replace)." + } + Write-Host " ~ $detail" -ForegroundColor Yellow + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label ContentType' -Target $LabelDef.DisplayName -Status 'Updated' -Detail $detail + } catch { + $msg = Format-IPPSError $_ + Write-Warning " Failed to update ContentType for label '$($LabelDef.DisplayName)': $msg" + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label ContentType' -Target $LabelDef.DisplayName -Status 'Failed' -Detail $msg + } + } + } + } + } + + # Encryption (newly created OR managed OR -AdoptExisting) + if ($LabelDef.Encrypt -and $existing -and ($justCreated -or $owned -or $AdoptExisting)) { + $protType = if ($LabelDef.ProtectionType) { $LabelDef.ProtectionType } else { 'Template' } + $outlookBehavior = if ($LabelDef.UserDefinedOutlookBehavior) { $LabelDef.UserDefinedOutlookBehavior } else { 'None' } + # Per-label rights bundle override. When the label config carries its own + # `EncryptionRightsDefinitions`, resolve {TenantDomain} against the same + # TenantIdentity and pass to Set-LabelEncryption. Otherwise the function + # falls back to the module-level `$rights` default. Used by HCAllEmps + # (Co-Author bundle: View/Edit/Save/Copy/Print/Allow-Macros/Reply...) + # without affecting siblings that should stay on the Reviewer default. + $labelRights = $null + if ($LabelDef.EncryptionRightsDefinitions) { + $labelRights = Resolve-EncryptionRightsTokens ` + -RightsDefinitions $LabelDef.EncryptionRightsDefinitions ` + -TenantIdentity $TenantIdentity ` + -Source "Labels.$($LabelDef.Name).EncryptionRightsDefinitions" + } + $shouldProcessTarget = if ($protType -eq 'Template') { + $bundleNote = if ($labelRights) { 'custom rights' } else { "$rightsBundleName rights" } + "Apply encryption (Template / $bundleNote)" + } else { + "Apply encryption (UserDefined / Outlook=$outlookBehavior)" + } + if ($PSCmdlet.ShouldProcess($idForOps, $shouldProcessTarget)) { + try { + $encParams = @{ + Identity = $idForOps + ProtectionType = $protType + UserDefinedOutlookBehavior = $outlookBehavior + } + if ($labelRights) { $encParams['RightsDefinitions'] = $labelRights } + Set-LabelEncryption @encParams + $effectiveRights = if ($labelRights) { $labelRights } else { $rights } + $encDetail = if ($protType -eq 'Template') { + "Applied Template encryption to label '$($LabelDef.DisplayName)' with rights: $effectiveRights." + } else { + "Applied UserDefined encryption to label '$($LabelDef.DisplayName)' (Outlook=$outlookBehavior)." + } + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label encryption' -Target $LabelDef.DisplayName -Status 'Updated' -Detail $encDetail + } + catch { + $encMsg = Format-IPPSError $_ + Write-Warning " Encryption update failed for '$($LabelDef.DisplayName)': $encMsg" + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label encryption' -Target $LabelDef.DisplayName -Status 'Failed' -Detail $encMsg + } + } + } + + # Content marking (newly created OR managed OR -AdoptExisting) + # Skipped entirely when $Config.EnableContentMarking is $false (the + # toolkit-wide master switch). Per-label `ContentMark = $true` settings + # are honoured only when the master switch is on. + $contentMarkingEnabled = $true + if ($Config -and $Config.PSObject.Properties.Name -contains 'EnableContentMarking' -and $null -ne $Config.EnableContentMarking) { + $contentMarkingEnabled = [bool] $Config.EnableContentMarking + } elseif ($Config -is [hashtable] -and $Config.ContainsKey('EnableContentMarking')) { + $contentMarkingEnabled = [bool] $Config['EnableContentMarking'] + } + if ($contentMarkingEnabled -and $LabelDef.ContentMark -and $existing -and ($justCreated -or $owned -or $AdoptExisting) ` + -and $PSCmdlet.ShouldProcess($idForOps, 'Apply content marking')) { + try { + Set-LabelContentMarking -Identity $idForOps ` + -HeaderText $LabelDef.HeaderText ` + -FooterText $LabelDef.FooterText ` + -WatermarkText $LabelDef.WatermarkText + } catch { Write-Warning " Content-marking update failed for '$($LabelDef.DisplayName)': $($_.Exception.Message)" } + } + + $final = Get-Label -Identity $idForOps -ErrorAction SilentlyContinue + if ($final) { return $final } else { return $existing } +} + +# --------------------------------------------------------------------------- +# Pass 0 — upfront validation (name length + colour hex) +# +# Purview enforces a 64-character maximum on label, sub-label, and label-policy +# names. The IPPS backend rejects longer names with an empty/cryptic error, so +# we validate upfront to give partners a clear, actionable diagnostic. +# +# Label colours are passed through to `New-Label/Set-Label -AdvancedSettings +# @{color=...}` and must be valid 6-digit hex triplets (`#RRGGBB`). Anything +# else (3-digit shorthand, named colours, missing `#`) is rejected upfront +# because the backend silently drops malformed values and the colour silently +# fails to appear. +# --------------------------------------------------------------------------- +foreach ($lbl in $Config.Labels) { + if ($lbl.Name.Length -gt 64) { + throw "Sensitivity label name '$($lbl.Name)' is $($lbl.Name.Length) characters; Microsoft Purview enforces a 64-character maximum. Edit PurviewConfig.psd1 and shorten the Labels.Name field." + } + if ($lbl.DisplayName -and $lbl.DisplayName.Length -gt 64) { + throw "Sensitivity label DisplayName '$($lbl.DisplayName)' is $($lbl.DisplayName.Length) characters; Microsoft Purview enforces a 64-character maximum. Edit PurviewConfig.psd1 and shorten the Labels.DisplayName field." + } + if ($lbl.PSObject.Properties.Match('Color').Count -gt 0 -or ($lbl -is [hashtable] -and $lbl.ContainsKey('Color'))) { + $c = [string]$lbl.Color + if (-not [string]::IsNullOrWhiteSpace($c) -and $c -notmatch '^#[0-9A-Fa-f]{6}$') { + throw "Sensitivity label '$($lbl.Name)' has invalid Color '$c'; must be a 6-digit hex triplet like '#13A10E'. Edit PurviewConfig.psd1." + } + } + foreach ($sub in @($lbl.SubLabels)) { + if (-not $sub) { continue } + if ($sub.Name.Length -gt 64) { + throw "Sub-label name '$($sub.Name)' (under parent '$($lbl.Name)') is $($sub.Name.Length) characters; Microsoft Purview enforces a 64-character maximum. Edit PurviewConfig.psd1." + } + if ($sub.DisplayName -and $sub.DisplayName.Length -gt 64) { + throw "Sub-label DisplayName '$($sub.DisplayName)' is $($sub.DisplayName.Length) characters; Microsoft Purview enforces a 64-character maximum. Edit PurviewConfig.psd1." + } + if ($sub.PSObject.Properties.Match('Color').Count -gt 0 -or ($sub -is [hashtable] -and $sub.ContainsKey('Color'))) { + $sc = [string]$sub.Color + if (-not [string]::IsNullOrWhiteSpace($sc) -and $sc -notmatch '^#[0-9A-Fa-f]{6}$') { + throw "Sub-label '$($sub.Name)' (under parent '$($lbl.Name)') has invalid Color '$sc'; must be a 6-digit hex triplet like '#EAA300'. Edit PurviewConfig.psd1." + } + } + } +} +if ($Config.LabelPolicy -and $Config.LabelPolicy.Name -and $Config.LabelPolicy.Name.Length -gt 64) { + throw "Label policy name '$($Config.LabelPolicy.Name)' is $($Config.LabelPolicy.Name.Length) characters; Microsoft Purview enforces a 64-character maximum. Edit PurviewConfig.psd1 and shorten the LabelPolicy.Name field." +} + +# --------------------------------------------------------------------------- +# Pre-flight: container-scope sanity check. +# +# When the operator passed -SkipContainerLabels (or license auto-detect did +# so for them), Setup-TenantSettings.ps1 step [5/5] skips the +# Group.Unified `EnableMIPLabels = $true` toggle. Without that tenant-side +# toggle on, the 'Site' / 'UnifiedGroup' bits we'd otherwise stamp on +# selected labels have no effect — Teams / Groups / SharePoint sites do +# not surface the labels in their picker. +# +# We strip those bits per-label inside Get-DesiredContentTypeSet (logged at +# Skipped status). The pre-flight below surfaces ONE roll-up info line so +# the operator sees the trade-off upfront in the console, even when the +# affected labels are deep in the config tree. +# --------------------------------------------------------------------------- +if ($SkipContainerLabels.IsPresent) { + $configuredContainerLabels = New-Object System.Collections.Generic.List[string] + foreach ($lbl in $Config.Labels) { + if ($lbl.ContainsKey('ContentType') -and + (ConvertTo-ContentTypeSet -Value ([string]$lbl.ContentType) | Where-Object { $_ -in $script:ContainerContentTypeBits })) { + [void]$configuredContainerLabels.Add($lbl.DisplayName) + } + if ($lbl.SubLabels) { + foreach ($s in $lbl.SubLabels) { + if ($s.ContainsKey('ContentType') -and + (ConvertTo-ContentTypeSet -Value ([string]$s.ContentType) | Where-Object { $_ -in $script:ContainerContentTypeBits })) { + [void]$configuredContainerLabels.Add("$($lbl.DisplayName)\$($s.DisplayName)") + } + } + } + } + if ($configuredContainerLabels.Count -gt 0) { + Write-Host ("[info] -SkipContainerLabels is set. Container-scope bits (Site, UnifiedGroup) will be stripped from these label(s): {0}. The labels will still be provisioned with the remaining scope (File, Email). Re-run without -SkipContainerLabels (and ensure Setup-TenantSettings.ps1 enables Group.Unified EnableMIPLabels) to make these labels selectable in Teams / Microsoft 365 Groups / SharePoint sites." -f ($configuredContainerLabels -join ', ')) -ForegroundColor DarkYellow + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Pre-flight ContentType' ` + -Target '(container-scope summary)' -Status 'Info' ` + -Detail ("-SkipContainerLabels is set; container-scope bits (Site, UnifiedGroup) will be stripped from {0} configured label(s): {1}." -f + $configuredContainerLabels.Count, ($configuredContainerLabels -join ', ')) + } +} + +# --------------------------------------------------------------------------- +# Pass 1 — create / update parent labels +# --------------------------------------------------------------------------- +Write-Host "Creating sensitivity labels..." -ForegroundColor Cyan +$script:createdParents = @{} +foreach ($lbl in $Config.Labels) { + Write-Host " Label: $($lbl.Name)" -ForegroundColor White + $parent = New-OrUpdate-Label -LabelDef $lbl + $script:createdParents[$lbl.Name] = $parent +} + +# --------------------------------------------------------------------------- +# Pass 1.5 - preflight: enforce that every cached parent is actually a +# TOP-LEVEL live label. Pass 1's New-OrUpdate-Label already surfaces a Warning +# + null cache entry when a cross-scope Name collision is detected upfront, +# but the duplicate-display-name recovery path can also unmask a sub-label. +# This unified throw gives the operator a single actionable error before +# Pass 2 wires sub-labels under the wrong object and Pass 3 Phase A explodes +# with InvalidSubLabelPriorityException on a modern-scheme tenant. +# --------------------------------------------------------------------------- +$parentScopeCollisions = @() +foreach ($lbl in $Config.Labels) { + $cached = $script:createdParents[$lbl.Name] + if (-not $cached) { continue } # Pass 1 creation failed; warning already emitted. + if ($cached.PSObject.Properties.Name -contains 'ParentId' -and $cached.ParentId) { + $parentScopeCollisions += [pscustomobject]@{ + ConfigName = $lbl.Name + DisplayName = $lbl.DisplayName + LiveGuid = if ($cached.Guid) { [string]$cached.Guid } else { 'unknown' } + LiveParentId = [string]$cached.ParentId + } + } +} +if ($parentScopeCollisions.Count -gt 0) { + $lines = foreach ($c in $parentScopeCollisions) { + " - Configured parent label '$($c.ConfigName)' (DisplayName '$($c.DisplayName)') resolved to LIVE label (Id: $($c.LiveGuid)) that is a SUB-LABEL of parent (Id: $($c.LiveParentId))." + } + $detail = @( + "Parent label scope collision detected after Pass 1. The modern label scheme strictly enforces sub-label priority ranges, so a configured top-level label that resolves to an existing sub-label cannot be reordered to priority 0." + ($lines -join "`n") + "Remediation options:" + " 1. Rename the configured label in Products/Purview/Config/PurviewConfig.psd1 (Labels.Name and Labels.DisplayName) so it no longer collides with the existing sub-label." + " 2. In Purview Admin Center (https://purview.microsoft.com), edit the colliding live sub-label to move it out from under its parent (or delete it if unused), then re-run." + " 3. If the collision is with a Microsoft-default sensitivity label, update the configured Name to a tenant-unique value." + ) -join "`n" + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Preflight-ParentScope' ` + -Target 'configured parents' -Status 'Failed' -Detail $detail + throw $detail +} + +# --------------------------------------------------------------------------- +# Pass 2 — create / update sub-labels +# --------------------------------------------------------------------------- +Write-Host "Creating sub-labels..." -ForegroundColor Cyan +foreach ($lbl in $Config.Labels) { + if (-not $lbl.SubLabels) { continue } + foreach ($sub in $lbl.SubLabels) { + Write-Host " Sub-label: $($lbl.Name)/$($sub.Name)" -ForegroundColor White + $parentObj = $script:createdParents[$lbl.Name] + if (-not $parentObj) { + if ($WhatIfPreference) { + Write-Host " What if: parent '$($lbl.Name)' does not yet exist; would create sub-label '$($sub.Name)' under it." -ForegroundColor DarkYellow + } else { + Write-Warning "Parent label '$($lbl.Name)' not available; skipping sub-label." + } + continue + } + $null = New-OrUpdate-Label -LabelDef $sub -ParentId $parentObj.Guid + } +} + +# --------------------------------------------------------------------------- +# Pass 3 — set label priority (least sensitive first → priority 0) +# +# Purview enforces two ordering constraints that break a naive +# "Set-Label -Priority $i" loop with a flat global counter: +# (1) a top-level label can only swap with another top-level slot, and +# (2) a sub-label can only move within its parent's contiguous block. +# Trying to set a child to a top-level slot (or vice versa) fails with +# InvalidLabelPriorityException / InvalidSubLabelPriorityException. +# +# Workaround — the standard Purview reorder pattern: +# Phase A (top-level): iterate $Config.Labels in REVERSE order and push +# each parent to priority 0. Sub-blocks move with their parents. +# This shoves any unmanaged top-level labels (e.g. the MS-managed +# "Personal") to the back automatically. +# Phase B (sub-labels): for each parent, iterate its sub-labels in +# REVERSE order and push each to (parent.Priority + 1). Within the +# parent's block this rotates them into config order. +# Both phases pre-check whether the order is already correct so re-runs +# are no-ops (and avoid the "0 is not a valid priority for this label" +# error you get when Set-Label asks for a label's current slot). +# --------------------------------------------------------------------------- +Write-Host "Setting label priority order..." -ForegroundColor Cyan + +# Identify unmanaged top-level labels (e.g. tenant-provisioned 'Personal' +# from the Microsoft default sensitivity labels policy). 'Personal' is +# pinned at priority 0 (the lowest) so it stays out of the way of users +# but is still available; other unmanaged labels follow it. +$configuredDisplayNamesEarly = @() +foreach ($lbl in $Config.Labels) { + $configuredDisplayNamesEarly += $lbl.DisplayName + if ($lbl.SubLabels) { $configuredDisplayNamesEarly += $lbl.SubLabels.DisplayName } +} + +# Refresh the $allLabels cache with LIVE state from Purview BEFORE slot +# accounting and Phase A. A single fresh snapshot is reused for unmanaged +# top-level detection AND live sub-label counts so both views agree (and +# downstream Resolve-ConfigParentLabel / Get-LabelByName see the same data). +# The initial snapshot at the top of the script was taken before Pass 2 +# created labels (and before any prior Pass 3 run shuffled priorities); +# reading priorities from a stale cache causes Phase A to skip labels that +# look "already correct" but aren't, which is how 'Public' gets stranded +# at the wrong end of the priority list on multi-run tenants. +$script:allLabels = @(Get-Label -ErrorAction SilentlyContinue | Where-Object { -not (Test-LabelSoftDeleted $_) }) +$allTopLevel = @($script:allLabels | Where-Object { -not $_.ParentId }) +$unmanagedTopLevel = @($allTopLevel | Where-Object { $configuredDisplayNamesEarly -notcontains $_.DisplayName }) + +# Total slot footprint of unmanaged top-level labels = each unmanaged parent +# PLUS its live direct children. Personal has no children in practice, but +# counting children defensively handles arbitrary unmanaged hierarchies and +# keeps configured-label slot accounting correct regardless. +$unmanagedFootprint = 0 +foreach ($u in $unmanagedTopLevel) { + $unmanagedFootprint++ + if ($u.Guid) { + $unmanagedFootprint += @($script:allLabels | Where-Object { $_.ParentId -eq $u.Guid }).Count + } +} + +# Build pin-order: 'Personal' FIRST (so it gets pushed LAST in reverse +# iteration and lands at slot 0), then any other unmanaged labels. +$personalLbl = @($unmanagedTopLevel | Where-Object { $_.DisplayName -eq 'Personal' }) +$otherUnmanaged = @($unmanagedTopLevel | Where-Object { $_.DisplayName -ne 'Personal' }) +$pinOrder = @() +if ($personalLbl.Count -gt 0) { $pinOrder += $personalLbl[0] } +if ($otherUnmanaged.Count -gt 0) { $pinOrder += $otherUnmanaged } + +# Compute the expected GLOBAL priority for each configured top-level label. +# Slots 0..unmanagedFootprint-1 are reserved for unmanaged top-level labels +# and their children (Personal at slot 0); configured labels start at +# $unmanagedFootprint. +# +# Each managed parent's slot footprint is 1 (parent) + LIVE direct sub-label +# count. Using LIVE count (not config count) is critical when a managed +# parent has extra "phantom" sub-labels left over from a previous failed +# run: those extras still occupy slots in the parent's block, so a config- +# only count under-reserves and downstream parents land at the wrong slot. +# Use MAX(live, config) so we don't under-reserve when the live snapshot is +# missing newly-created children due to eventual consistency. +$expectedParentPriority = @{} +$cursor = $unmanagedFootprint +foreach ($lbl in $Config.Labels) { + $expectedParentPriority[$lbl.DisplayName] = $cursor + $cursor++ + $parentObjForCount = Resolve-ConfigParentLabel -LabelDef $lbl + $configChildCount = if ($lbl.SubLabels) { $lbl.SubLabels.Count } else { 0 } + $liveChildren = @() + if ($parentObjForCount -and $parentObjForCount.Guid) { + $liveChildren = @($script:allLabels | Where-Object { $_.ParentId -eq $parentObjForCount.Guid }) + } + $effectiveChildCount = if ($liveChildren.Count -gt $configChildCount) { $liveChildren.Count } else { $configChildCount } + $cursor += $effectiveChildCount + + # Identity-based phantom detection: warn about any live sub-label whose + # Name AND DisplayName don't match any configured sub-label under this + # parent. They occupy slot(s) inside the parent's block but won't be + # reordered by Phase B; admin should rename or remove them in Purview UI. + if ($parentObjForCount -and $liveChildren.Count -gt 0) { + $configChildNames = @() + $configChildDisplayNames = @() + if ($lbl.SubLabels) { + $configChildNames = @($lbl.SubLabels | ForEach-Object { $_.Name }) + $configChildDisplayNames = @($lbl.SubLabels | ForEach-Object { $_.DisplayName }) + } + $extras = @($liveChildren | Where-Object { + ($configChildNames -notcontains $_.Name) -and ($configChildDisplayNames -notcontains $_.DisplayName) + }) + if ($extras.Count -gt 0) { + $extrasDesc = ($extras | ForEach-Object { "'$($_.DisplayName)' [Name=$($_.Name); Guid=$($_.Guid)]" }) -join ', ' + Write-Warning (" Managed parent '{0}' has {1} unmanaged live sub-label(s): {2}. They occupy slot(s) in the parent's block; rename or remove them in Purview Admin if not intentional." -f $lbl.DisplayName, $extras.Count, $extrasDesc) + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Detect-PhantomSubLabel' ` + -Target $lbl.DisplayName -Status 'Info' ` + -Detail "Live sub-label count ($($liveChildren.Count)) exceeds configured ($configChildCount). Extras: $extrasDesc" + } + } +} + +# Pre-check: are config parents already in the right slots AND is Personal at 0? +# Use Get-LiveLabelByObject (bulk-fetch fallback) so the precheck succeeds even +# for Microsoft-default labels where Get-Label -Identity by Guid returns null. +$parentsAlreadyCorrect = $true +foreach ($lbl in $Config.Labels) { + $obj = Resolve-ConfigParentLabel -LabelDef $lbl + if (-not $obj) { + $parentsAlreadyCorrect = $false + break + } + $live = Get-LiveLabelByObject -LabelObject $obj + if (-not $live) { + $parentsAlreadyCorrect = $false + break + } + if ($live.Priority -ne $expectedParentPriority[$lbl.DisplayName]) { + $parentsAlreadyCorrect = $false + break + } +} +$personalAlreadyAtZero = $true +if ($personalLbl.Count -gt 0) { + $pNow = Get-LiveLabelByObject -LabelObject $personalLbl[0] + if ($pNow -and $pNow.Priority -ne 0) { $personalAlreadyAtZero = $false } +} + +if (-not $parentsAlreadyCorrect -or -not $personalAlreadyAtZero) { + # Phase A — config top-levels: reverse-push to 0. Final config order: + # Config.Labels[0] at slot 0, Config.Labels[1] at slot 1, ... + # + # Each Set-Label is verified via a fresh bulk Get-Label and retried with + # alternate Identity forms if the priority did not actually change. This + # is the fix for the classic "Public stuck at the bottom" bug — IPPS + # Set-Label silently no-ops on Microsoft-default labels when called with + # a Guid identity, but accepts DisplayName for the same label. + for ($i = $Config.Labels.Count - 1; $i -ge 0; $i--) { + $lbl = $Config.Labels[$i] + $obj = Resolve-ConfigParentLabel -LabelDef $lbl + if (-not $obj) { + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label priority=0' ` + -Target $lbl.DisplayName -Status 'Skipped' ` + -Detail "Configured label could not be resolved in createdParents cache or via Get-LabelByName. Bulk Get-Label may not be surfacing it (common for Microsoft-default labels)." + continue + } + $identityForShouldProcess = Get-StableLabelIdentity -LabelObject $obj + if ($PSCmdlet.ShouldProcess($identityForShouldProcess, "Set priority=0 (top-level reorder)")) { + $r = Set-LabelPriorityWithVerify -LabelObject $obj -TargetPriority 0 -DisplayNameForLog $lbl.DisplayName + if ($r.Success) { + # Refresh cache so later phases / verification see the new state. + $script:createdParents[$lbl.Name] = $r.Live + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label priority=0' ` + -Target $lbl.DisplayName -Status 'Succeeded' ` + -Detail ("Used identity '{0}'. Attempts: {1}" -f $r.UsedIdentity, ($r.Attempts -join ' | ')) + } else { + $detail = "All identity forms failed to update priority. Attempts: " + ($r.Attempts -join ' | ') + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label priority=0' ` + -Target $lbl.DisplayName -Status 'Failed' -Detail $detail + throw "Label priority reorder failed for '$($lbl.DisplayName)'. $detail. Re-run after addressing the underlying error." + } + } + } + + # Phase A2 — pin unmanaged top-levels (Personal first in $pinOrder so + # it gets pushed LAST and ends up at slot 0). Uses the same verified + # helper so MS-default unmanaged labels (e.g. 'Personal') also benefit + # from identity-form fallback. + for ($i = $pinOrder.Count - 1; $i -ge 0; $i--) { + $u = $pinOrder[$i] + $uLive = Get-LiveLabelByObject -LabelObject $u + if (-not $uLive) { + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label priority=0 (unmanaged)' ` + -Target $u.DisplayName -Status 'Skipped' ` + -Detail "Bulk Get-Label could not resolve unmanaged top-level label '$($u.DisplayName)'. May have been deleted between Phase A and Phase A2." + continue + } + if ($PSCmdlet.ShouldProcess($uLive.Name, "Set priority=0 (pin unmanaged label)")) { + $r = Set-LabelPriorityWithVerify -LabelObject $uLive -TargetPriority 0 -DisplayNameForLog $u.DisplayName + if (-not $r.Success) { + $detail = "All identity forms failed to pin unmanaged label. Attempts: " + ($r.Attempts -join ' | ') + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label priority=0 (unmanaged)' ` + -Target $u.DisplayName -Status 'Failed' -Detail $detail + throw "Label priority reorder failed for unmanaged label '$($u.DisplayName)'. $detail. Re-run after addressing the underlying error." + } + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label priority=0 (unmanaged)' ` + -Target $u.DisplayName -Status 'Succeeded' ` + -Detail ("Used identity '{0}'." -f $r.UsedIdentity) + } + } +} + +# Phase B — sub-label reorder within each parent's block +# +# Sub-label priorities are GLOBAL (not relative to the parent), but Purview +# enforces that a sub-label can only move within its parent's contiguous block. +# Derive the first child slot from the LIVE parent priority (refreshed via +# Get-LiveLabelByObject) and sanity-check against the expected slot so we +# never target a slot outside the parent's actual live block — which causes +# InvalidSubLabelPriorityException when Phase A's expected slot calculation +# is off (e.g. tenant has phantom unmanaged sub-labels under a managed parent). +foreach ($lbl in $Config.Labels) { + if (-not $lbl.SubLabels -or $lbl.SubLabels.Count -eq 0) { continue } + $parentMeta = Resolve-ConfigParentLabel -LabelDef $lbl + if (-not $parentMeta) { + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Phase B parent resolve' ` + -Target $lbl.DisplayName -Status 'Skipped' ` + -Detail "Parent label could not be resolved; sub-label reorder skipped for '$($lbl.DisplayName)'." + continue + } + $parentObj = Get-LiveLabelByObject -LabelObject $parentMeta + if (-not $parentObj) { $parentObj = $parentMeta } + if (-not $parentObj.Guid) { + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Phase B parent resolve' ` + -Target $lbl.DisplayName -Status 'Skipped' ` + -Detail "Parent label resolved but has no Guid; cannot match sub-labels by ParentId. Skipping." + continue + } + + # Phase B derives child slots from the LIVE parent priority (not from the + # statically computed expected) so it stays correct even if the parent + # ended up at a slightly different slot than expected (e.g. IPPS eventual + # consistency right after Phase A). Sanity-check live vs expected first; + # if they disagree after one refresh, defer Phase B for this parent so + # the verifier/recovery can fix the parent on a subsequent pass. + $expectedParentSlot = [int]$expectedParentPriority[$lbl.DisplayName] + if ($parentObj.Priority -ne $expectedParentSlot) { + Start-Sleep -Seconds 2 + $refreshed = Get-LiveLabelByObject -LabelObject $parentMeta + if ($refreshed) { $parentObj = $refreshed } + } + if ($parentObj.Priority -ne $expectedParentSlot) { + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Phase B sub-label reorder' ` + -Target $lbl.DisplayName -Status 'Skipped' ` + -Detail "Parent priority $($parentObj.Priority) does not match expected $expectedParentSlot after refresh; deferring sub-label reorder so verifier can recover parent first." + Write-Warning " Sub-label reorder deferred for '$($lbl.DisplayName)' (parent at slot $($parentObj.Priority), expected $expectedParentSlot)." + continue + } + $firstChildSlot = [int]$parentObj.Priority + 1 + + # Pre-check: are the sub-labels already in config order? + $childrenCorrect = $true + $expectedSlot = $firstChildSlot + foreach ($sub in $lbl.SubLabels) { + $subMeta = Get-LabelByName -Name $sub.Name -DisplayName $sub.DisplayName -ParentId $parentObj.Guid -Scope 'Parent' + if (-not $subMeta) { $childrenCorrect = $false; break } + $subLive = Get-LiveLabelByObject -LabelObject $subMeta + if (-not $subLive -or $subLive.Priority -ne $expectedSlot) { $childrenCorrect = $false; break } + $expectedSlot++ + } + if ($childrenCorrect) { continue } + + for ($i = $lbl.SubLabels.Count - 1; $i -ge 0; $i--) { + $sub = $lbl.SubLabels[$i] + $subMeta = Get-LabelByName -Name $sub.Name -DisplayName $sub.DisplayName -ParentId $parentObj.Guid -Scope 'Parent' + if (-not $subMeta) { + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action "Set-Label priority=$firstChildSlot" ` + -Target $sub.DisplayName -Status 'Skipped' ` + -Detail "Sub-label could not be resolved under parent '$($lbl.DisplayName)'." + continue + } + $subIdentForShouldProcess = Get-StableLabelIdentity -LabelObject $subMeta + if ($PSCmdlet.ShouldProcess($subIdentForShouldProcess, "Set priority=$firstChildSlot (sub-label reorder)")) { + $r = Set-LabelPriorityWithVerify -LabelObject $subMeta -TargetPriority $firstChildSlot ` + -DisplayNameForLog ("{0}/{1}" -f $lbl.DisplayName, $sub.DisplayName) -IsSubLabel + if (-not $r.Success) { + $detail = "All identity forms failed to update sub-label priority. Attempts: " + ($r.Attempts -join ' | ') + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action "Set-Label priority=$firstChildSlot" ` + -Target $sub.DisplayName -Status 'Failed' -Detail $detail + # Sub-label failures are warned but don't throw — the post-reorder + # verification covers top-level priorities; sub-label ordering + # within a block is best-effort and recoverable in Purview UI. + Write-Warning " Sub-label priority update failed for '$($sub.DisplayName)'. See run log for details." + } + } + } +} + +# --------------------------------------------------------------------------- +# Pass 3 verification — confirm every configured top-level label is at its +# expected priority. If not, attempt ONE recovery push, then re-verify and +# throw with a diagnostic detail if still wrong. +# +# This catches silent skips in Phase A (e.g. a Microsoft-default label that +# bulk Get-Label fails to surface), which is how 'Public' previously got +# stranded at the wrong priority without any error in the run log. +# --------------------------------------------------------------------------- +$script:allLabels = @(Get-Label -ErrorAction SilentlyContinue | Where-Object { -not (Test-LabelSoftDeleted $_) }) + +function Test-ParentPrioritiesCorrect { + $mismatches = @() + foreach ($lbl in $Config.Labels) { + $obj = Resolve-ConfigParentLabel -LabelDef $lbl + if (-not $obj) { + $mismatches += [pscustomobject]@{ + Label = $lbl + Reason = 'unresolved' + Identity = $null + Actual = $null + Expected = $expectedParentPriority[$lbl.DisplayName] + } + continue + } + # Use the robust bulk-fetch resolver instead of Get-Label -Identity, + # because Get-Label -Identity by Guid returns null for some + # Microsoft-default labels even when the label clearly exists in the + # tenant. That false-null is what previously made the verifier + # report "live-lookup-failed" for Public. + $live = Get-LiveLabelByObject -LabelObject $obj + $actual = if ($live) { $live.Priority } else { $obj.Priority } + $expected = $expectedParentPriority[$lbl.DisplayName] + if ($actual -ne $expected) { + $mismatches += [pscustomobject]@{ + Label = $lbl + Reason = if ($live) { 'wrong-priority' } else { 'live-lookup-failed' } + Identity = if ($obj.Guid) { [string]$obj.Guid } else { [string]$obj.Name } + Actual = $actual + Expected = $expected + } + } + } + return ,$mismatches +} + +$mismatches = Test-ParentPrioritiesCorrect +if ($mismatches.Count -gt 0) { + Write-Host " ! Priority order off after primary reorder; attempting recovery..." -ForegroundColor Yellow + # Reverse-iterate Config.Labels (same direction as Phase A) and push any + # mismatched label to slot 0 using the same verified helper. The reverse + # direction preserves intent: the LAST push wins slot 0, so we need to + # push every label above the lowest mismatched label as well. + $mismatchedNames = @{} + foreach ($m in $mismatches) { $mismatchedNames[$m.Label.Name] = $true } + for ($i = $Config.Labels.Count - 1; $i -ge 0; $i--) { + $lbl = $Config.Labels[$i] + if (-not $mismatchedNames.ContainsKey($lbl.Name)) { continue } + $obj = Resolve-ConfigParentLabel -LabelDef $lbl + if (-not $obj) { + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label priority=0 (recovery)' ` + -Target $lbl.DisplayName -Status 'Failed' ` + -Detail "Cannot resolve label for recovery push." + continue + } + $idForShouldProcess = Get-StableLabelIdentity -LabelObject $obj + if ($PSCmdlet.ShouldProcess($idForShouldProcess, "Set priority=0 (priority recovery)")) { + $r = Set-LabelPriorityWithVerify -LabelObject $obj -TargetPriority 0 -DisplayNameForLog $lbl.DisplayName + if ($r.Success) { + $script:createdParents[$lbl.Name] = $r.Live + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label priority=0 (recovery)' ` + -Target $lbl.DisplayName -Status 'Succeeded' ` + -Detail ("Used identity '{0}'. Attempts: {1}" -f $r.UsedIdentity, ($r.Attempts -join ' | ')) + } else { + $detail = "All identity forms failed during recovery. Attempts: " + ($r.Attempts -join ' | ') + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label priority=0 (recovery)' ` + -Target $lbl.DisplayName -Status 'Failed' -Detail $detail + Write-Warning " Recovery Set-Label failed for '$($lbl.DisplayName)': $detail" + } + } + } + # Re-pin unmanaged labels (Personal last so it lands at 0) after the recovery push. + for ($i = $pinOrder.Count - 1; $i -ge 0; $i--) { + $u = $pinOrder[$i] + $uLive = Get-LiveLabelByObject -LabelObject $u + if (-not $uLive) { continue } + if ($PSCmdlet.ShouldProcess($uLive.Name, "Set priority=0 (recovery pin unmanaged)")) { + $r = Set-LabelPriorityWithVerify -LabelObject $uLive -TargetPriority 0 -DisplayNameForLog $u.DisplayName + if (-not $r.Success) { + Write-Warning " Recovery Set-Label failed for unmanaged label '$($u.DisplayName)'. See run log for diagnostic detail." + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Set-Label priority=0 (recovery unmanaged)' ` + -Target $u.DisplayName -Status 'Failed' ` + -Detail ("All identity forms failed. Attempts: " + ($r.Attempts -join ' | ')) + } + } + } + $script:allLabels = @(Get-Label -ErrorAction SilentlyContinue | Where-Object { -not (Test-LabelSoftDeleted $_) }) + $mismatches = Test-ParentPrioritiesCorrect +} +if ($mismatches.Count -gt 0) { + $lines = foreach ($m in $mismatches) { + if ($m.Reason -eq 'unresolved') { + " - '$($m.Label.DisplayName)' could not be resolved as a TOP-LEVEL live label (expected priority $($m.Expected)). Likely a scope collision (a sub-label with the same Name exists in this tenant). See earlier Pass 1 warnings or the Preflight-ParentScope log entry; rename the configured label or un-nest the live label, then re-run." + } else { + " - '$($m.Label.DisplayName)' (Id: $($m.Identity)) is at priority $($m.Actual); expected $($m.Expected) [reason: $($m.Reason)]" + } + } + $detail = "Sensitivity label priority order is incorrect after reorder + recovery:`n" + ($lines -join "`n") + Add-RunLogEntry -Module 'Setup-SensitivityLabels' -Action 'Verify-LabelPriority' ` + -Target 'all top-level labels' -Status 'Failed' -Detail $detail + throw $detail +} + +# Surface MS-managed labels (e.g. tenant-provisioned 'Personal') that are +# not in our config. They've been pinned to the lowest priority slots +# above (Personal at 0). The warning below is informational only — they +# remain in the tenant and won't appear to users because they aren't in +# our PublishedLabels filter. +foreach ($tl in $allTopLevel) { + if ($configuredDisplayNamesEarly -notcontains $tl.DisplayName) { + $now = Get-Label -Identity $tl.Name -ErrorAction SilentlyContinue + $curPri = if ($now) { $now.Priority } else { $tl.Priority } + Write-Host " Unmanaged top-level label '$($tl.DisplayName)' is at priority $curPri (created by '$($tl.CreatedBy)'). Not in SMBTool config — left in place." -ForegroundColor DarkGray + } +} + +# --------------------------------------------------------------------------- +# Pass 4 — publish via label policy +# --------------------------------------------------------------------------- +Write-Host "Publishing label policy..." -ForegroundColor Cyan + +$policyCfg = $Config.LabelPolicy +$existingPolicy = Get-LabelPolicy -Identity $policyCfg.Name -ErrorAction SilentlyContinue +$policyOwned = Test-Owned -Object $existingPolicy -Tag $tag + +if ($existingPolicy -and -not $policyOwned) { + Write-Host " Label policy '$($policyCfg.Name)' exists but is not tagged as toolkit-managed. Updating in place (will be re-tagged)." -ForegroundColor DarkYellow +} + +# Resolve default label by name OR display name (handles adopted labels). +# Parent-aware: the default applied label may be a sub-label, and on tenants +# that already had the label it was adopted from (so its live internal Name is +# a GUID), a top-level-only lookup would miss it. Resolve-ConfiguredDefaultLabel +# matches sub-label defaults within their parent's scope. +$defaultLabelObj = Resolve-ConfiguredDefaultLabel -DefaultName $policyCfg.DefaultLabel +if (-not $defaultLabelObj) { + if ($WhatIfPreference) { + Write-Host " What if: default label '$($policyCfg.DefaultLabel)' would be created earlier in this run; previewing publish step with placeholder GUID." -ForegroundColor DarkYellow + $defaultLabelObj = [pscustomobject]@{ + Name = $policyCfg.DefaultLabel + Guid = [guid]::Empty + } + } else { + throw "Default label '$($policyCfg.DefaultLabel)' was not found after creation." + } +} + +# Resolve the optional Outlook-only default. When set, this becomes a +# separate per-app default for email; documents continue to use DefaultLabel. +$emailDefaultLabelObj = $null +if ($policyCfg.DefaultLabelForEmail) { + $emailDefaultLabelObj = Resolve-ConfiguredDefaultLabel -DefaultName $policyCfg.DefaultLabelForEmail + if (-not $emailDefaultLabelObj) { + if ($WhatIfPreference) { + Write-Host " What if: email default label '$($policyCfg.DefaultLabelForEmail)' would be created earlier in this run; previewing publish step with placeholder GUID." -ForegroundColor DarkYellow + $emailDefaultLabelObj = [pscustomobject]@{ + Name = $policyCfg.DefaultLabelForEmail + Guid = [guid]::Empty + } + } else { + throw "Email default label '$($policyCfg.DefaultLabelForEmail)' was not found after creation." + } + } +} + +# A modern-scheme Label Group cannot be a default applied label (DefaultLabelId +# / OutlookDefaultLabel must point at an assignable label). If a resolved +# default or email-default is a Label Group, substitute its appropriate child +# (prefer 'Anyone (unrestricted)') and force-publish that child so it is a valid +# default. On classic tenants / flat labels this is a no-op (Test-IsLabelGroup +# is False), so behaviour there is unchanged. +$forcePublishNames = @() +if ($defaultLabelObj -and (Test-IsLabelGroup $defaultLabelObj)) { + $child = Resolve-LabelGroupDefaultChild -GroupLabel $defaultLabelObj + if (-not $child) { + throw "Default label '$($policyCfg.DefaultLabel)' is a Label Group with no child to act as the default applied label. Set LabelPolicy.DefaultLabel to a sub-label." + } + Write-Host " Document default '$($policyCfg.DefaultLabel)' is a Label Group; using its child '$($child.DisplayName)' as the default applied label." -ForegroundColor DarkYellow + $defaultLabelObj = $child + $forcePublishNames += $child.Name +} +if ($emailDefaultLabelObj -and (Test-IsLabelGroup $emailDefaultLabelObj)) { + $child = Resolve-LabelGroupDefaultChild -GroupLabel $emailDefaultLabelObj + if ($child) { + Write-Host " Email default '$($policyCfg.DefaultLabelForEmail)' is a Label Group; using its child '$($child.DisplayName)' as the Outlook default." -ForegroundColor DarkYellow + $emailDefaultLabelObj = $child + $forcePublishNames += $child.Name + } else { + Write-Warning " Email default '$($policyCfg.DefaultLabelForEmail)' is a Label Group with no child to use; omitting the Outlook-specific default." + $emailDefaultLabelObj = $null + } +} + +# Build the published-labels filter set (if configured). When sub-labels are +# listed, their parents are auto-included so the Purview hierarchy stays +# valid (Purview rejects publishing a sub-label without its parent). +$publishFilter = $null +if ($policyCfg.PublishedLabels -and @($policyCfg.PublishedLabels).Count -gt 0) { + $publishFilter = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + foreach ($n in $policyCfg.PublishedLabels) { if ($n) { [void] $publishFilter.Add($n) } } + foreach ($lbl in $Config.Labels) { + foreach ($s in @($lbl.SubLabels)) { + if ($s -and $publishFilter.Contains($s.Name)) { [void] $publishFilter.Add($lbl.Name) } + } + } + Write-Host " Publishing only: $([string]::Join(', ', @($publishFilter)))" -ForegroundColor Cyan +} + +# Collect every label (parents + subs) for the policy. We resolve each one +# to its actual internal Name -- which may differ from the config Name when +# matched by DisplayName on an adopted label. +$allLabelNames = @() +foreach ($lbl in $Config.Labels) { + $resolved = Get-LabelByName -Name $lbl.Name -DisplayName $lbl.DisplayName -Scope 'TopLevel' + $includeParent = (-not $publishFilter) -or $publishFilter.Contains($lbl.Name) + if ($includeParent) { + if ($resolved) { + if (Test-IsLabelGroup $resolved) { + # Modern-scheme Label Group: publishing the parent directly fails + # ("Label group(s) '' can not be published"). Skip it — the + # service auto-includes the group when a sub-label is published. + Write-Host " Skipping parent '$($lbl.DisplayName)' from the publish set: it is a modern Label Group (auto-included via its sub-labels)." -ForegroundColor DarkGray + } else { + $allLabelNames += $resolved.Name + } + } elseif ($WhatIfPreference) { + $allLabelNames += $lbl.Name + } + } + if ($lbl.SubLabels) { + $parentId = if ($resolved) { $resolved.Guid } else { $null } + foreach ($s in $lbl.SubLabels) { + $subScope = if ($parentId) { 'Parent' } else { 'Any' } + $rs = Get-LabelByName -Name $s.Name -DisplayName $s.DisplayName -ParentId $parentId -Scope $subScope + $includeSub = (-not $publishFilter) -or $publishFilter.Contains($s.Name) + if (-not $includeSub) { continue } + if ($rs) { + $allLabelNames += $rs.Name + } elseif ($WhatIfPreference) { + $allLabelNames += $s.Name + } + } + } +} + +# Force-publish any child substituted in for a Label-Group default/email-default +# above (e.g. General\Anyone (unrestricted)) so the default points at a label +# that is actually in the policy. +foreach ($fn in $forcePublishNames) { + if ($fn -and ($allLabelNames -notcontains $fn)) { $allLabelNames += $fn } +} + +$advanced = @{ + DefaultLabelId = $defaultLabelObj.Guid.ToString() +} +if ($emailDefaultLabelObj) { + # IPPS advanced setting that overrides the document default just for + # Outlook (email). With this set, new docs use DefaultLabelId and new + # mails use OutlookDefaultLabel. + $advanced['OutlookDefaultLabel'] = $emailDefaultLabelObj.Guid.ToString() +} +if ($policyCfg.MandatoryLabelling) { $advanced['Mandatory'] = 'True' } +if ($policyCfg.DowngradeJustification) { $advanced['RequireDowngradeJustification'] = 'True' } +# "Inherit label from attachments": when an email has labelled attachments, apply +# the highest-priority attachment label to the email. 'Automatic' applies it +# silently; 'Recommended' prompts the user. Requires labels scoped to Files + Emails +# (our published labels are). Omitted/empty in config leaves the feature off. +if ($policyCfg.AttachmentAction) { $advanced['AttachmentAction'] = [string]$policyCfg.AttachmentAction } + +if (-not $existingPolicy) { + if ($PSCmdlet.ShouldProcess($policyCfg.Name, 'New-LabelPolicy (publish to All)')) { + try { + Invoke-WithTransientRetry -Description ("New-LabelPolicy '$($policyCfg.Name)'") -AlreadyExistsIsSuccess -Action { + New-LabelPolicy ` + -Name $policyCfg.Name ` + -Comment "$tag $($policyCfg.Comment)" ` + -Labels $allLabelNames ` + -ExchangeLocation 'All' ` + -AdvancedSettings $advanced ` + -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false | Out-Null + } + Write-Host " + Created label policy '$($policyCfg.Name)'." -ForegroundColor Green + } catch { + Write-Warning " Failed to create label policy '$($policyCfg.Name)': $(Format-IPPSError $_)" + } + } +} else { + # Diff existing published labels vs target so we can REMOVE any that + # used to be published but are no longer in PublishedLabels, AND only + # ADD labels that aren't already published. Purview's + # Set-LabelPolicy -AddLabels rejects the entire call with + # LabelAlreadyPublishedException if any AddLabels entry is already + # in the policy, so the diff is required for idempotency. + $labelsToRemove = @() + $labelsToAdd = @() + if ($existingPolicy.Labels) { + # Internal Names of all live Label Groups. When a sub-label is published, + # the service auto-adds its parent group to the stored policy (e.g. + # 'ConfidentialGroup'). We must never try to remove those — they are + # auto-managed — or every re-run would churn. We also never ADD a group + # (the publish builder above already excludes groups from $allLabelNames). + $groupNames = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + foreach ($l in $allLabels) { + if ((Test-IsLabelGroup $l) -and $l.Name) { [void] $groupNames.Add([string]$l.Name) } + } + $targetSet = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + foreach ($n in $allLabelNames) { if ($n) { [void] $targetSet.Add($n) } } + $existingSet = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + foreach ($n in @($existingPolicy.Labels)) { + if ($n) { [void] $existingSet.Add($n) } + if ($n -and -not $targetSet.Contains($n) -and -not $groupNames.Contains($n)) { $labelsToRemove += $n } + } + foreach ($n in $allLabelNames) { + if ($n -and -not $existingSet.Contains($n)) { $labelsToAdd += $n } + } + } else { + $labelsToAdd = $allLabelNames + } + if ($PSCmdlet.ShouldProcess($policyCfg.Name, 'Set-LabelPolicy (refresh labels + advanced settings)')) { + $setArgs = @{ + Identity = $policyCfg.Name + Comment = "$tag $($policyCfg.Comment)" + AdvancedSettings = $advanced + ErrorAction = 'Stop' + WarningAction = 'SilentlyContinue' + Confirm = $false + } + if ($labelsToAdd.Count -gt 0) { + $setArgs['AddLabels'] = $labelsToAdd + Write-Host " Publishing newly-added labels: $([string]::Join(', ', $labelsToAdd))" -ForegroundColor DarkGray + } + if ($labelsToRemove.Count -gt 0) { + $setArgs['RemoveLabels'] = $labelsToRemove + Write-Host " Unpublishing previously-published labels: $([string]::Join(', ', $labelsToRemove))" -ForegroundColor DarkYellow + } + try { + Invoke-WithTransientRetry -Description ("Set-LabelPolicy '$($policyCfg.Name)'") -Action { + Set-LabelPolicy @setArgs | Out-Null + } + if ($labelsToAdd.Count -eq 0 -and $labelsToRemove.Count -eq 0) { + Write-Host " ~ Refreshed label policy '$($policyCfg.Name)' (label set already in sync)." -ForegroundColor Yellow + } else { + Write-Host " ~ Updated label policy '$($policyCfg.Name)'." -ForegroundColor Yellow + } + } catch { + Write-Warning " Failed to update label policy '$($policyCfg.Name)': $(Format-IPPSError $_)" + } + } +} + +Write-Host "Sensitivity labels and policy complete." -ForegroundColor Green +Write-Host "NOTE: Label policy changes can take up to 24 hours to propagate to clients." -ForegroundColor DarkYellow diff --git a/Products/Purview/Modules/Setup-TenantSettings.ps1 b/Products/Purview/Modules/Setup-TenantSettings.ps1 index 7112b32..ee93ddb 100644 --- a/Products/Purview/Modules/Setup-TenantSettings.ps1 +++ b/Products/Purview/Modules/Setup-TenantSettings.ps1 @@ -1,509 +1,509 @@ -#requires -Version 7.0 -<# -.SYNOPSIS - Applies foundational Microsoft Purview tenant settings. - -.DESCRIPTION - Configures the tenant-wide settings that must be in place before sensitivity - labels and DLP policies can work effectively across SharePoint, OneDrive, - Office, and Exchange. - - Settings applied (controlled by PurviewConfig.psd1 / parameters): - * Unified Audit Log ingestion (Set-AdminAuditLogConfig) - * SharePoint sensitivity label support (Set-SPOTenant -EnableAIPIntegration) - * SharePoint PDF labelling (Set-SPOTenant -EnableSensitivityLabelforPDF) - * Office co-authoring with labels (Set-PolicyConfig -EnableLabelCoauth) - * (optional) Group/Site MIP labels (Group.Unified directory setting) - * (optional) Premium Audit (SearchQueryInitiated mailbox audit) - - Idempotent: every change is preceded by a Get-* read and skipped if already - in the desired state. - -.PARAMETER Config - Hashtable from PurviewConfig.psd1. - -.PARAMETER SkipContainerLabels - Opt out of Group.Unified EnableMIPLabels=True for container (group/site) - labels. By default the script applies the setting (the toolkit's - licensing floor is Microsoft 365 Business Premium, which includes - Entra ID P1 — the AAD-side requirement). The container-label step - requires a Microsoft Graph (Beta) connection. - -.PARAMETER EnablePremiumAudit - Enable per-mailbox SearchQueryInitiated audit on the supplied mailbox(es). - Requires Microsoft 365 Audit (Premium) licensing. - -.PARAMETER PremiumAuditMailbox - Mailbox UPN(s) on which to enable SearchQueryInitiated audit. Required when - -EnablePremiumAudit is passed. -#> -[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] -param( - [Parameter(Mandatory)] - [hashtable] $Config, - - [Parameter()] - [switch] $SkipContainerLabels, - - [Parameter()] - [switch] $EnablePremiumAudit, - - [Parameter()] - [string[]] $PremiumAuditMailbox, - - [Parameter()] - [switch] $NonInteractive -) - -$ErrorActionPreference = 'Stop' -# Auto-confirm: this toolkit is designed for unattended/scripted runs. Use -WhatIf for dry-run. -$ConfirmPreference = 'None' -$settings = $Config.TenantSettings - -# --------------------------------------------------------------------------- -# !!! DO NOT ADD -Confirm TO Set-SPOTenant CALLS — IT WILL BREAK THE RUN !!! -# -# Set-SPOTenant (Microsoft.Online.SharePoint.PowerShell) does NOT implement -# SupportsShouldProcess and therefore does NOT accept -Confirm. Passing -# -Confirm:$false fails at parameter-binding time with: -# "A parameter cannot be found that matches parameter name 'Confirm'." -# This was previously diagnosed and fixed in May 2026 after a multi-hour -# debug session; do not re-introduce it. Verified against MS Learn: -# https://learn.microsoft.com/powershell/module/sharepoint-online/set-spotenant -# -# We rely on $ConfirmPreference = 'None' above for any cmdlet that DOES -# honour it (EXO / IPPS Set-* cmdlets). Set-SPOTenant doesn't prompt -# interactively in module-mode either, so no -Confirm is needed. -# -# !!! DO NOT ADD `Set-PolicyConfig -EnableSpoAipMigration` AS A STEP !!! -# -# `EnableSpoAipMigration` is an EXO/IPPS internal flag for tenants -# MIGRATING from legacy on-premises AIP/RMS. It is NOT a prerequisite for -# SPO sensitivity-label support and NOT a prerequisite for label -# co-authoring (`EnableLabelCoauth`) on a normal greenfield tenant. -# The MS Learn opt-in for SPO/ODFB labels is one cmdlet only: -# Set-SPOTenant -EnableAIPIntegration $true -# Ref: https://learn.microsoft.com/purview/sensitivity-labels-sharepoint-onedrive-files#use-powershell-to-enable-support-for-sensitivity-labels -# -# A transient `SetPolicyConfigEnableLabelCoauthSpoAIpMigrationIsDisabledException` -# on Set-PolicyConfig -EnableLabelCoauth was previously misdiagnosed (May -# 2026) as a hard prereq on EnableSpoAipMigration. It is NOT. Verified on -# tenant m365b485722: EnableLabelCoauth=True with EnableSpoAipMigration=False. -# Re-attempting Set-PolicyConfig -EnableSpoAipMigration alone throws -# `SetPolicyConfigEnableSpoAipMigrationRequiresEnableLabelCoauthException`, -# confirming the flag has its own pairing rules and is not safe to touch -# blindly. Do not re-introduce a step that sets it. -# --------------------------------------------------------------------------- - -# --------------------------------------------------------------------------- -# Transient-error retry helper - shared across all Setup-* modules (PR4). -# Defined in Modules/Invoke-WithTransientRetry.ps1; dot-sourced here so this -# module can use Invoke-WithTransientRetry + Test-TransientServerError. -# --------------------------------------------------------------------------- -. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') - -# --------------------------------------------------------------------------- -# 1. Unified Audit Log (checked & set in Exchange Online, not IPPS) -# --------------------------------------------------------------------------- -if ($settings.EnableUnifiedAuditLog) { - Write-Host "[1/5] Unified Audit Log..." -ForegroundColor Cyan - - # Preflight: many EXO/IPPS write cmdlets (incl. Set-AdminAuditLogConfig) fail - # with a generic 'server side error' on dehydrated tenants. Hydrate proactively. - $orgCustomizationAttempted = $false - try { - $orgCfg = Get-OrganizationConfig -ErrorAction Stop - if ($orgCfg.IsDehydrated) { - Write-Host " Tenant is dehydrated. Running Enable-OrganizationCustomization (one-time, ~30-60s)..." -ForegroundColor Yellow - try { - Enable-OrganizationCustomization -ErrorAction Stop - $orgCustomizationAttempted = $true - Write-Host " Organization customization enabled. Waiting 15s for propagation..." -ForegroundColor DarkGray - Start-Sleep -Seconds 15 - } catch { - $inner = $_.Exception.Message - if ($inner -match 'already' -or $inner -match 'DSAlreadyExist') { - $orgCustomizationAttempted = $true - Write-Host " Organization customization already enabled (continuing)." -ForegroundColor DarkGray - } else { - Write-Warning " Enable-OrganizationCustomization failed during preflight: $inner" - Write-Warning " Continuing — Set-* will retry the hydration if it surfaces in the error path." - } - } - } else { - Write-Host " Tenant already hydrated (IsDehydrated=False)." -ForegroundColor DarkGray - } - } catch { - Write-Warning " Get-OrganizationConfig failed during preflight: $($_.Exception.Message). Continuing." - } - - $audit = $null - try { - Invoke-WithTransientRetry -Description 'Get-AdminAuditLogConfig' -Action { - $script:auditCfg = Get-AdminAuditLogConfig -ErrorAction Stop - } | Out-Null - $audit = $script:auditCfg - } catch { - Write-Warning " Get-AdminAuditLogConfig failed: $($_.Exception.Message). Skipping audit-log step." - } - - - if ($null -eq $audit) { - # already warned above - } elseif ($audit.UnifiedAuditLogIngestionEnabled) { - Write-Host " Already enabled." -ForegroundColor DarkGray - Add-RunLogEntry -Module 'Setup-TenantSettings' -Action 'UnifiedAuditLogIngestion' -Target 'Tenant' -Status 'Skipped' -Detail 'Already enabled; no action taken.' - } elseif ($PSCmdlet.ShouldProcess('Tenant', 'Enable Unified Audit Log ingestion')) { - - # Call the cmdlet plainly. Empirically, on some tenants any of the - # following will cause Set-AdminAuditLogConfig to fail with a generic - # "server side error" in-script while working when typed manually: - # * Piping to Out-Null - # * -ErrorAction SilentlyContinue - # * -ErrorVariable capture - # Bare invocation works. $ErrorActionPreference='Stop' is set at the - # top of the file, so any real failure becomes a terminating exception - # we catch below. - $auditSucceeded = $false - try { - Invoke-WithTransientRetry -Description 'Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled' -Action { - Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled $true - } - - # Verify the write took effect. Both EXO and IPPS expose - # Get-AdminAuditLogConfig, but only EXO exposes Set. Because the - # toolkit connects IPPS after EXO, a bare Get can bind to the IPPS - # session and shadow the EXO view we just wrote. Resolve Get from - # the same module that exposes Set so verification reads the same - # source of truth, then poll briefly for eventual consistency. - $setCmd = Get-Command Set-AdminAuditLogConfig -ErrorAction SilentlyContinue - $auditGetCmd = $null - if ($setCmd) { - $auditGetCmd = Get-Command Get-AdminAuditLogConfig -All -ErrorAction SilentlyContinue | - Where-Object { $_.Source -eq $setCmd.Source } | - Select-Object -First 1 - } - if (-not $auditGetCmd) { - $auditGetCmd = Get-Command Get-AdminAuditLogConfig -ErrorAction SilentlyContinue - } - - $verifyAttempts = 5 - $auditVerified = $false - for ($va = 1; $va -le $verifyAttempts; $va++) { - try { - $vCfg = & $auditGetCmd -ErrorAction Stop - if ($vCfg.UnifiedAuditLogIngestionEnabled) { - $auditVerified = $true - break - } - } catch { - # Read failure is non-fatal here; keep polling. - } - if ($va -lt $verifyAttempts) { - Write-Host (" Audit config not confirmed yet (attempt $va/$verifyAttempts). Waiting 5s...") -ForegroundColor DarkYellow - Start-Sleep -Seconds 5 - } - } - - if ($auditVerified) { - Write-Host " Enabled and verified." -ForegroundColor Green - } else { - Write-Host " Pending — audit configuration may still be applying (UnifiedAuditLogIngestionEnabled returned False after 20s)." -ForegroundColor Yellow - Write-Host " To verify: Connect-ExchangeOnline; (Get-AdminAuditLogConfig).UnifiedAuditLogIngestionEnabled" -ForegroundColor DarkGray - Write-Host " Expected on some tenants where EXO propagation exceeds 20s — the Set call succeeded (no exception)." -ForegroundColor DarkGray - } - $auditSucceeded = $true - } catch { - $err = $_ - $msg = if ($err.ErrorDetails -and $err.ErrorDetails.Message) { - $err.ErrorDetails.Message - } else { - $err.Exception.Message - } - - # Hydration retry: tenant not customized yet. - if (-not $orgCustomizationAttempted -and $msg -match 'Enable-OrganizationCustomization') { - Write-Host " Tenant not customized yet. Running Enable-OrganizationCustomization..." -ForegroundColor Yellow - try { - Enable-OrganizationCustomization -ErrorAction Stop - $orgCustomizationAttempted = $true - Write-Host " Organization customization enabled. Retrying audit toggle..." -ForegroundColor DarkGray - Start-Sleep -Seconds 15 - Invoke-WithTransientRetry -Description 'Set-AdminAuditLogConfig (post-hydration)' -Action { - Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled $true - } - Write-Host " Enabled (propagation can take up to 60 minutes)." -ForegroundColor Green - $auditSucceeded = $true - } catch { - $inner = $_.Exception.Message - if ($inner -match 'already' -or $inner -match 'DSAlreadyExist') { - $orgCustomizationAttempted = $true - Start-Sleep -Seconds 15 - try { - Invoke-WithTransientRetry -Description 'Set-AdminAuditLogConfig (post-hydration, DS already exists)' -Action { - Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled $true - } - Write-Host " Enabled (propagation can take up to 60 minutes)." -ForegroundColor Green - $auditSucceeded = $true - } catch { - Write-Warning " Set-AdminAuditLogConfig failed after hydration: $($_.Exception.Message)" - } - } else { - Write-Warning " Enable-OrganizationCustomization failed: $inner" - } - } - } else { - Write-Warning " Set-AdminAuditLogConfig failed: $msg" - } - } - - if (-not $auditSucceeded) { - Write-Warning " Manual fix:" - Write-Warning " 1. In a fresh PowerShell window: Connect-ExchangeOnline ; Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled `$true" - Write-Warning " 2. Or via the portal: https://purview.microsoft.com/audit (click 'Start recording user and admin activity')" - Write-Warning " Other deploy steps will continue." - } - } -} else { - Write-Host "[1/5] Unified Audit Log: skipped (config disabled)." -ForegroundColor DarkGray -} - -# --------------------------------------------------------------------------- -# 2. SharePoint AIP integration (sensitivity labels for SPO/ODFB) -# --------------------------------------------------------------------------- -$spoAvailable = $null -ne (Get-Command Get-SPOTenant -ErrorAction SilentlyContinue) - -if ($spoAvailable -and $settings.EnableAIPIntegrationInSPO) { - Write-Host "[2/5] SharePoint AIP integration (EnableAIPIntegration)..." -ForegroundColor Cyan - try { - Invoke-WithTransientRetry -Description 'Get-SPOTenant' -Action { - $script:spoTenantState = Get-SPOTenant - } | Out-Null - $spoTenant = $script:spoTenantState - if ($spoTenant.EnableAIPIntegration) { - Write-Host " Already enabled." -ForegroundColor DarkGray - Add-RunLogEntry -Module 'Setup-TenantSettings' -Action 'Set-SPOTenant EnableAIPIntegration' -Target 'SharePoint Online' -Status 'Skipped' -Detail 'Already enabled; no action taken.' - } elseif ($PSCmdlet.ShouldProcess('SharePoint Online', 'Enable AIP integration')) { - # PR5/5f banner: tell the operator what is about to change BEFORE the - # tenant-wide setting flip. We rely on $ConfirmPreference='None' (set - # at the top of this file) for any cmdlet that honours -Confirm; this - # banner ensures unattended/scripted runs are never silent about a - # tenant-wide change. - # - # NOTE: Do NOT pass -Confirm:$false to Set-SPOTenant — see the big - # warning block at the top of this file. Set-SPOTenant does not - # implement SupportsShouldProcess. - Write-Host " About to change a TENANT-WIDE SharePoint setting:" -ForegroundColor Yellow - Write-Host " Set-SPOTenant -EnableAIPIntegration = `$true" -ForegroundColor Yellow - Write-Host " Effect: SharePoint Online and OneDrive for Business start honouring sensitivity" -ForegroundColor DarkGray - Write-Host " labels on files (auto-labelling, label-based access, downstream DLP)." -ForegroundColor DarkGray - Write-Host " Required for label policies to enforce on SPO/ODFB content." -ForegroundColor DarkGray - Invoke-WithTransientRetry -Description 'Set-SPOTenant -EnableAIPIntegration' -Action { - Set-SPOTenant -EnableAIPIntegration $true -WarningAction SilentlyContinue -ErrorAction Stop - } | Out-Null - Write-Host " Enabled." -ForegroundColor Green - } - } catch { - Write-Warning " SharePoint AIP integration step failed after retries: $($_.Exception.Message)" - } -} else { - Write-Host "[2/5] SharePoint AIP integration: skipped." -ForegroundColor DarkGray -} - -# --------------------------------------------------------------------------- -# 3. SharePoint PDF sensitivity labels -# -# NOTE: As of late 2023, EnableSensitivityLabelforPDF was removed from -# Set-SPOTenant -- PDF sensitivity-label support is now built into SPO and -# always on. Recent SPO module versions don't ship the parameter at all. -# We dynamically check whether the parameter still exists; if not, we treat -# the feature as built-in and skip cleanly. -# --------------------------------------------------------------------------- -if ($spoAvailable -and $settings.EnableSensitivityLabelForPDF) { - Write-Host "[3/5] SharePoint PDF sensitivity labels..." -ForegroundColor Cyan - - $setSpoCmd = Get-Command Set-SPOTenant -ErrorAction SilentlyContinue - $hasPdfParam = $false - if ($setSpoCmd) { - $hasPdfParam = $setSpoCmd.Parameters.ContainsKey('EnableSensitivityLabelforPDF') - } - - if (-not $hasPdfParam) { - Write-Host " Built-in (parameter removed from Set-SPOTenant; PDF labels always on)." -ForegroundColor DarkGray - } else { - $spoTenant = Get-SPOTenant - $current = $null - try { $current = $spoTenant.EnableSensitivityLabelforPDF } catch { $current = $null } - - if ($current -eq $true) { - Write-Host " Already enabled." -ForegroundColor DarkGray - } elseif ($PSCmdlet.ShouldProcess('SharePoint Online', 'Enable EnableSensitivityLabelforPDF')) { - # PR5/5f banner: see comment in section [2/5] above. PDF labelling - # is also tenant-wide and worth surfacing before the flip. - Write-Host " About to change a TENANT-WIDE SharePoint setting:" -ForegroundColor Yellow - Write-Host " Set-SPOTenant -EnableSensitivityLabelforPDF = `$true" -ForegroundColor Yellow - Write-Host " Effect: SharePoint Online applies sensitivity labels to PDF files (display label" -ForegroundColor DarkGray - Write-Host " in the SPO viewer, persist label on the PDF, honour label-based access)." -ForegroundColor DarkGray - try { - # NOTE: Do NOT pass -Confirm to Set-SPOTenant — see warning block - # at the top of this file. The cmdlet does not implement - # SupportsShouldProcess and -Confirm fails parameter binding. - Invoke-WithTransientRetry -Description 'Set-SPOTenant -EnableSensitivityLabelforPDF' -Action { - Set-SPOTenant -EnableSensitivityLabelforPDF $true -ErrorAction Stop -WarningAction SilentlyContinue - } - Write-Host " Enabled." -ForegroundColor Green - } catch { - Write-Warning " Set-SPOTenant -EnableSensitivityLabelforPDF failed: $($_.Exception.Message). PDF labels may already be built-in for this tenant." - } - } - } -} else { - Write-Host "[3/5] SharePoint PDF labels: skipped." -ForegroundColor DarkGray -} - -# --------------------------------------------------------------------------- -# 4. Label co-authoring metadata-format switch -# (Set-PolicyConfig -EnableLabelCoauth) -# -# SAFETY (THIS IS WHY IT'S OPT-IN, NOT LICENSING): -# This switch is ONE-WAY in practice. Enabling it moves sensitivity-label -# metadata out of the old custom-properties location and into the new -# embedded location. Once flipped: -# * Disabling it later REMOVES the new-location metadata; unencrypted -# Word / Excel / PowerPoint files lose their labels entirely. -# * It cannot be disabled via the Purview portal — PowerShell only. -# * Any app, service, scanner or script that reads or writes labels -# from the OLD location will break: AIP scanner < v3.0, OneDrive sync -# < 19.002, MIP SDK < 1.7, custom Exchange mail-flow rules that look -# up labels from old-location metadata, custom DLP scanners, etc. -# -# Because the toolkit runs on partner tenants where we cannot enumerate -# every third-party integration the customer relies on, this step is -# DEFAULT-OFF in PurviewConfig.psd1 and the operator opts in explicitly -# via -EnableLabelCoAuthoring on Deploy-PurviewBestPractice.ps1. Do not -# remove this gate. -# -# LICENSING: not an E5-only feature; included in Business Premium, E3, -# E5, A3, A5, F3, AIP P1/P2. License is not the reason this is opt-in. -# -# Ref: https://learn.microsoft.com/purview/sensitivity-labels-coauthoring -# --------------------------------------------------------------------------- -if ($settings.EnableLabelCoAuth) { - Write-Host "[4/5] Label co-authoring (Set-PolicyConfig — one-way switch)..." -ForegroundColor Cyan - - if ($PSCmdlet.ShouldProcess('Tenant', 'Enable label co-authoring (irreversible metadata format change)')) { - # Set-PolicyConfig -EnableLabelCoauth:$true is idempotent on re-runs - # of an already-enabled tenant, but the first flip is one-way (see - # the safety comment above). -WarningAction SilentlyContinue - # suppresses the cosmetic "command completed successfully but no - # settings ... have been modified" warning that fires when the - # setting is already in the desired state. - try { - Invoke-WithTransientRetry -Description 'Set-PolicyConfig -EnableLabelCoauth' -Action { - Set-PolicyConfig -EnableLabelCoauth:$true -WarningAction SilentlyContinue -ErrorAction Stop - } | Out-Null - Write-Host " Enabled (idempotent re-apply)." -ForegroundColor Green - Add-RunLogEntry -Module 'Setup-TenantSettings' -Action 'Set-PolicyConfig EnableLabelCoauth' -Target 'Tenant' -Status 'Updated' -Detail 'Tenant-wide label co-authoring enabled (one-way switch — confirmed via -EnableLabelCoAuthoring opt-in).' - } catch { - Write-Warning " Set-PolicyConfig failed after retries: $($_.Exception.Message)" - Add-RunLogEntry -Module 'Setup-TenantSettings' -Action 'Set-PolicyConfig EnableLabelCoauth' -Target 'Tenant' -Status 'Failed' -Detail $_.Exception.Message -Error $_.Exception - } - } -} else { - # SKIP-PATH SENTINEL: surface every disposition in the report - # (Skills/_CONVENTIONS.md §3 / Skills/Purview/_CONVENTIONS.md §1). - # If a future change silently reverts the opt-in gate (e.g. flips the - # config default back on or stops honouring -EnableLabelCoAuthoring), - # the absence of this Skipped entry across BP runs will tell us. - Write-Host "[4/5] Label co-authoring: skipped (-EnableLabelCoAuthoring not set; this is a one-way tenant change — see help)." -ForegroundColor DarkGray - Add-RunLogEntry -Module 'Setup-TenantSettings' -Action 'Set-PolicyConfig EnableLabelCoauth' -Target 'Tenant' -Status 'Skipped' -Detail 'EnableLabelCoAuth=$false in config; -EnableLabelCoAuthoring not passed. Tenant-wide co-authoring metadata-format switch left at current value. This is the safe default — see PurviewConfig.psd1 comment and https://learn.microsoft.com/purview/sensitivity-labels-coauthoring.' -} - -# --------------------------------------------------------------------------- -# 5a. (optional) Container labels — Group.Unified EnableMIPLabels -# --------------------------------------------------------------------------- -if (-not $SkipContainerLabels) { - Write-Host "[5/5] Container labels (Group.Unified EnableMIPLabels)..." -ForegroundColor Cyan - - $existing = Get-MgBetaDirectorySetting -ErrorAction SilentlyContinue | - Where-Object { $_.TemplateId -and ($_.Values.Name -contains 'EnableMIPLabels') } - - if ($null -eq $existing) { - # Create from template, preserving template defaults for every other value. - $template = Get-MgBetaDirectorySettingTemplate | - Where-Object { $_.DisplayName -eq 'Group.Unified' } - if (-not $template) { throw "Group.Unified directory setting template not found." } - - $values = foreach ($def in $template.Values) { - if ($def.Name -eq 'EnableMIPLabels') { - @{ name = $def.Name; value = 'True' } - } else { - @{ name = $def.Name; value = $def.DefaultValue } - } - } - - if ($PSCmdlet.ShouldProcess('Group.Unified directory setting', 'Create with EnableMIPLabels=True')) { - Invoke-WithTransientRetry -Description 'New-MgBetaDirectorySetting Group.Unified' -AlreadyExistsIsSuccess -Action { - New-MgBetaDirectorySetting -BodyParameter @{ templateId = $template.Id; values = $values } -ErrorAction Stop | Out-Null - } - Write-Host " Created with EnableMIPLabels=True." -ForegroundColor Green - } - } else { - $current = ($existing.Values | Where-Object Name -EQ 'EnableMIPLabels').Value - if ($current -eq 'True') { - Write-Host " Already enabled." -ForegroundColor DarkGray - Add-RunLogEntry -Module 'Setup-TenantSettings' -Action 'Group.Unified EnableMIPLabels' -Target 'AAD directory setting' -Status 'Skipped' -Detail 'Already True; no action taken.' - } elseif ($PSCmdlet.ShouldProcess('Group.Unified directory setting', 'Set EnableMIPLabels=True (preserving other values)')) { - # Read-modify-write: preserve every other value the customer has set. - $newValues = foreach ($v in $existing.Values) { - if ($v.Name -eq 'EnableMIPLabels') { - @{ name = $v.Name; value = 'True' } - } else { - @{ name = $v.Name; value = $v.Value } - } - } - Invoke-WithTransientRetry -Description 'Update-MgBetaDirectorySetting Group.Unified' -Action { - Update-MgBetaDirectorySetting -DirectorySettingId $existing.Id ` - -BodyParameter @{ values = $newValues } -ErrorAction Stop | Out-Null - } - Write-Host " Set EnableMIPLabels=True (other values preserved)." -ForegroundColor Green - } - } -} else { - # Surface this in the run log too — earlier versions only printed to host, - # so when container labels silently went missing from a BP run (e.g. after - # a regression in the license auto-detect) the HTML/JSON report had no - # evidence the step was even considered. Now every run records the skip. - Write-Host "[5/5] Container labels: skipped (-SkipContainerLabels was set, or license auto-detect found no recognised M365 BP/E5/Purview Suite SKU)." -ForegroundColor DarkGray - Add-RunLogEntry -Module 'Setup-TenantSettings' -Action 'Group.Unified EnableMIPLabels' -Target 'AAD directory setting' -Status 'Skipped' -Detail '-SkipContainerLabels was set, or license auto-detect classified the tenant as ''Other'' (no recognised M365 BP/E5/Purview Suite SKU); container labels for Teams / M365 Groups / SharePoint sites not enabled. Re-run without -SkipContainerLabels on a Business Premium (or higher) tenant to enable.' -} - -# --------------------------------------------------------------------------- -# 5b. (optional) Premium Audit — SearchQueryInitiated per-mailbox -# --------------------------------------------------------------------------- -if ($EnablePremiumAudit) { - if (-not $PremiumAuditMailbox -or $PremiumAuditMailbox.Count -eq 0) { - Write-Warning "Premium Audit enabled but no -PremiumAuditMailbox specified. Skipping." - } else { - Write-Host "Premium Audit (SearchQueryInitiated) on $($PremiumAuditMailbox.Count) mailbox(es)..." -ForegroundColor Cyan - foreach ($mbx in $PremiumAuditMailbox) { - try { - $existing = (Get-Mailbox -Identity $mbx).AuditOwner - if ($existing -contains 'SearchQueryInitiated') { - Write-Host " $mbx : already enabled." -ForegroundColor DarkGray - continue - } - if ($PSCmdlet.ShouldProcess($mbx, 'Add SearchQueryInitiated to AuditOwner')) { - Invoke-WithTransientRetry -Description ("Set-Mailbox -Identity $mbx -AuditOwner +SearchQueryInitiated") -Action { - Set-Mailbox -Identity $mbx -AuditOwner @{ Add = 'SearchQueryInitiated' } -ErrorAction Stop - } - Write-Host " $mbx : enabled." -ForegroundColor Green - } - } catch { - Write-Warning "Failed to enable premium audit on $mbx : $($_.Exception.Message)" - } - } - } -} - -Write-Host "Tenant settings complete." -ForegroundColor Green +#requires -Version 7.0 +<# +.SYNOPSIS + Applies foundational Microsoft Purview tenant settings. + +.DESCRIPTION + Configures the tenant-wide settings that must be in place before sensitivity + labels and DLP policies can work effectively across SharePoint, OneDrive, + Office, and Exchange. + + Settings applied (controlled by PurviewConfig.psd1 / parameters): + * Unified Audit Log ingestion (Set-AdminAuditLogConfig) + * SharePoint sensitivity label support (Set-SPOTenant -EnableAIPIntegration) + * SharePoint PDF labelling (Set-SPOTenant -EnableSensitivityLabelforPDF) + * Office co-authoring with labels (Set-PolicyConfig -EnableLabelCoauth) + * (optional) Group/Site MIP labels (Group.Unified directory setting) + * (optional) Premium Audit (SearchQueryInitiated mailbox audit) + + Idempotent: every change is preceded by a Get-* read and skipped if already + in the desired state. + +.PARAMETER Config + Hashtable from PurviewConfig.psd1. + +.PARAMETER SkipContainerLabels + Opt out of Group.Unified EnableMIPLabels=True for container (group/site) + labels. By default the script applies the setting (the toolkit's + licensing floor is Microsoft 365 Business Premium, which includes + Entra ID P1 — the AAD-side requirement). The container-label step + requires a Microsoft Graph (Beta) connection. + +.PARAMETER EnablePremiumAudit + Enable per-mailbox SearchQueryInitiated audit on the supplied mailbox(es). + Requires Microsoft 365 Audit (Premium) licensing. + +.PARAMETER PremiumAuditMailbox + Mailbox UPN(s) on which to enable SearchQueryInitiated audit. Required when + -EnablePremiumAudit is passed. +#> +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +param( + [Parameter(Mandatory)] + [hashtable] $Config, + + [Parameter()] + [switch] $SkipContainerLabels, + + [Parameter()] + [switch] $EnablePremiumAudit, + + [Parameter()] + [string[]] $PremiumAuditMailbox, + + [Parameter()] + [switch] $NonInteractive +) + +$ErrorActionPreference = 'Stop' +# Auto-confirm: this toolkit is designed for unattended/scripted runs. Use -WhatIf for dry-run. +$ConfirmPreference = 'None' +$settings = $Config.TenantSettings + +# --------------------------------------------------------------------------- +# !!! DO NOT ADD -Confirm TO Set-SPOTenant CALLS — IT WILL BREAK THE RUN !!! +# +# Set-SPOTenant (Microsoft.Online.SharePoint.PowerShell) does NOT implement +# SupportsShouldProcess and therefore does NOT accept -Confirm. Passing +# -Confirm:$false fails at parameter-binding time with: +# "A parameter cannot be found that matches parameter name 'Confirm'." +# This was previously diagnosed and fixed in May 2026 after a multi-hour +# debug session; do not re-introduce it. Verified against MS Learn: +# https://learn.microsoft.com/powershell/module/sharepoint-online/set-spotenant +# +# We rely on $ConfirmPreference = 'None' above for any cmdlet that DOES +# honour it (EXO / IPPS Set-* cmdlets). Set-SPOTenant doesn't prompt +# interactively in module-mode either, so no -Confirm is needed. +# +# !!! DO NOT ADD `Set-PolicyConfig -EnableSpoAipMigration` AS A STEP !!! +# +# `EnableSpoAipMigration` is an EXO/IPPS internal flag for tenants +# MIGRATING from legacy on-premises AIP/RMS. It is NOT a prerequisite for +# SPO sensitivity-label support and NOT a prerequisite for label +# co-authoring (`EnableLabelCoauth`) on a normal greenfield tenant. +# The MS Learn opt-in for SPO/ODFB labels is one cmdlet only: +# Set-SPOTenant -EnableAIPIntegration $true +# Ref: https://learn.microsoft.com/purview/sensitivity-labels-sharepoint-onedrive-files#use-powershell-to-enable-support-for-sensitivity-labels +# +# A transient `SetPolicyConfigEnableLabelCoauthSpoAIpMigrationIsDisabledException` +# on Set-PolicyConfig -EnableLabelCoauth was previously misdiagnosed (May +# 2026) as a hard prereq on EnableSpoAipMigration. It is NOT. Verified on +# tenant m365b485722: EnableLabelCoauth=True with EnableSpoAipMigration=False. +# Re-attempting Set-PolicyConfig -EnableSpoAipMigration alone throws +# `SetPolicyConfigEnableSpoAipMigrationRequiresEnableLabelCoauthException`, +# confirming the flag has its own pairing rules and is not safe to touch +# blindly. Do not re-introduce a step that sets it. +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# Transient-error retry helper - shared across all Setup-* modules (PR4). +# Defined in Modules/Invoke-WithTransientRetry.ps1; dot-sourced here so this +# module can use Invoke-WithTransientRetry + Test-TransientServerError. +# --------------------------------------------------------------------------- +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') + +# --------------------------------------------------------------------------- +# 1. Unified Audit Log (checked & set in Exchange Online, not IPPS) +# --------------------------------------------------------------------------- +if ($settings.EnableUnifiedAuditLog) { + Write-Host "[1/5] Unified Audit Log..." -ForegroundColor Cyan + + # Preflight: many EXO/IPPS write cmdlets (incl. Set-AdminAuditLogConfig) fail + # with a generic 'server side error' on dehydrated tenants. Hydrate proactively. + $orgCustomizationAttempted = $false + try { + $orgCfg = Get-OrganizationConfig -ErrorAction Stop + if ($orgCfg.IsDehydrated) { + Write-Host " Tenant is dehydrated. Running Enable-OrganizationCustomization (one-time, ~30-60s)..." -ForegroundColor Yellow + try { + Enable-OrganizationCustomization -ErrorAction Stop + $orgCustomizationAttempted = $true + Write-Host " Organization customization enabled. Waiting 15s for propagation..." -ForegroundColor DarkGray + Start-Sleep -Seconds 15 + } catch { + $inner = $_.Exception.Message + if ($inner -match 'already' -or $inner -match 'DSAlreadyExist') { + $orgCustomizationAttempted = $true + Write-Host " Organization customization already enabled (continuing)." -ForegroundColor DarkGray + } else { + Write-Warning " Enable-OrganizationCustomization failed during preflight: $inner" + Write-Warning " Continuing — Set-* will retry the hydration if it surfaces in the error path." + } + } + } else { + Write-Host " Tenant already hydrated (IsDehydrated=False)." -ForegroundColor DarkGray + } + } catch { + Write-Warning " Get-OrganizationConfig failed during preflight: $($_.Exception.Message). Continuing." + } + + $audit = $null + try { + Invoke-WithTransientRetry -Description 'Get-AdminAuditLogConfig' -Action { + $script:auditCfg = Get-AdminAuditLogConfig -ErrorAction Stop + } | Out-Null + $audit = $script:auditCfg + } catch { + Write-Warning " Get-AdminAuditLogConfig failed: $($_.Exception.Message). Skipping audit-log step." + } + + + if ($null -eq $audit) { + # already warned above + } elseif ($audit.UnifiedAuditLogIngestionEnabled) { + Write-Host " Already enabled." -ForegroundColor DarkGray + Add-RunLogEntry -Module 'Setup-TenantSettings' -Action 'UnifiedAuditLogIngestion' -Target 'Tenant' -Status 'Skipped' -Detail 'Already enabled; no action taken.' + } elseif ($PSCmdlet.ShouldProcess('Tenant', 'Enable Unified Audit Log ingestion')) { + + # Call the cmdlet plainly. Empirically, on some tenants any of the + # following will cause Set-AdminAuditLogConfig to fail with a generic + # "server side error" in-script while working when typed manually: + # * Piping to Out-Null + # * -ErrorAction SilentlyContinue + # * -ErrorVariable capture + # Bare invocation works. $ErrorActionPreference='Stop' is set at the + # top of the file, so any real failure becomes a terminating exception + # we catch below. + $auditSucceeded = $false + try { + Invoke-WithTransientRetry -Description 'Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled' -Action { + Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled $true + } + + # Verify the write took effect. Both EXO and IPPS expose + # Get-AdminAuditLogConfig, but only EXO exposes Set. Because the + # toolkit connects IPPS after EXO, a bare Get can bind to the IPPS + # session and shadow the EXO view we just wrote. Resolve Get from + # the same module that exposes Set so verification reads the same + # source of truth, then poll briefly for eventual consistency. + $setCmd = Get-Command Set-AdminAuditLogConfig -ErrorAction SilentlyContinue + $auditGetCmd = $null + if ($setCmd) { + $auditGetCmd = Get-Command Get-AdminAuditLogConfig -All -ErrorAction SilentlyContinue | + Where-Object { $_.Source -eq $setCmd.Source } | + Select-Object -First 1 + } + if (-not $auditGetCmd) { + $auditGetCmd = Get-Command Get-AdminAuditLogConfig -ErrorAction SilentlyContinue + } + + $verifyAttempts = 5 + $auditVerified = $false + for ($va = 1; $va -le $verifyAttempts; $va++) { + try { + $vCfg = & $auditGetCmd -ErrorAction Stop + if ($vCfg.UnifiedAuditLogIngestionEnabled) { + $auditVerified = $true + break + } + } catch { + # Read failure is non-fatal here; keep polling. + } + if ($va -lt $verifyAttempts) { + Write-Host (" Audit config not confirmed yet (attempt $va/$verifyAttempts). Waiting 5s...") -ForegroundColor DarkYellow + Start-Sleep -Seconds 5 + } + } + + if ($auditVerified) { + Write-Host " Enabled and verified." -ForegroundColor Green + } else { + Write-Host " Pending — audit configuration may still be applying (UnifiedAuditLogIngestionEnabled returned False after 20s)." -ForegroundColor Yellow + Write-Host " To verify: Connect-ExchangeOnline; (Get-AdminAuditLogConfig).UnifiedAuditLogIngestionEnabled" -ForegroundColor DarkGray + Write-Host " Expected on some tenants where EXO propagation exceeds 20s — the Set call succeeded (no exception)." -ForegroundColor DarkGray + } + $auditSucceeded = $true + } catch { + $err = $_ + $msg = if ($err.ErrorDetails -and $err.ErrorDetails.Message) { + $err.ErrorDetails.Message + } else { + $err.Exception.Message + } + + # Hydration retry: tenant not customized yet. + if (-not $orgCustomizationAttempted -and $msg -match 'Enable-OrganizationCustomization') { + Write-Host " Tenant not customized yet. Running Enable-OrganizationCustomization..." -ForegroundColor Yellow + try { + Enable-OrganizationCustomization -ErrorAction Stop + $orgCustomizationAttempted = $true + Write-Host " Organization customization enabled. Retrying audit toggle..." -ForegroundColor DarkGray + Start-Sleep -Seconds 15 + Invoke-WithTransientRetry -Description 'Set-AdminAuditLogConfig (post-hydration)' -Action { + Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled $true + } + Write-Host " Enabled (propagation can take up to 60 minutes)." -ForegroundColor Green + $auditSucceeded = $true + } catch { + $inner = $_.Exception.Message + if ($inner -match 'already' -or $inner -match 'DSAlreadyExist') { + $orgCustomizationAttempted = $true + Start-Sleep -Seconds 15 + try { + Invoke-WithTransientRetry -Description 'Set-AdminAuditLogConfig (post-hydration, DS already exists)' -Action { + Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled $true + } + Write-Host " Enabled (propagation can take up to 60 minutes)." -ForegroundColor Green + $auditSucceeded = $true + } catch { + Write-Warning " Set-AdminAuditLogConfig failed after hydration: $($_.Exception.Message)" + } + } else { + Write-Warning " Enable-OrganizationCustomization failed: $inner" + } + } + } else { + Write-Warning " Set-AdminAuditLogConfig failed: $msg" + } + } + + if (-not $auditSucceeded) { + Write-Warning " Manual fix:" + Write-Warning " 1. In a fresh PowerShell window: Connect-ExchangeOnline ; Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled `$true" + Write-Warning " 2. Or via the portal: https://purview.microsoft.com/audit (click 'Start recording user and admin activity')" + Write-Warning " Other deploy steps will continue." + } + } +} else { + Write-Host "[1/5] Unified Audit Log: skipped (config disabled)." -ForegroundColor DarkGray +} + +# --------------------------------------------------------------------------- +# 2. SharePoint AIP integration (sensitivity labels for SPO/ODFB) +# --------------------------------------------------------------------------- +$spoAvailable = $null -ne (Get-Command Get-SPOTenant -ErrorAction SilentlyContinue) + +if ($spoAvailable -and $settings.EnableAIPIntegrationInSPO) { + Write-Host "[2/5] SharePoint AIP integration (EnableAIPIntegration)..." -ForegroundColor Cyan + try { + Invoke-WithTransientRetry -Description 'Get-SPOTenant' -Action { + $script:spoTenantState = Get-SPOTenant + } | Out-Null + $spoTenant = $script:spoTenantState + if ($spoTenant.EnableAIPIntegration) { + Write-Host " Already enabled." -ForegroundColor DarkGray + Add-RunLogEntry -Module 'Setup-TenantSettings' -Action 'Set-SPOTenant EnableAIPIntegration' -Target 'SharePoint Online' -Status 'Skipped' -Detail 'Already enabled; no action taken.' + } elseif ($PSCmdlet.ShouldProcess('SharePoint Online', 'Enable AIP integration')) { + # PR5/5f banner: tell the operator what is about to change BEFORE the + # tenant-wide setting flip. We rely on $ConfirmPreference='None' (set + # at the top of this file) for any cmdlet that honours -Confirm; this + # banner ensures unattended/scripted runs are never silent about a + # tenant-wide change. + # + # NOTE: Do NOT pass -Confirm:$false to Set-SPOTenant — see the big + # warning block at the top of this file. Set-SPOTenant does not + # implement SupportsShouldProcess. + Write-Host " About to change a TENANT-WIDE SharePoint setting:" -ForegroundColor Yellow + Write-Host " Set-SPOTenant -EnableAIPIntegration = `$true" -ForegroundColor Yellow + Write-Host " Effect: SharePoint Online and OneDrive for Business start honouring sensitivity" -ForegroundColor DarkGray + Write-Host " labels on files (auto-labelling, label-based access, downstream DLP)." -ForegroundColor DarkGray + Write-Host " Required for label policies to enforce on SPO/ODFB content." -ForegroundColor DarkGray + Invoke-WithTransientRetry -Description 'Set-SPOTenant -EnableAIPIntegration' -Action { + Set-SPOTenant -EnableAIPIntegration $true -WarningAction SilentlyContinue -ErrorAction Stop + } | Out-Null + Write-Host " Enabled." -ForegroundColor Green + } + } catch { + Write-Warning " SharePoint AIP integration step failed after retries: $($_.Exception.Message)" + } +} else { + Write-Host "[2/5] SharePoint AIP integration: skipped." -ForegroundColor DarkGray +} + +# --------------------------------------------------------------------------- +# 3. SharePoint PDF sensitivity labels +# +# NOTE: As of late 2023, EnableSensitivityLabelforPDF was removed from +# Set-SPOTenant -- PDF sensitivity-label support is now built into SPO and +# always on. Recent SPO module versions don't ship the parameter at all. +# We dynamically check whether the parameter still exists; if not, we treat +# the feature as built-in and skip cleanly. +# --------------------------------------------------------------------------- +if ($spoAvailable -and $settings.EnableSensitivityLabelForPDF) { + Write-Host "[3/5] SharePoint PDF sensitivity labels..." -ForegroundColor Cyan + + $setSpoCmd = Get-Command Set-SPOTenant -ErrorAction SilentlyContinue + $hasPdfParam = $false + if ($setSpoCmd) { + $hasPdfParam = $setSpoCmd.Parameters.ContainsKey('EnableSensitivityLabelforPDF') + } + + if (-not $hasPdfParam) { + Write-Host " Built-in (parameter removed from Set-SPOTenant; PDF labels always on)." -ForegroundColor DarkGray + } else { + $spoTenant = Get-SPOTenant + $current = $null + try { $current = $spoTenant.EnableSensitivityLabelforPDF } catch { $current = $null } + + if ($current -eq $true) { + Write-Host " Already enabled." -ForegroundColor DarkGray + } elseif ($PSCmdlet.ShouldProcess('SharePoint Online', 'Enable EnableSensitivityLabelforPDF')) { + # PR5/5f banner: see comment in section [2/5] above. PDF labelling + # is also tenant-wide and worth surfacing before the flip. + Write-Host " About to change a TENANT-WIDE SharePoint setting:" -ForegroundColor Yellow + Write-Host " Set-SPOTenant -EnableSensitivityLabelforPDF = `$true" -ForegroundColor Yellow + Write-Host " Effect: SharePoint Online applies sensitivity labels to PDF files (display label" -ForegroundColor DarkGray + Write-Host " in the SPO viewer, persist label on the PDF, honour label-based access)." -ForegroundColor DarkGray + try { + # NOTE: Do NOT pass -Confirm to Set-SPOTenant — see warning block + # at the top of this file. The cmdlet does not implement + # SupportsShouldProcess and -Confirm fails parameter binding. + Invoke-WithTransientRetry -Description 'Set-SPOTenant -EnableSensitivityLabelforPDF' -Action { + Set-SPOTenant -EnableSensitivityLabelforPDF $true -ErrorAction Stop -WarningAction SilentlyContinue + } + Write-Host " Enabled." -ForegroundColor Green + } catch { + Write-Warning " Set-SPOTenant -EnableSensitivityLabelforPDF failed: $($_.Exception.Message). PDF labels may already be built-in for this tenant." + } + } + } +} else { + Write-Host "[3/5] SharePoint PDF labels: skipped." -ForegroundColor DarkGray +} + +# --------------------------------------------------------------------------- +# 4. Label co-authoring metadata-format switch +# (Set-PolicyConfig -EnableLabelCoauth) +# +# SAFETY (THIS IS WHY IT'S OPT-IN, NOT LICENSING): +# This switch is ONE-WAY in practice. Enabling it moves sensitivity-label +# metadata out of the old custom-properties location and into the new +# embedded location. Once flipped: +# * Disabling it later REMOVES the new-location metadata; unencrypted +# Word / Excel / PowerPoint files lose their labels entirely. +# * It cannot be disabled via the Purview portal — PowerShell only. +# * Any app, service, scanner or script that reads or writes labels +# from the OLD location will break: AIP scanner < v3.0, OneDrive sync +# < 19.002, MIP SDK < 1.7, custom Exchange mail-flow rules that look +# up labels from old-location metadata, custom DLP scanners, etc. +# +# Because the toolkit runs on partner tenants where we cannot enumerate +# every third-party integration the customer relies on, this step is +# DEFAULT-OFF in PurviewConfig.psd1 and the operator opts in explicitly +# via -EnableLabelCoAuthoring on Deploy-PurviewBestPractice.ps1. Do not +# remove this gate. +# +# LICENSING: not an E5-only feature; included in Business Premium, E3, +# E5, A3, A5, F3, AIP P1/P2. License is not the reason this is opt-in. +# +# Ref: https://learn.microsoft.com/purview/sensitivity-labels-coauthoring +# --------------------------------------------------------------------------- +if ($settings.EnableLabelCoAuth) { + Write-Host "[4/5] Label co-authoring (Set-PolicyConfig — one-way switch)..." -ForegroundColor Cyan + + if ($PSCmdlet.ShouldProcess('Tenant', 'Enable label co-authoring (irreversible metadata format change)')) { + # Set-PolicyConfig -EnableLabelCoauth:$true is idempotent on re-runs + # of an already-enabled tenant, but the first flip is one-way (see + # the safety comment above). -WarningAction SilentlyContinue + # suppresses the cosmetic "command completed successfully but no + # settings ... have been modified" warning that fires when the + # setting is already in the desired state. + try { + Invoke-WithTransientRetry -Description 'Set-PolicyConfig -EnableLabelCoauth' -Action { + Set-PolicyConfig -EnableLabelCoauth:$true -WarningAction SilentlyContinue -ErrorAction Stop + } | Out-Null + Write-Host " Enabled (idempotent re-apply)." -ForegroundColor Green + Add-RunLogEntry -Module 'Setup-TenantSettings' -Action 'Set-PolicyConfig EnableLabelCoauth' -Target 'Tenant' -Status 'Updated' -Detail 'Tenant-wide label co-authoring enabled (one-way switch — confirmed via -EnableLabelCoAuthoring opt-in).' + } catch { + Write-Warning " Set-PolicyConfig failed after retries: $($_.Exception.Message)" + Add-RunLogEntry -Module 'Setup-TenantSettings' -Action 'Set-PolicyConfig EnableLabelCoauth' -Target 'Tenant' -Status 'Failed' -Detail $_.Exception.Message -Error $_.Exception + } + } +} else { + # SKIP-PATH SENTINEL: surface every disposition in the report + # Record skipped operations as well as applied changes. + # If a future change silently reverts the opt-in gate (e.g. flips the + # config default back on or stops honouring -EnableLabelCoAuthoring), + # the absence of this Skipped entry across BP runs will tell us. + Write-Host "[4/5] Label co-authoring: skipped (-EnableLabelCoAuthoring not set; this is a one-way tenant change — see help)." -ForegroundColor DarkGray + Add-RunLogEntry -Module 'Setup-TenantSettings' -Action 'Set-PolicyConfig EnableLabelCoauth' -Target 'Tenant' -Status 'Skipped' -Detail 'EnableLabelCoAuth=$false in config; -EnableLabelCoAuthoring not passed. Tenant-wide co-authoring metadata-format switch left at current value. This is the safe default — see PurviewConfig.psd1 comment and https://learn.microsoft.com/purview/sensitivity-labels-coauthoring.' +} + +# --------------------------------------------------------------------------- +# 5a. (optional) Container labels — Group.Unified EnableMIPLabels +# --------------------------------------------------------------------------- +if (-not $SkipContainerLabels) { + Write-Host "[5/5] Container labels (Group.Unified EnableMIPLabels)..." -ForegroundColor Cyan + + $existing = Get-MgBetaDirectorySetting -ErrorAction SilentlyContinue | + Where-Object { $_.TemplateId -and ($_.Values.Name -contains 'EnableMIPLabels') } + + if ($null -eq $existing) { + # Create from template, preserving template defaults for every other value. + $template = Get-MgBetaDirectorySettingTemplate | + Where-Object { $_.DisplayName -eq 'Group.Unified' } + if (-not $template) { throw "Group.Unified directory setting template not found." } + + $values = foreach ($def in $template.Values) { + if ($def.Name -eq 'EnableMIPLabels') { + @{ name = $def.Name; value = 'True' } + } else { + @{ name = $def.Name; value = $def.DefaultValue } + } + } + + if ($PSCmdlet.ShouldProcess('Group.Unified directory setting', 'Create with EnableMIPLabels=True')) { + Invoke-WithTransientRetry -Description 'New-MgBetaDirectorySetting Group.Unified' -AlreadyExistsIsSuccess -Action { + New-MgBetaDirectorySetting -BodyParameter @{ templateId = $template.Id; values = $values } -ErrorAction Stop | Out-Null + } + Write-Host " Created with EnableMIPLabels=True." -ForegroundColor Green + } + } else { + $current = ($existing.Values | Where-Object Name -EQ 'EnableMIPLabels').Value + if ($current -eq 'True') { + Write-Host " Already enabled." -ForegroundColor DarkGray + Add-RunLogEntry -Module 'Setup-TenantSettings' -Action 'Group.Unified EnableMIPLabels' -Target 'AAD directory setting' -Status 'Skipped' -Detail 'Already True; no action taken.' + } elseif ($PSCmdlet.ShouldProcess('Group.Unified directory setting', 'Set EnableMIPLabels=True (preserving other values)')) { + # Read-modify-write: preserve every other value the customer has set. + $newValues = foreach ($v in $existing.Values) { + if ($v.Name -eq 'EnableMIPLabels') { + @{ name = $v.Name; value = 'True' } + } else { + @{ name = $v.Name; value = $v.Value } + } + } + Invoke-WithTransientRetry -Description 'Update-MgBetaDirectorySetting Group.Unified' -Action { + Update-MgBetaDirectorySetting -DirectorySettingId $existing.Id ` + -BodyParameter @{ values = $newValues } -ErrorAction Stop | Out-Null + } + Write-Host " Set EnableMIPLabels=True (other values preserved)." -ForegroundColor Green + } + } +} else { + # Surface this in the run log too — earlier versions only printed to host, + # so when container labels silently went missing from a BP run (e.g. after + # a regression in the license auto-detect) the HTML/JSON report had no + # evidence the step was even considered. Now every run records the skip. + Write-Host "[5/5] Container labels: skipped (-SkipContainerLabels was set, or license auto-detect found no recognised M365 BP/E5/Purview Suite SKU)." -ForegroundColor DarkGray + Add-RunLogEntry -Module 'Setup-TenantSettings' -Action 'Group.Unified EnableMIPLabels' -Target 'AAD directory setting' -Status 'Skipped' -Detail '-SkipContainerLabels was set, or license auto-detect classified the tenant as ''Other'' (no recognised M365 BP/E5/Purview Suite SKU); container labels for Teams / M365 Groups / SharePoint sites not enabled. Re-run without -SkipContainerLabels on a Business Premium (or higher) tenant to enable.' +} + +# --------------------------------------------------------------------------- +# 5b. (optional) Premium Audit — SearchQueryInitiated per-mailbox +# --------------------------------------------------------------------------- +if ($EnablePremiumAudit) { + if (-not $PremiumAuditMailbox -or $PremiumAuditMailbox.Count -eq 0) { + Write-Warning "Premium Audit enabled but no -PremiumAuditMailbox specified. Skipping." + } else { + Write-Host "Premium Audit (SearchQueryInitiated) on $($PremiumAuditMailbox.Count) mailbox(es)..." -ForegroundColor Cyan + foreach ($mbx in $PremiumAuditMailbox) { + try { + $existing = (Get-Mailbox -Identity $mbx).AuditOwner + if ($existing -contains 'SearchQueryInitiated') { + Write-Host " $mbx : already enabled." -ForegroundColor DarkGray + continue + } + if ($PSCmdlet.ShouldProcess($mbx, 'Add SearchQueryInitiated to AuditOwner')) { + Invoke-WithTransientRetry -Description ("Set-Mailbox -Identity $mbx -AuditOwner +SearchQueryInitiated") -Action { + Set-Mailbox -Identity $mbx -AuditOwner @{ Add = 'SearchQueryInitiated' } -ErrorAction Stop + } + Write-Host " $mbx : enabled." -ForegroundColor Green + } + } catch { + Write-Warning "Failed to enable premium audit on $mbx : $($_.Exception.Message)" + } + } + } +} + +Write-Host "Tenant settings complete." -ForegroundColor Green diff --git a/Products/Purview/Modules/Write-PurviewDeploymentPlan.ps1 b/Products/Purview/Modules/Write-PurviewDeploymentPlan.ps1 new file mode 100644 index 0000000..4840009 --- /dev/null +++ b/Products/Purview/Modules/Write-PurviewDeploymentPlan.ps1 @@ -0,0 +1,2591 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Builds and writes the offline Purview Deployment Plan. + +.DESCRIPTION + Creates one canonical intent model from local configuration, effective + switches, and the versioned Microsoft guide mapping. The writer performs no + authentication, service-module import, tenant read, or tenant write. +#> + +. (Join-Path $PSScriptRoot 'PurviewConfigurationContract.ps1') + +function Get-PurviewPlanSha256 { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Text + ) + + $bytes = [Text.Encoding]::UTF8.GetBytes($Text) + $hash = [Security.Cryptography.SHA256]::HashData($bytes) + return [Convert]::ToHexString($hash).ToLowerInvariant() +} + +function Get-PurviewPlanWorkload { + [CmdletBinding()] + param( + [Parameter()] + [object] $Policy + ) + + if ($Policy -is [System.Collections.IDictionary] -and + $Policy.Keys -contains 'Workload') { + return [string]$Policy['Workload'] + } + + return '' +} + +function Test-PurviewPlanParameter { + [CmdletBinding()] + param( + [Parameter()] + [System.Collections.IDictionary] $Parameters, + + [Parameter(Mandatory)] + [string] $Name + ) + + if (-not $Parameters -or -not ($Parameters.Keys -contains $Name)) { + return $false + } + + $value = $Parameters[$Name] + if ($value -is [Management.Automation.SwitchParameter]) { + return $value.IsPresent + } + + return [bool]$value +} + +function Get-PurviewPlanStatusLabel { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Status + ) + + switch ($Status) { + 'NotConfigured' { return 'Not configured' } + 'NotImplemented' { return 'Not implemented' } + 'NotApplicable' { return 'Not applicable' } + default { return $Status } + } +} + +function Get-PurviewPlanLevelLabel { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Level + ) + + $labels = foreach ($value in ($Level -split ',')) { + switch ($value.Trim()) { + 'Good' { 'Priority 1 (Good)' } + 'Better' { 'Priority 2 (Better)' } + 'Best' { 'Priority 3 (Best)' } + 'Extension' { 'Toolkit extension' } + default { $value.Trim() } + } + } + return $labels -join ', ' +} + +function New-PurviewPlanAction { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $ActionId, + [Parameter(Mandatory)] [string] $Module, + [Parameter(Mandatory)] [string] $Title, + [Parameter(Mandatory)] + [ValidateSet('Included', 'Excluded', 'Conditional', 'NotConfigured')] + [string] $Intent, + [Parameter(Mandatory)] [string] $ConfigPath, + [Parameter()] [string] $Gate = '', + [Parameter()] [string] $RuntimeCondition = '', + [Parameter()] [string] $Rationale = '', + [Parameter()] [string] $SafeDefault = '', + [Parameter()] [string] $TenantImpact = '' + ) + + return [pscustomobject][ordered]@{ + ActionId = $ActionId + Module = $Module + Title = $Title + Intent = $Intent + ConfigPath = $ConfigPath + Gate = $Gate + RuntimeCondition = $RuntimeCondition + GuideControlIds = @() + PrimaryGuideLevel = 'Extension' + PrimaryGuideRecommendations = @() + ComparisonStatus = 'Extended' + SupportingGuideMappings = @() + IntendedStateKeys = @() + Rationale = $Rationale + SafeDefault = $SafeDefault + TenantImpact = $TenantImpact + } +} + +function Get-PurviewPlanComparisonStatus { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]] $Controls + ) + + $statuses = @( + $Controls | + ForEach-Object { [string]$_.Status } | + Select-Object -Unique + ) + foreach ($status in @( + 'Conditional', + 'Aligned', + 'Extended', + 'NotImplemented', + 'NotApplicable' + )) { + if ($status -in $statuses) { + return $status + } + } + return 'Extended' +} + +function New-PurviewPlanReference { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [guid] $PlanId, + + [Parameter(Mandatory)] + [datetime] $GeneratedAt + ) + + $suffix = $PlanId.ToString('N').Substring(0, 8).ToUpperInvariant() + return 'PUR-{0}-{1}' -f $GeneratedAt.ToUniversalTime().ToString('yyyyMMdd-HHmmss'), $suffix +} + +function Get-PurviewPlanValue { + [CmdletBinding()] + param( + [Parameter()] [object] $InputObject, + [Parameter(Mandatory)] [string] $Name, + [Parameter()] [object] $Default = $null + ) + + if ($null -eq $InputObject) { return $Default } + if ($InputObject -is [System.Collections.IDictionary]) { + if ($InputObject.Contains($Name)) { + $value = $InputObject[$Name] + if ($null -eq $value) { return $Default } + return $value + } + return $Default + } + $property = $InputObject.PSObject.Properties[$Name] + if ($property -and $null -ne $property.Value) { return $property.Value } + return $Default +} + +function Protect-PurviewPlanIntendedText { + [CmdletBinding()] + param([Parameter()] [AllowEmptyString()] [string] $Text) + + if ([string]::IsNullOrWhiteSpace($Text)) { return '' } + $protected = $Text -replace '(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b', '[REDACTED-UPN]' + $protected = $protected -replace '(?i)\b[A-Z0-9-]+\.onmicrosoft\.com\b', '[REDACTED-DOMAIN]' + $protected = $protected -replace '(?i)https?://\S+', '[REDACTED-URL]' + $protected = $protected -replace '(?i)\b(?:[A-Z0-9-]+\.)+[A-Z]{2,}\b', '[REDACTED-DOMAIN]' + $protected = $protected -replace '(?i)\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b', '[REDACTED-GUID]' + if ($protected -match '(?i)(?:\b[A-Z]:\\|\\\\[^\\\s]+\\|/(?:home|Users)/)') { + return '[REDACTED-LOCAL-PATH]' + } + $protected = $protected -replace '(?i)\b(password|secret|token|credential|api[-_]?key)\s*[:=]\s*\S+', '$1=[REDACTED]' + return $protected +} + +function Get-PurviewPlanOpaqueDigest { + [CmdletBinding()] + param([Parameter(Mandatory)] [AllowEmptyString()] [string] $Value) + + return 'sha256:' + ( + Get-PurviewPlanSha256 -Text $Value.Trim().ToLowerInvariant() + ).Substring(0, 24) +} + +function ConvertTo-PurviewPlanPrincipalSet { + [CmdletBinding()] + param( + [Parameter()] [object] $Value, + [Parameter()] [string[]] $AllowedRoles = @('SiteAdmin', 'LastModifier', 'Owner') + ) + + return @( + ConvertTo-PurviewPlanStringSet -Value $Value | + ForEach-Object { + if ($_ -in $AllowedRoles) { + [pscustomobject][ordered]@{ + Type = 'RoleToken' + Role = [string]$_ + Digest = '' + } + } else { + [pscustomobject][ordered]@{ + Type = 'CustomPrincipal' + Role = '' + Digest = Get-PurviewPlanOpaqueDigest -Value ([string]$_) + } + } + } | + Sort-Object Type, Role, Digest + ) +} + +function Get-PurviewPlanConfiguredLabelPaths { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object] $Policy, + [Parameter(Mandatory)] [AllowEmptyCollection()] + [Collections.Generic.List[string]] $Diagnostics, + [Parameter(Mandatory)] [string] $PolicyKey, + [Parameter()] [switch] $AllowSingular + ) + + $plural = @( + @(Get-PurviewPlanValue $Policy 'LabelPaths' @()) | + ForEach-Object { ([string]$_).Trim() } | + Where-Object { $_ } + ) + $singular = [string](Get-PurviewPlanValue $Policy 'LabelPath' '') + if ($plural.Count -gt 0) { + if (-not [string]::IsNullOrWhiteSpace($singular)) { + $Diagnostics.Add( + "Both LabelPaths and LabelPath are configured for $PolicyKey; LabelPaths takes precedence." + ) + } + return @($plural) + } + if ($AllowSingular -and -not [string]::IsNullOrWhiteSpace($singular)) { + return @($singular.Trim()) + } + if (-not $AllowSingular -and -not [string]::IsNullOrWhiteSpace($singular)) { + $Diagnostics.Add( + "LabelPath is not supported for $PolicyKey; configure LabelPaths." + ) + return @() + } + $Diagnostics.Add("No label reference is configured for $PolicyKey.") + return @() +} + +function Protect-PurviewPlanIntendedValue { + [CmdletBinding()] + param( + [Parameter()] [object] $Value, + [Parameter()] [string] $PropertyName = '' + ) + + if ($null -eq $Value) { return $null } + if ($Value -is [string]) { + if ($PropertyName -in @( + 'Key', + 'ParentKey', + 'ActionId', + 'BuiltInName', + 'LabelKeys', + 'PublishedLabelKeys', + 'TenantDependentParentKeys', + 'DefaultLabelKey', + 'EmailDefaultLabelKey', + 'OverrideLabelKeys', + 'IntendedStateKeys' + )) { + return $Value + } + return Protect-PurviewPlanIntendedText $Value + } + if ($Value -is [System.Collections.IDictionary]) { + $protected = [ordered]@{} + foreach ($key in $Value.Keys) { + $protected[[string]$key] = Protect-PurviewPlanIntendedValue ` + -Value $Value[$key] ` + -PropertyName ([string]$key) + } + return [pscustomobject]$protected + } + if ($Value -is [pscustomobject]) { + $protected = [ordered]@{} + foreach ($property in $Value.PSObject.Properties) { + $protected[$property.Name] = Protect-PurviewPlanIntendedValue ` + -Value $property.Value ` + -PropertyName $property.Name + } + return [pscustomobject]$protected + } + if ($Value -is [System.Collections.IEnumerable] -and + $Value -isnot [string]) { + $protectedItems = [Collections.Generic.List[object]]::new() + foreach ($item in $Value) { + $protectedItem = Protect-PurviewPlanIntendedValue ` + -Value $item ` + -PropertyName $PropertyName + $protectedItems.Add($protectedItem) + } + return ,([object[]]$protectedItems.ToArray()) + } + return $Value +} + +function ConvertTo-PurviewPlanStringSet { + [CmdletBinding()] + param( + [Parameter()] [object] $Value, + [Parameter()] [string[]] $Remove = @() + ) + + $values = if ($Value -is [string]) { + @($Value -split ',') + } else { + @($Value) + } + return @( + $values | + ForEach-Object { ([string]$_).Trim() } | + Where-Object { $_ -and $_ -notin $Remove } | + Sort-Object -Unique + ) +} + +function Get-PurviewPlanLabelKey { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object] $Label, + [Parameter()] [string] $ParentName = '' + ) + + $builtInName = [string](Get-PurviewPlanValue -InputObject $Label -Name 'BuiltInName') + if ($builtInName -match '^defa4170-0d19-0005-[0-9a-fA-F]{4}-bc88714345d2$') { + return "builtin:$($builtInName.Trim().ToLowerInvariant())" + } + if (-not [string]::IsNullOrWhiteSpace($builtInName)) { + throw ( + "Configured BuiltInName is invalid. Use a Microsoft built-in signature matching " + + "'defa4170-0d19-0005-NNNN-bc88714345d2' or leave it empty for a custom label." + ) + } + $name = [string](Get-PurviewPlanValue -InputObject $Label -Name 'Name') + if ($ParentName) { + $hash = Get-PurviewPlanSha256 -Text ( + "$($ParentName.Trim().ToLowerInvariant())/$($name.Trim().ToLowerInvariant())" + ) + return "custom:$($hash.Substring(0, 24))" + } + $hash = Get-PurviewPlanSha256 -Text $name.Trim().ToLowerInvariant() + return "custom:$($hash.Substring(0, 24))" +} + +function Get-PurviewPlanRightsSnapshot { + [CmdletBinding()] + param([Parameter()] [string] $Definition) + + if ([string]::IsNullOrWhiteSpace($Definition)) { + return [pscustomobject][ordered]@{ + Entries = @() + } + } + $entries = foreach ($entry in @($Definition -split ';')) { + if ([string]::IsNullOrWhiteSpace($entry)) { continue } + $parts = @($entry -split ':', 2) + $principal = $parts[0].Trim() + $audience = if ($principal -eq '{TenantDomain}') { + 'TenantDomainToken' + } elseif ($principal -in @('{AuthenticatedUsers}', 'AuthenticatedUsers')) { + 'BroadAuthenticatedUsers' + } elseif ($principal -match '@|\.[A-Za-z]{2,}$') { + 'CustomPrincipalRedacted' + } else { + 'NamedPrincipalRedacted' + } + $rights = if ($parts.Count -gt 1) { + @( + ConvertTo-PurviewPlanStringSet -Value $parts[1] | + ForEach-Object { + if ($_ -match '^[A-Za-z][A-Za-z0-9_-]*$') { + $_.ToUpperInvariant() + } else { + '[REDACTED-RIGHT]' + } + } | + Sort-Object -Unique + ) + } else { + @() + } + [pscustomobject][ordered]@{ + Audience = $audience + Digest = if ($audience -in @( + 'TenantDomainToken', + 'BroadAuthenticatedUsers' + )) { + '' + } else { + Get-PurviewPlanOpaqueDigest -Value $principal + } + Rights = @($rights) + } + } + return [pscustomobject][ordered]@{ + Entries = @( + $entries | + Sort-Object Audience, Digest, { + @($_.Rights) -join ',' + } + ) + } +} + +function Get-PurviewPlanIntendedState { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [System.Collections.IDictionary] $Config, + [Parameter(Mandatory)] [object[]] $Actions, + [Parameter(Mandatory)] [bool] $SkipContainerLabels, + [Parameter(Mandatory)] [int] $PremiumAuditMailboxCount + ) + + $actionsById = @{} + foreach ($action in $Actions) { $actionsById[[string]$action.ActionId] = $action } + function ActionIntent([string] $ActionId) { + if ($actionsById.ContainsKey($ActionId)) { return [string]$actionsById[$ActionId].Intent } + return 'NotConfigured' + } + + $tenantSettings = Get-PurviewPlanValue -InputObject $Config -Name 'TenantSettings' -Default @{} + $tenantSnapshot = @( + [pscustomobject][ordered]@{ + Key = 'tenant.audit-standard' + ActionId = 'purview.tenant.audit-standard' + Intent = ActionIntent 'purview.tenant.audit-standard' + DesiredValue = [bool](Get-PurviewPlanValue $tenantSettings 'EnableUnifiedAuditLog' $false) + } + [pscustomobject][ordered]@{ + Key = 'tenant.spo-labels' + ActionId = 'purview.tenant.spo-labels' + Intent = ActionIntent 'purview.tenant.spo-labels' + DesiredValue = [bool](Get-PurviewPlanValue $tenantSettings 'EnableAIPIntegrationInSPO' $false) + } + [pscustomobject][ordered]@{ + Key = 'tenant.pdf-labels' + ActionId = 'purview.tenant.pdf-labels' + Intent = ActionIntent 'purview.tenant.pdf-labels' + DesiredValue = [bool](Get-PurviewPlanValue $tenantSettings 'EnableSensitivityLabelForPDF' $false) + } + [pscustomobject][ordered]@{ + Key = 'tenant.container-labels' + ActionId = 'purview.tenant.container-directory-setting' + Intent = ActionIntent 'purview.tenant.container-directory-setting' + DesiredValue = -not $SkipContainerLabels + } + [pscustomobject][ordered]@{ + Key = 'tenant.label-coauthoring' + ActionId = 'purview.tenant.label-coauthoring' + Intent = ActionIntent 'purview.tenant.label-coauthoring' + DesiredValue = (ActionIntent 'purview.tenant.label-coauthoring') -eq 'Included' + } + [pscustomobject][ordered]@{ + Key = 'tenant.audit-premium' + ActionId = 'purview.tenant.audit-premium' + Intent = ActionIntent 'purview.tenant.audit-premium' + RequestedMailboxCount = $PremiumAuditMailboxCount + ExactTargetsIncluded = $false + } + ) + + $intendedDiagnostics = [Collections.Generic.List[string]]::new() + $labels = [Collections.Generic.List[object]]::new() + $labelKeyByPath = @{} + $labelKeysByName = @{} + function AddLabelNameCandidate([string] $Name, [string] $Key) { + if ([string]::IsNullOrWhiteSpace($Name)) { return } + $lookup = $Name.Trim().ToLowerInvariant() + if (-not $labelKeysByName.ContainsKey($lookup)) { + $labelKeysByName[$lookup] = [Collections.Generic.List[string]]::new() + } + if (-not $labelKeysByName[$lookup].Contains($Key)) { + $labelKeysByName[$lookup].Add($Key) + } + } + $priority = 0 + $contentMarkingEnabled = [bool](Get-PurviewPlanValue $Config 'EnableContentMarking' $false) + foreach ($root in @(Get-PurviewPlanValue $Config 'Labels' @())) { + $rootKey = Get-PurviewPlanLabelKey -Label $root + $rootName = [string](Get-PurviewPlanValue $root 'Name') + $rootBuiltInName = [string](Get-PurviewPlanValue $root 'BuiltInName') + $rootEncrypt = [bool](Get-PurviewPlanValue $root 'Encrypt' $false) + $rootProtectionType = if ($rootEncrypt) { + $configuredProtectionType = [string](Get-PurviewPlanValue $root 'ProtectionType' 'Template') + if ([string]::IsNullOrWhiteSpace($configuredProtectionType)) { 'Template' } else { $configuredProtectionType } + } else { '' } + $rootExplicitRights = [string](Get-PurviewPlanValue $root 'EncryptionRightsDefinitions' '') + $rootRightsSource = if (-not $rootEncrypt -or $rootProtectionType -ne 'Template') { + 'NotApplicable' + } elseif (-not [string]::IsNullOrWhiteSpace($rootExplicitRights)) { + 'PerLabelOverride' + } else { + 'GlobalDefault' + } + $rootRights = if ($rootRightsSource -eq 'PerLabelOverride') { + $rootExplicitRights + } elseif ($rootRightsSource -eq 'GlobalDefault') { + [string](Get-PurviewPlanValue $Config 'EncryptionRightsDefinitions' '') + } else { + '' + } + $rootChildren = @(Get-PurviewPlanValue $root 'SubLabels' @()) + $rootOriginalContentTypes = @( + ConvertTo-PurviewPlanStringSet -Value (Get-PurviewPlanValue $root 'ContentType' '') + ) + $rootConfiguredContentTypes = @( + ConvertTo-PurviewPlanStringSet ` + -Value (Get-PurviewPlanValue $root 'ContentType' '') ` + -Remove $(if ($SkipContainerLabels) { @('Site', 'UnifiedGroup') } else { @() }) + ) + $rootContainerBitsStripped = $SkipContainerLabels -and + @($rootOriginalContentTypes | Where-Object { $_ -in @('Site', 'UnifiedGroup') }).Count -gt 0 + $rootContentTypeSource = if ($rootOriginalContentTypes.Count -gt 0) { + 'Configured' + } elseif ($rootChildren.Count -gt 0) { + 'RuntimeLabelScheme' + } else { + 'ServiceDefault' + } + $rootContentTypes = if ($rootContentTypeSource -eq 'ServiceDefault') { + @('Email', 'File') + } else { + @($rootConfiguredContentTypes) + } + $rootContentMark = $contentMarkingEnabled -and + [bool](Get-PurviewPlanValue $root 'ContentMark' $false) + $labels.Add([pscustomobject][ordered]@{ + Key = $rootKey + ParentKey = '' + Name = $rootName + DisplayName = Protect-PurviewPlanIntendedText ([string](Get-PurviewPlanValue $root 'DisplayName')) + BuiltInName = $rootBuiltInName + Priority = $priority++ + ContentTypes = @($rootContentTypes) + ContentTypeSource = $rootContentTypeSource + ContentTypeSemantics = if ($rootContentTypeSource -eq 'RuntimeLabelScheme') { + 'RuntimeLabelScheme' + } elseif ($rootContentTypeSource -eq 'Configured' -and $rootContentTypes.Count -gt 0) { + 'EnsureMinimumPreserveExisting' + } else { + 'NewUsesServiceDefaultExistingPreserved' + } + ContainerBitsStripped = $rootContainerBitsStripped + Color = ([string](Get-PurviewPlanValue $root 'Color')).ToUpperInvariant() + Encrypt = $rootEncrypt + ProtectionType = $rootProtectionType + OutlookBehavior = if ($rootEncrypt) { + [string](Get-PurviewPlanValue $root 'UserDefinedOutlookBehavior') + } else { '' } + RightsSource = $rootRightsSource + Rights = Get-PurviewPlanRightsSnapshot -Definition $rootRights + ContentMark = $rootContentMark + HeaderText = if ($rootContentMark) { + Protect-PurviewPlanIntendedText ([string](Get-PurviewPlanValue $root 'HeaderText')) + } else { '' } + FooterText = if ($rootContentMark) { + Protect-PurviewPlanIntendedText ([string](Get-PurviewPlanValue $root 'FooterText')) + } else { '' } + WatermarkText = if ($rootContentMark) { + Protect-PurviewPlanIntendedText ([string](Get-PurviewPlanValue $root 'WatermarkText')) + } else { '' } + }) + $labelKeyByPath[$rootName.ToLowerInvariant()] = $rootKey + AddLabelNameCandidate -Name $rootName -Key $rootKey + foreach ($child in $rootChildren) { + $childKey = Get-PurviewPlanLabelKey -Label $child -ParentName $rootName + $childName = [string](Get-PurviewPlanValue $child 'Name') + $childBuiltInName = [string](Get-PurviewPlanValue $child 'BuiltInName') + $childEncrypt = [bool](Get-PurviewPlanValue $child 'Encrypt' $false) + $childProtectionType = if ($childEncrypt) { + $configuredProtectionType = [string](Get-PurviewPlanValue $child 'ProtectionType' 'Template') + if ([string]::IsNullOrWhiteSpace($configuredProtectionType)) { 'Template' } else { $configuredProtectionType } + } else { '' } + $childExplicitRights = [string](Get-PurviewPlanValue $child 'EncryptionRightsDefinitions' '') + $childRightsSource = if (-not $childEncrypt -or $childProtectionType -ne 'Template') { + 'NotApplicable' + } elseif (-not [string]::IsNullOrWhiteSpace($childExplicitRights)) { + 'PerLabelOverride' + } else { + 'GlobalDefault' + } + $childRights = if ($childRightsSource -eq 'PerLabelOverride') { + $childExplicitRights + } elseif ($childRightsSource -eq 'GlobalDefault') { + [string](Get-PurviewPlanValue $Config 'EncryptionRightsDefinitions' '') + } else { + '' + } + $childOriginalContentTypes = @( + ConvertTo-PurviewPlanStringSet -Value (Get-PurviewPlanValue $child 'ContentType' '') + ) + $childConfiguredContentTypes = @( + ConvertTo-PurviewPlanStringSet ` + -Value (Get-PurviewPlanValue $child 'ContentType' '') ` + -Remove $(if ($SkipContainerLabels) { @('Site', 'UnifiedGroup') } else { @() }) + ) + $childContainerBitsStripped = $SkipContainerLabels -and + @($childOriginalContentTypes | Where-Object { $_ -in @('Site', 'UnifiedGroup') }).Count -gt 0 + $childContentTypeSource = if ($childOriginalContentTypes.Count -gt 0) { + 'Configured' + } else { + 'ServiceDefault' + } + $childContentTypes = if ($childContentTypeSource -eq 'ServiceDefault') { + @('Email', 'File') + } else { + @($childConfiguredContentTypes) + } + $childContentMark = $contentMarkingEnabled -and + [bool](Get-PurviewPlanValue $child 'ContentMark' $false) + $labels.Add([pscustomobject][ordered]@{ + Key = $childKey + ParentKey = $rootKey + Name = $childName + DisplayName = Protect-PurviewPlanIntendedText ([string](Get-PurviewPlanValue $child 'DisplayName')) + BuiltInName = $childBuiltInName + Priority = $priority++ + ContentTypes = @($childContentTypes) + ContentTypeSource = $childContentTypeSource + ContentTypeSemantics = if ($childContentTypeSource -eq 'Configured' -and + $childContentTypes.Count -gt 0) { + 'EnsureMinimumPreserveExisting' + } else { + 'NewUsesServiceDefaultExistingPreserved' + } + ContainerBitsStripped = $childContainerBitsStripped + Color = ([string](Get-PurviewPlanValue $child 'Color')).ToUpperInvariant() + Encrypt = $childEncrypt + ProtectionType = $childProtectionType + OutlookBehavior = if ($childEncrypt) { + [string](Get-PurviewPlanValue $child 'UserDefinedOutlookBehavior') + } else { '' } + RightsSource = $childRightsSource + Rights = Get-PurviewPlanRightsSnapshot -Definition $childRights + ContentMark = $childContentMark + HeaderText = if ($childContentMark) { + Protect-PurviewPlanIntendedText ([string](Get-PurviewPlanValue $child 'HeaderText')) + } else { '' } + FooterText = if ($childContentMark) { + Protect-PurviewPlanIntendedText ([string](Get-PurviewPlanValue $child 'FooterText')) + } else { '' } + WatermarkText = if ($childContentMark) { + Protect-PurviewPlanIntendedText ([string](Get-PurviewPlanValue $child 'WatermarkText')) + } else { '' } + }) + $labelKeyByPath[("$rootName/$childName").ToLowerInvariant()] = $childKey + AddLabelNameCandidate -Name $childName -Key $childKey + } + } + + $duplicateLabelKeys = [Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase + ) + foreach ($group in @($labels | Group-Object Key | Where-Object Count -gt 1)) { + $null = $duplicateLabelKeys.Add([string]$group.Name) + $safeKey = Protect-PurviewPlanIntendedText ([string]$group.Name) + $intendedDiagnostics.Add( + "Duplicate configured label key: $safeKey appears $($group.Count) times" + ) + } + $labelsForSnapshot = @( + $labels | + Where-Object { -not $duplicateLabelKeys.Contains([string]$_.Key) } | + Sort-Object Priority, Key + ) + + function ResolveLabelKey([string] $Path) { + if ([string]::IsNullOrWhiteSpace($Path)) { return '' } + $lookup = $Path.Trim().Replace('\', '/').ToLowerInvariant() + if ($labelKeyByPath.ContainsKey($lookup)) { + $resolvedKey = [string]$labelKeyByPath[$lookup] + if (-not $duplicateLabelKeys.Contains($resolvedKey)) { return $resolvedKey } + $safePath = Protect-PurviewPlanIntendedText $Path + $diagnostic = "Ambiguous configured label reference: $safePath resolves to a duplicate key" + if (-not $intendedDiagnostics.Contains($diagnostic)) { + $intendedDiagnostics.Add($diagnostic) + } + return '' + } + if ($lookup -notmatch '/' -and $labelKeysByName.ContainsKey($lookup)) { + $candidates = @($labelKeysByName[$lookup] | Sort-Object -Unique) + if ($candidates.Count -eq 1 -and + -not $duplicateLabelKeys.Contains([string]$candidates[0])) { + return [string]$candidates[0] + } + $safePath = Protect-PurviewPlanIntendedText $Path + $diagnostic = "Ambiguous configured label reference: $safePath matches $($candidates.Count) labels" + if (-not $intendedDiagnostics.Contains($diagnostic)) { + $intendedDiagnostics.Add($diagnostic) + } + return '' + } + $safePath = Protect-PurviewPlanIntendedText $Path + $diagnostic = "Unresolved configured label reference: $safePath" + if (-not $intendedDiagnostics.Contains($diagnostic)) { + $intendedDiagnostics.Add($diagnostic) + } + return '' + } + + $labelPolicyConfig = Get-PurviewPlanValue $Config 'LabelPolicy' @{} + $publishedLabelNames = @(Get-PurviewPlanValue $labelPolicyConfig 'PublishedLabels' @()) + $publicationMode = if (@($publishedLabelNames | Where-Object { $_ }).Count -gt 0) { + 'ExplicitList' + } else { + 'AllConfigured' + } + if ($publicationMode -eq 'AllConfigured') { + $publishedLabelKeys = @( + $labelsForSnapshot | + ForEach-Object Key | + Sort-Object -Unique + ) + } else { + $publishKeySet = [Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase + ) + foreach ($publishedName in $publishedLabelNames) { + $publishedKey = ResolveLabelKey ([string]$publishedName) + if (-not $publishedKey) { continue } + $null = $publishKeySet.Add($publishedKey) + $labelRecord = @( + $labelsForSnapshot | + Where-Object Key -eq $publishedKey | + Select-Object -First 1 + ) + if ($labelRecord.Count -eq 1 -and $labelRecord[0].ParentKey) { + $null = $publishKeySet.Add([string]$labelRecord[0].ParentKey) + } + } + $publishedLabelKeys = @($publishKeySet | Sort-Object) + } + $defaultLabelName = [string](Get-PurviewPlanValue $labelPolicyConfig 'DefaultLabel') + $defaultLabelKey = ResolveLabelKey $defaultLabelName + $emailDefaultLabelName = [string](Get-PurviewPlanValue $labelPolicyConfig 'DefaultLabelForEmail') + $emailDefaultLabelKey = ResolveLabelKey $emailDefaultLabelName + $labelPolicyUnresolvedCount = @( + @($publishedLabelNames) + @($defaultLabelName, $emailDefaultLabelName) | + Where-Object { $_ -and -not (ResolveLabelKey ([string]$_)) } + ).Count + $labelPolicy = [pscustomobject][ordered]@{ + Key = 'label-policy/main' + ActionId = 'purview.labels.publish' + Intent = ActionIntent 'purview.labels.publish' + Name = Protect-PurviewPlanIntendedText ([string](Get-PurviewPlanValue $labelPolicyConfig 'Name')) + PublicationMode = $publicationMode + PublishedLabelKeys = @($publishedLabelKeys) + TenantDependentParentKeys = @( + $labelsForSnapshot | + Where-Object { + $_.ContentTypeSource -eq 'RuntimeLabelScheme' -and + $_.Key -in $publishedLabelKeys + } | + ForEach-Object Key | + Sort-Object -Unique + ) + DefaultLabelKey = $defaultLabelKey + EmailDefaultLabelKey = $emailDefaultLabelKey + UnresolvedLabelReferenceCount = $labelPolicyUnresolvedCount + Mandatory = [bool](Get-PurviewPlanValue $labelPolicyConfig 'MandatoryLabelling' $false) + RequireDowngradeJustification = [bool](Get-PurviewPlanValue $labelPolicyConfig 'DowngradeJustification' $false) + AttachmentAction = [string](Get-PurviewPlanValue $labelPolicyConfig 'AttachmentAction') + } + + $startInSimulation = [bool](Get-PurviewPlanValue $Config 'DlpStartInSimulation' $true) + $dlpPolicies = [Collections.Generic.List[object]]::new() + $dlpIndex = 0 + $customDlpIndex = 0 + $supportedDlpWorkloads = @('Exchange', 'SharePointOneDrive', 'Endpoint') + foreach ($policy in @(Get-PurviewPlanValue $Config 'DlpPolicies' @())) { + $workload = [string](Get-PurviewPlanValue $policy 'Workload') + $actionId = switch ($workload) { + 'Exchange' { 'purview.dlp.exchange' } + 'SharePointOneDrive' { 'purview.dlp.sharepoint-onedrive' } + 'Endpoint' { 'purview.dlp.endpoint' } + default { + $customDlpIndex++ + "purview.dlp.custom-$customDlpIndex" + } + } + if ($workload -notin $supportedDlpWorkloads) { + $safeWorkload = Protect-PurviewPlanIntendedText $workload + $intendedDiagnostics.Add( + "Unsupported DLP workload omitted from intended state: $safeWorkload" + ) + $dlpIndex++ + continue + } + $rawPolicyName = [string](Get-PurviewPlanValue $policy 'Name') + $rawRuleName = [string](Get-PurviewPlanValue $policy 'RuleName') + $safePolicyName = Protect-PurviewPlanIntendedText $rawPolicyName + $safeRuleName = Protect-PurviewPlanIntendedText $rawRuleName + $policyKeyHash = ( + Get-PurviewPlanSha256 -Text ( + "$actionId|$($rawPolicyName.Trim().ToLowerInvariant())|$($rawRuleName.Trim().ToLowerInvariant())" + ) + ).Substring(0, 16) + $policyKey = "dlp/$actionId/$policyKeyHash" + $configuredLabelPaths = @( + Get-PurviewPlanConfiguredLabelPaths ` + -Policy $policy ` + -Diagnostics $intendedDiagnostics ` + -PolicyKey $policyKey ` + -AllowSingular + ) + $locations = switch ($workload) { + 'Exchange' { @('Exchange:All') } + 'SharePointOneDrive' { @('OneDrive:All', 'SharePoint:All') } + 'Endpoint' { @('Endpoint:All') } + default { @() } + } + $restrictions = @( + @(Get-PurviewPlanValue $policy 'EndpointDlpRestrictions' @()) | + ForEach-Object { + [pscustomobject][ordered]@{ + Setting = [string](Get-PurviewPlanValue $_ 'Setting') + Value = [string](Get-PurviewPlanValue $_ 'Value' ( + Get-PurviewPlanValue $_ 'Action' + )) + } + } | + Sort-Object Setting, Value + ) + $dlpPolicies.Add([pscustomobject][ordered]@{ + Key = $policyKey + ActionId = $actionId + Intent = ActionIntent $actionId + Name = $safePolicyName + RuleName = $safeRuleName + Workload = $workload + Mode = if ($startInSimulation) { 'TestWithoutNotifications' } else { 'Enable' } + Locations = @($locations) + LabelKeys = @( + $configuredLabelPaths | + ForEach-Object { ResolveLabelKey ([string]$_) } | + Where-Object { $_ } | + Sort-Object -Unique + ) + UnresolvedLabelReferenceCount = @( + $configuredLabelPaths | + Where-Object { -not (ResolveLabelKey ([string]$_)) } + ).Count + BlockAccess = [bool](Get-PurviewPlanValue $policy 'BlockAccess' $false) + BlockAccessScope = [string](Get-PurviewPlanValue $policy 'BlockAccessScope') + EndpointRestrictions = @($restrictions) + EnforcePortalAccess = [bool](Get-PurviewPlanValue $policy 'EnforcePortalAccess' $false) + ReportSeverityLevel = [string](Get-PurviewPlanValue $policy 'ReportSeverityLevel') + GenerateAlert = [bool](Get-PurviewPlanValue $policy 'GenerateAlert' $false) + NotifyUser = @( + ConvertTo-PurviewPlanPrincipalSet ( + Get-PurviewPlanValue $policy 'NotifyUser' @() + ) + ) + GenerateIncidentReport = @( + ConvertTo-PurviewPlanPrincipalSet ( + Get-PurviewPlanValue $policy 'GenerateIncidentReport' @() + ) + ) + }) + $dlpIndex++ + } + + $retentionConfig = Get-PurviewPlanValue $Config 'Retention' @{} + $retentionLocations = Get-PurviewRetentionLocationClassification -Value ( + Get-PurviewPlanValue $retentionConfig 'Locations' @() + ) + foreach ($unsupportedLocation in @($retentionLocations.UnsupportedEvidence)) { + $intendedDiagnostics.Add( + "Unsupported retention location omitted from deployment intent: $unsupportedLocation" + ) + } + if ($retentionLocations.Supported.Count -eq 0 -and + $retentionLocations.Unsupported.Count -gt 0) { + $intendedDiagnostics.Add( + 'Retention has no supported locations and cannot be deployed.' + ) + } elseif ($retentionLocations.Supported.Count -eq 0) { + $intendedDiagnostics.Add( + 'Retention has no configured locations and cannot be deployed.' + ) + } + $retention = [pscustomobject][ordered]@{ + Key = 'retention/main' + ActionId = 'purview.retention.exchange' + Intent = ActionIntent 'purview.retention.exchange' + Name = Protect-PurviewPlanIntendedText ([string](Get-PurviewPlanValue $retentionConfig 'Name')) + RuleName = Protect-PurviewPlanIntendedText ([string](Get-PurviewPlanValue $retentionConfig 'RuleName')) + Locations = @($retentionLocations.Supported) + UnsupportedLocations = @($retentionLocations.UnsupportedEvidence) + LocationDisposition = if ($retentionLocations.Supported.Count -eq 0 -and + $retentionLocations.Unsupported.Count -gt 0) { + 'UnsupportedOnly' + } elseif ($retentionLocations.Supported.Count -eq 0) { + 'Empty' + } elseif ($retentionLocations.Unsupported.Count -eq 0) { + 'Supported' + } else { + 'Partial' + } + DurationDays = [int](Get-PurviewPlanValue $retentionConfig 'DurationDays' 0) + DurationDisplayHint = [string](Get-PurviewPlanValue $retentionConfig 'DurationDisplayHint') + Action = [string](Get-PurviewPlanValue $retentionConfig 'Action') + ExpirationDateOption = [string](Get-PurviewPlanValue $retentionConfig 'ExpirationDateOption') + } + + $contentExpiration = [string]( + Get-PurviewPlanValue $Config 'EncryptionContentExpiredOnDateInDaysOrNever' 'Never' + ) + if ([string]::IsNullOrWhiteSpace($contentExpiration)) { + $contentExpiration = 'Never' + } + $encryption = [pscustomobject][ordered]@{ + Key = 'encryption/global' + ActionId = 'purview.labels.encryption' + Intent = ActionIntent 'purview.labels.encryption' + OfflineAccessDays = [int](Get-PurviewPlanValue $Config 'EncryptionOfflineAccessDays' 0) + ContentExpiration = $contentExpiration + DefaultRights = Get-PurviewPlanRightsSnapshot -Definition ( + [string](Get-PurviewPlanValue $Config 'EncryptionRightsDefinitions' '') + ) + OverrideLabelKeys = @( + $labelsForSnapshot | + Where-Object { + $_.Encrypt -and $_.RightsSource -eq 'PerLabelOverride' + } | + ForEach-Object Key | + Sort-Object -Unique + ) + } + + $aiPolicies = [Collections.Generic.List[object]]::new() + $aiConfig = Get-PurviewPlanValue $Config 'AIGovernance' @{} + $aiIndex = 0 + foreach ($policy in @(Get-PurviewPlanValue $aiConfig 'DlpPolicies' @())) { + $configuredAiLabelPaths = @( + Get-PurviewPlanConfiguredLabelPaths ` + -Policy $policy ` + -Diagnostics $intendedDiagnostics ` + -PolicyKey "ai/purview.ai.copilot-dlp/$aiIndex" + ) + $locations = @( + @(Get-PurviewPlanValue $policy 'Locations' @()) | + ForEach-Object { + $locationIdentity = Get-PurviewLocationIdentity -Value ( + [string](Get-PurviewPlanValue $_ 'Location') + ) + $inclusions = @( + @(Get-PurviewPlanValue $_ 'Inclusions' @()) | + ForEach-Object { + $identity = [string](Get-PurviewPlanValue $_ 'Identity') + [pscustomobject][ordered]@{ + Type = [string](Get-PurviewPlanValue $_ 'Type') + IdentityClass = if ($identity -eq 'All') { 'All' } else { 'CustomPrincipal' } + Digest = if ($identity -eq 'All') { '' } else { + Get-PurviewPlanOpaqueDigest -Value $identity + } + } + } | + Sort-Object Type, IdentityClass, Digest + ) + $exclusions = @( + @(Get-PurviewPlanValue $_ 'Exclusions' @()) | + ForEach-Object { + [pscustomobject][ordered]@{ + Type = [string](Get-PurviewPlanValue $_ 'Type') + IdentityClass = 'CustomPrincipal' + Digest = Get-PurviewPlanOpaqueDigest -Value ( + [string](Get-PurviewPlanValue $_ 'Identity') + ) + } + } | + Sort-Object Type, IdentityClass, Digest + ) + [pscustomobject][ordered]@{ + Workload = [string](Get-PurviewPlanValue $_ 'Workload') + Location = $locationIdentity.Value + LocationClass = $locationIdentity.Class + LocationDigest = $locationIdentity.Digest + Inclusions = @($inclusions) + Exclusions = @($exclusions) + } + } | + Sort-Object Workload, LocationClass, LocationDigest, Location + ) + $restrictions = @( + @(Get-PurviewPlanValue $policy 'RestrictAccess' @()) | + ForEach-Object { + [pscustomobject][ordered]@{ + Setting = [string](Get-PurviewPlanValue $_ 'setting') + Value = [string](Get-PurviewPlanValue $_ 'value') + } + } | + Sort-Object Setting, Value + ) + $rawAiPolicyName = [string](Get-PurviewPlanValue $policy 'Name') + $rawAiRuleName = [string](Get-PurviewPlanValue $policy 'RuleName') + $safeAiPolicyName = Protect-PurviewPlanIntendedText $rawAiPolicyName + $safeAiRuleName = Protect-PurviewPlanIntendedText $rawAiRuleName + $aiKeyHash = ( + Get-PurviewPlanSha256 -Text ( + "purview.ai.copilot-dlp|$($rawAiPolicyName.Trim().ToLowerInvariant())|$($rawAiRuleName.Trim().ToLowerInvariant())" + ) + ).Substring(0, 16) + $aiPolicies.Add([pscustomobject][ordered]@{ + Key = "ai/purview.ai.copilot-dlp/$aiKeyHash" + ActionId = 'purview.ai.copilot-dlp' + Intent = ActionIntent 'purview.ai.copilot-dlp' + Name = $safeAiPolicyName + RuleName = $safeAiRuleName + Mode = if ($startInSimulation) { 'TestWithoutNotifications' } else { + [string](Get-PurviewPlanValue $policy 'Mode' 'Enable') + } + EnforcementPlanes = @( + ConvertTo-PurviewPlanStringSet (Get-PurviewPlanValue $policy 'EnforcementPlanes' @()) + ) + Locations = @($locations) + LabelKeys = @( + $configuredAiLabelPaths | + ForEach-Object { ResolveLabelKey ([string]$_) } | + Where-Object { $_ } | + Sort-Object -Unique + ) + UnresolvedLabelReferenceCount = @( + $configuredAiLabelPaths | + Where-Object { -not (ResolveLabelKey ([string]$_)) } + ).Count + LabelResolution = 'ExpandLabelGroupsAtRuntime' + Restrictions = @($restrictions) + }) + $aiIndex++ + } + + $duplicateDlpKeys = [Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase + ) + foreach ($group in @($dlpPolicies | Group-Object Key | Where-Object Count -gt 1)) { + $null = $duplicateDlpKeys.Add([string]$group.Name) + $intendedDiagnostics.Add( + "Duplicate configured DLP identity omitted from intended state: $($group.Name)" + ) + } + $dlpPoliciesForSnapshot = @( + $dlpPolicies | + Where-Object { -not $duplicateDlpKeys.Contains([string]$_.Key) } | + Sort-Object Key + ) + + $duplicateAiKeys = [Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase + ) + foreach ($group in @($aiPolicies | Group-Object Key | Where-Object Count -gt 1)) { + $null = $duplicateAiKeys.Add([string]$group.Name) + $intendedDiagnostics.Add( + "Duplicate configured AI governance identity omitted from intended state: $($group.Name)" + ) + } + $aiPoliciesForSnapshot = @( + $aiPolicies | + Where-Object { -not $duplicateAiKeys.Contains([string]$_.Key) } | + Sort-Object Key + ) + + $state = [pscustomobject][ordered]@{ + TenantSettings = @($tenantSnapshot) + Labels = @($labelsForSnapshot) + LabelPolicy = $labelPolicy + DlpPolicies = @($dlpPoliciesForSnapshot) + Retention = $retention + Encryption = $encryption + AIGovernance = @($aiPoliciesForSnapshot) + Diagnostics = @($intendedDiagnostics | Sort-Object -Unique) + } + return Protect-PurviewPlanIntendedValue $state +} + +function Test-PurviewDeploymentPlanMapping { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [System.Collections.IDictionary] $GuideMapping, + + [Parameter(Mandatory)] + [object[]] $Actions, + + [Parameter(Mandatory)] + [ValidateSet('Primary', 'Supporting')] + [string] $ExpectedRole + ) + + $requiredGuideFields = @( + 'Id', 'Title', 'RevisionDate', 'VerifiedDate', 'Publisher', + 'SourceClassification', 'Owner' + ) + if (-not $GuideMapping.Contains('Guide') -or + $GuideMapping.Guide -isnot [System.Collections.IDictionary]) { + throw 'Guide mapping must contain a Guide dictionary.' + } + + foreach ($field in $requiredGuideFields) { + if (-not $GuideMapping.Guide.Contains($field) -or + [string]::IsNullOrWhiteSpace([string]$GuideMapping.Guide[$field])) { + throw "Guide mapping is missing required metadata '$field'." + } + } + + $role = if ($GuideMapping.Contains('Role')) { + [string]$GuideMapping.Role + } else { + 'Primary' + } + if ($role -notin @('Primary', 'Supporting')) { + throw "Guide mapping role '$role' is not supported." + } + if ($role -ne $ExpectedRole) { + throw "Guide mapping role '$role' cannot be used as '$ExpectedRole'." + } + if ($role -eq 'Primary') { + foreach ($primaryField in @('Edition', 'SourceModifiedDate', 'SourceFileName', 'Levels')) { + if (-not $GuideMapping.Guide.Contains($primaryField) -or + $null -eq $GuideMapping.Guide[$primaryField]) { + throw "Primary guide mapping is missing required metadata '$primaryField'." + } + } + $levels = @($GuideMapping.Guide.Levels) + $levelIds = @($levels | ForEach-Object { [string]$_.Id }) + if ($levelIds.Count -ne 3 -or + (@($levelIds | Sort-Object) -join ',') -ne 'Best,Better,Good') { + throw 'Primary guide mapping must define Good, Better, and Best levels exactly once.' + } + } else { + foreach ($supportingField in @('SourceUri', 'SourceCommit')) { + if (-not $GuideMapping.Guide.Contains($supportingField) -or + [string]::IsNullOrWhiteSpace([string]$GuideMapping.Guide[$supportingField])) { + throw "Supporting guide mapping is missing required metadata '$supportingField'." + } + } + } + + if (-not $GuideMapping.Contains('Controls')) { + throw 'Guide mapping must contain Controls.' + } + + $allowed = @('Aligned', 'Conditional', 'Extended', 'NotImplemented', 'NotApplicable') + $controlIds = [Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase + ) + $actionIds = [Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase + ) + foreach ($action in $Actions) { + if (-not $actionIds.Add([string]$action.ActionId)) { + throw "Deployment Plan action ID '$($action.ActionId)' is duplicated." + } + } + + foreach ($control in @($GuideMapping.Controls)) { + foreach ($field in @('Id', 'Section', 'Summary', 'Status', 'ActionIds', 'ConfigPaths', 'Rationale', 'ReferenceUris')) { + if (-not $control.Contains($field)) { + throw "Guide control is missing required field '$field'." + } + } + if ($role -eq 'Primary') { + foreach ($primaryControlField in @('Level', 'SourceSection')) { + if (-not $control.Contains($primaryControlField) -or + [string]::IsNullOrWhiteSpace([string]$control[$primaryControlField])) { + throw "Primary guide control is missing required field '$primaryControlField'." + } + } + if ([string]$control.Level -notin @('Good', 'Better', 'Best')) { + throw "Primary guide control '$($control.Id)' has unknown level '$($control.Level)'." + } + } + if (-not $controlIds.Add([string]$control.Id)) { + throw "Guide control ID '$($control.Id)' is duplicated." + } + if ([string]$control.Status -notin $allowed) { + throw "Guide control '$($control.Id)' has unknown status '$($control.Status)'." + } + if ($control.Status -ne 'Aligned' -and + [string]::IsNullOrWhiteSpace([string]$control.Rationale)) { + throw "Guide control '$($control.Id)' requires a rationale for status '$($control.Status)'." + } + foreach ($actionId in @($control.ActionIds)) { + if (-not $actionIds.Contains([string]$actionId)) { + throw "Guide control '$($control.Id)' references undefined action '$actionId'." + } + } + } +} + +function Get-PurviewDeploymentPlanModel { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [System.Collections.IDictionary] $Config, + + [Parameter(Mandatory)] + [string] $ConfigPath, + + [Parameter()] + [System.Collections.IDictionary] $Parameters, + + [Parameter(Mandatory)] + [System.Collections.IDictionary] $GuideMapping, + + [Parameter()] + [object[]] $SupportingGuideMappings = @(), + + [Parameter()] + [string] $ScriptVersion = 'unknown', + + [Parameter()] + [guid] $PlanId = [guid]::NewGuid(), + + [Parameter()] + [string] $PlanReference, + + [Parameter()] + [datetime] $GeneratedAt = [datetime]::UtcNow + ) + + if (-not (Test-Path -LiteralPath $ConfigPath -PathType Leaf)) { + throw "Configuration file not found: $ConfigPath" + } + Assert-PurviewLabelIdentityConfiguration -Config $Config + + $managedByTag = [string](Get-PurviewPlanValue $Config 'ManagedByTag' '') + if ([string]::IsNullOrWhiteSpace($managedByTag)) { + throw 'Configuration ManagedByTag must be a non-empty string.' + } + if ((Protect-PurviewPlanIntendedText $managedByTag) -ne $managedByTag) { + throw 'Configuration ManagedByTag must not contain tenant identities, GUIDs, URLs, secrets, or local paths.' + } + + $skipTenant = Test-PurviewPlanParameter -Parameters $Parameters -Name 'SkipTenantSettings' + $skipLabels = Test-PurviewPlanParameter -Parameters $Parameters -Name 'SkipLabels' + $skipDlp = Test-PurviewPlanParameter -Parameters $Parameters -Name 'SkipDLP' + $applyRetention = Test-PurviewPlanParameter -Parameters $Parameters -Name 'ApplyRetention' + $skipAi = Test-PurviewPlanParameter -Parameters $Parameters -Name 'SkipAIControls' + $skipContainers = Test-PurviewPlanParameter -Parameters $Parameters -Name 'SkipContainerLabels' + $premiumAudit = Test-PurviewPlanParameter -Parameters $Parameters -Name 'EnablePremiumAudit' + $coauthoring = Test-PurviewPlanParameter -Parameters $Parameters -Name 'EnableLabelCoAuthoring' + $bpOnly = Test-PurviewPlanParameter -Parameters $Parameters -Name 'BPOnly' + $noLicenseDetect = Test-PurviewPlanParameter -Parameters $Parameters -Name 'NoLicenseAutoDetect' + + $actions = [Collections.Generic.List[object]]::new() + $tenantSettings = if ($Config.Contains('TenantSettings') -and + $Config.TenantSettings -is [System.Collections.IDictionary]) { + $Config.TenantSettings + } else { + @{} + } + + $standardAuditIntent = if ($skipTenant) { + 'Excluded' + } elseif ($tenantSettings.Contains('EnableUnifiedAuditLog') -and + [bool]$tenantSettings.EnableUnifiedAuditLog) { + 'Included' + } else { + 'NotConfigured' + } + $actions.Add((New-PurviewPlanAction ` + -ActionId 'purview.tenant.audit-standard' ` + -Module 'Setup-TenantSettings' ` + -Title 'Standard audit logging' ` + -Intent $standardAuditIntent ` + -ConfigPath 'TenantSettings.EnableUnifiedAuditLog' ` + -Gate $(if ($skipTenant) { '-SkipTenantSettings' } else { 'Configuration value' }) ` + -Rationale 'Tracks user and administrator activity across Microsoft 365.' ` + -SafeDefault 'Included when configured.' ` + -TenantImpact 'May enable unified audit ingestion.')) + + $spoIntent = if ($skipTenant) { + 'Excluded' + } elseif ($tenantSettings.Contains('EnableAIPIntegrationInSPO') -and + [bool]$tenantSettings.EnableAIPIntegrationInSPO) { + 'Included' + } else { + 'NotConfigured' + } + $actions.Add((New-PurviewPlanAction ` + -ActionId 'purview.tenant.spo-labels' ` + -Module 'Setup-TenantSettings' ` + -Title 'Sensitivity-label support for SharePoint and OneDrive' ` + -Intent $spoIntent ` + -ConfigPath 'TenantSettings.EnableAIPIntegrationInSPO' ` + -Gate $(if ($skipTenant) { '-SkipTenantSettings' } else { 'Configuration value' }) ` + -Rationale 'Allows Purview to recognize labels on stored Office files.' ` + -SafeDefault 'Included when configured.' ` + -TenantImpact 'Changes a tenant-wide SharePoint setting.')) + + $pdfIntent = if ($skipTenant) { + 'Excluded' + } elseif ($tenantSettings.Contains('EnableSensitivityLabelForPDF') -and + [bool]$tenantSettings.EnableSensitivityLabelForPDF) { + 'Included' + } else { + 'NotConfigured' + } + $actions.Add((New-PurviewPlanAction ` + -ActionId 'purview.tenant.pdf-labels' ` + -Module 'Setup-TenantSettings' ` + -Title 'Sensitivity labels for PDF files' ` + -Intent $pdfIntent ` + -ConfigPath 'TenantSettings.EnableSensitivityLabelForPDF' ` + -Gate $(if ($skipTenant) { '-SkipTenantSettings' } else { 'Configuration value' }) ` + -Rationale 'Extends label handling to PDF content.' ` + -SafeDefault 'Configuration controlled.' ` + -TenantImpact 'Changes tenant-wide PDF label behavior.')) + + $containerDirectoryIntent = if ($skipTenant -or $skipContainers) { + 'Excluded' + } elseif ($bpOnly) { + 'Included' + } else { + 'Conditional' + } + $containerRuntimeCondition = if ($bpOnly) { + 'Business Premium includes the Entra ID P1 prerequisite for container labels.' + } elseif ($noLicenseDetect) { + 'Operator must confirm Entra ID P1 entitlement because auto-detection is disabled.' + } else { + 'Runtime license detection must confirm a recognized Business Premium, E5, or Purview Suite SKU.' + } + $actions.Add((New-PurviewPlanAction ` + -ActionId 'purview.tenant.container-directory-setting' ` + -Module 'Setup-TenantSettings' ` + -Title 'Container label directory setting' ` + -Intent $containerDirectoryIntent ` + -ConfigPath 'TenantSettings' ` + -Gate $(if ($skipContainers) { '-SkipContainerLabels' } elseif ($skipTenant) { '-SkipTenantSettings' } else { 'Default on' }) ` + -RuntimeCondition $containerRuntimeCondition ` + -Rationale 'Enables labels for Microsoft 365 groups and sites.' ` + -SafeDefault 'Default on with an explicit opt-out.' ` + -TenantImpact 'May enable Group.Unified EnableMIPLabels.')) + + $coauthorIntent = if ($skipTenant -or -not $coauthoring) { + 'Excluded' + } else { + 'Included' + } + $actions.Add((New-PurviewPlanAction ` + -ActionId 'purview.tenant.label-coauthoring' ` + -Module 'Setup-TenantSettings' ` + -Title 'Label co-authoring metadata format' ` + -Intent $coauthorIntent ` + -ConfigPath 'TenantSettings.EnableLabelCoAuth' ` + -Gate $(if ($coauthoring) { '-EnableLabelCoAuthoring' } else { 'Explicit opt-in not selected' }) ` + -Rationale 'Enables co-authoring behavior for encrypted labeled Office files.' ` + -SafeDefault 'Excluded because the tenant switch has one-way compatibility effects.' ` + -TenantImpact 'Changes the tenant-wide sensitivity-label metadata format.')) + + $premiumIntent = if ($skipTenant -or -not $premiumAudit) { + 'Excluded' + } elseif ($bpOnly) { + 'Excluded' + } else { + 'Conditional' + } + $actions.Add((New-PurviewPlanAction ` + -ActionId 'purview.tenant.audit-premium' ` + -Module 'Setup-TenantSettings' ` + -Title 'Premium Audit mailbox events' ` + -Intent $premiumIntent ` + -ConfigPath 'TenantSettings' ` + -Gate $(if ($premiumAudit) { '-EnablePremiumAudit' } else { 'Explicit opt-in not selected' }) ` + -RuntimeCondition 'Requires Microsoft 365 E5 or Audit Premium entitlement.' ` + -Rationale 'Adds SearchQueryInitiated to selected mailbox audit settings.' ` + -SafeDefault 'Excluded unless explicitly requested.' ` + -TenantImpact 'Changes mailbox audit event selection.')) + + $labels = @( + if ($Config.Contains('Labels') -and $Config.Labels) { + $Config.Labels + } + ) + $subLabelCount = 0 + foreach ($label in $labels) { + if ($label -is [System.Collections.IDictionary] -and + $label.Contains('SubLabels') -and $label.SubLabels) { + $subLabelCount += @($label.SubLabels).Count + } + } + $labelsIntent = if ($skipLabels) { + 'Excluded' + } elseif ($labels.Count -gt 0) { + 'Included' + } else { + 'NotConfigured' + } + $actions.Add((New-PurviewPlanAction ` + -ActionId 'purview.labels.taxonomy' ` + -Module 'Setup-SensitivityLabels' ` + -Title "Sensitivity-label taxonomy ($($labels.Count) roots, $subLabelCount sub-labels)" ` + -Intent $labelsIntent ` + -ConfigPath 'Labels' ` + -Gate $(if ($skipLabels) { '-SkipLabels' } else { 'Configured label collection' }) ` + -Rationale 'Creates or reconciles the configured classification taxonomy.' ` + -SafeDefault 'Uses stable built-in signatures and managed-object collision checks.' ` + -TenantImpact 'Labels can appear in Office and Purview after publication.')) + + $actions.Add((New-PurviewPlanAction ` + -ActionId 'purview.labels.priority' ` + -Module 'Setup-SensitivityLabels' ` + -Title 'Sensitivity-label priority order' ` + -Intent $labelsIntent ` + -ConfigPath 'Labels' ` + -Gate $(if ($skipLabels) { '-SkipLabels' } else { 'Configured label order' }) ` + -Rationale 'Uses config order as the intended label priority.' ` + -SafeDefault 'Preserves the configured least-to-most-sensitive order.' ` + -TenantImpact 'May change label priority.')) + + $labelPolicy = if ($Config.Contains('LabelPolicy') -and + $Config.LabelPolicy -is [System.Collections.IDictionary]) { + $Config.LabelPolicy + } else { + @{} + } + $labelPolicyIntent = if ($skipLabels) { + 'Excluded' + } elseif ($labelPolicy.Count -gt 0) { + 'Included' + } else { + 'NotConfigured' + } + $actions.Add((New-PurviewPlanAction ` + -ActionId 'purview.labels.publish' ` + -Module 'Setup-SensitivityLabels' ` + -Title 'Publish labels and set defaults' ` + -Intent $labelPolicyIntent ` + -ConfigPath 'LabelPolicy' ` + -Gate $(if ($skipLabels) { '-SkipLabels' } else { 'Configured label policy' }) ` + -Rationale 'Publishes the configured label set and defaults.' ` + -SafeDefault 'Uses the configured policy scope and defaults.' ` + -TenantImpact 'Changes labels available to users and default labeling.')) + + $attachmentIntent = if ($skipLabels) { + 'Excluded' + } elseif ($labelPolicy.Contains('AttachmentAction')) { + 'Included' + } else { + 'NotConfigured' + } + $actions.Add((New-PurviewPlanAction ` + -ActionId 'purview.labels.attachment-inheritance' ` + -Module 'Setup-SensitivityLabels' ` + -Title 'Email label inheritance from attachments' ` + -Intent $attachmentIntent ` + -ConfigPath 'LabelPolicy.AttachmentAction' ` + -Gate $(if ($skipLabels) { '-SkipLabels' } else { 'Configuration value' }) ` + -Rationale 'Applies the configured attachment-based label behavior to email.' ` + -SafeDefault 'Configuration controlled.' ` + -TenantImpact 'May cause email to inherit a sensitivity label.')) + + $contentMarkIntent = if ($skipLabels) { + 'Excluded' + } elseif ($Config.Contains('EnableContentMarking') -and + [bool]$Config.EnableContentMarking) { + 'Included' + } else { + 'Excluded' + } + $actions.Add((New-PurviewPlanAction ` + -ActionId 'purview.labels.content-marking' ` + -Module 'Setup-SensitivityLabels' ` + -Title 'Label content marking' ` + -Intent $contentMarkIntent ` + -ConfigPath 'EnableContentMarking' ` + -Gate $(if ($skipLabels) { '-SkipLabels' } else { 'Configuration value' }) ` + -Rationale 'Applies configured headers, footers, or watermarks.' ` + -SafeDefault 'Disabled in the shipped configuration.' ` + -TenantImpact 'May add visible markings to labeled documents.')) + + $encryptedLabels = @( + foreach ($label in $labels) { + if ($label -isnot [System.Collections.IDictionary]) { continue } + if ($label.Contains('Encrypt') -and [bool]$label.Encrypt) { $label } + if ($label.Contains('SubLabels') -and $label.SubLabels) { + foreach ($subLabel in @($label.SubLabels)) { + if ($subLabel -is [System.Collections.IDictionary] -and + $subLabel.Contains('Encrypt') -and [bool]$subLabel.Encrypt) { + $subLabel + } + } + } + } + ) + $encryptionIntent = if ($skipLabels) { + 'Excluded' + } elseif ($encryptedLabels.Count -gt 0) { + 'Included' + } else { + 'NotConfigured' + } + $actions.Add((New-PurviewPlanAction ` + -ActionId 'purview.labels.encryption' ` + -Module 'Setup-SensitivityLabels' ` + -Title "Label encryption ($($encryptedLabels.Count) configured labels)" ` + -Intent $encryptionIntent ` + -ConfigPath 'Labels[].Encrypt' ` + -Gate $(if ($skipLabels) { '-SkipLabels' } else { 'Configured label protection' }) ` + -RuntimeCondition 'Tenant-scoped rights resolve only after tenant identity verification.' ` + -Rationale 'Protects configured high-sensitivity labels.' ` + -SafeDefault 'Tenant-bound rights fail closed if identity cannot be resolved.' ` + -TenantImpact 'May restrict access to labeled content.')) + + $containerScopeIntent = if ($skipLabels -or $skipContainers) { + 'Excluded' + } elseif ($labels.Count -gt 0) { + if ($bpOnly) { 'Included' } else { 'Conditional' } + } else { + 'NotConfigured' + } + $actions.Add((New-PurviewPlanAction ` + -ActionId 'purview.labels.container-scope' ` + -Module 'Setup-SensitivityLabels' ` + -Title 'Container scope on published labels' ` + -Intent $containerScopeIntent ` + -ConfigPath 'Labels[].ContentType' ` + -Gate $(if ($skipContainers) { '-SkipContainerLabels' } elseif ($skipLabels) { '-SkipLabels' } else { 'Default on' }) ` + -RuntimeCondition $containerRuntimeCondition ` + -Rationale 'Makes selected labels available for sites and Microsoft 365 groups.' ` + -SafeDefault 'Default on with an explicit opt-out.' ` + -TenantImpact 'May expose labels in container scope pickers.')) + + $dlpPolicies = @( + if ($Config.Contains('DlpPolicies') -and $Config.DlpPolicies) { + $Config.DlpPolicies + } + ) + $baselineDlp = @( + @{ Workload = 'Exchange'; ActionId = 'purview.dlp.exchange'; Title = 'Exchange DLP policy'; ConfigPath = 'DlpPolicies[Workload=Exchange]' } + @{ Workload = 'SharePointOneDrive'; ActionId = 'purview.dlp.sharepoint-onedrive'; Title = 'SharePoint and OneDrive DLP policy'; ConfigPath = 'DlpPolicies[Workload=SharePointOneDrive]' } + @{ Workload = 'Endpoint'; ActionId = 'purview.dlp.endpoint'; Title = 'Endpoint DLP policy'; ConfigPath = 'DlpPolicies[Workload=Endpoint]' } + ) + $dlpSimulationConfigured = [bool](Get-PurviewPlanValue $Config 'DlpStartInSimulation' $true) + foreach ($definition in $baselineDlp) { + $policyMatches = @( + $dlpPolicies | + Where-Object { + (Get-PurviewPlanWorkload -Policy $_) -eq $definition.Workload + } + ) + $intent = if ($skipDlp) { + 'Excluded' + } elseif ($policyMatches.Count -eq 0) { + 'NotConfigured' + } elseif ($definition.Workload -eq 'Endpoint' -and $bpOnly) { + 'Excluded' + } elseif ($definition.Workload -eq 'Endpoint') { + 'Conditional' + } else { + 'Included' + } + $condition = if ($definition.Workload -eq 'Endpoint') { + if ($noLicenseDetect) { + 'Operator must confirm E5 or Microsoft Purview Suite entitlement because auto-detection is disabled.' + } else { + 'Runtime license detection must confirm E5 or Microsoft Purview Suite entitlement.' + } + } else { + 'Requires the workload permissions and service availability documented for DLP.' + } + $actions.Add((New-PurviewPlanAction ` + -ActionId $definition.ActionId ` + -Module 'Setup-DLP' ` + -Title $definition.Title ` + -Intent $intent ` + -ConfigPath $definition.ConfigPath ` + -Gate $(if ($skipDlp) { '-SkipDLP' } elseif ($bpOnly -and $definition.Workload -eq 'Endpoint') { '-BPOnly' } else { 'Configured policy' }) ` + -RuntimeCondition $condition ` + -Rationale 'Uses configured label-based conditions and workload scope.' ` + -SafeDefault $(if ($dlpSimulationConfigured) { 'Simulation mode is configured.' } else { 'Enforcement mode is configured or simulation is not configured.' }) ` + -TenantImpact 'May create or reconcile a DLP policy after tenant state is read.')) + } + + $customIndex = 0 + $policyIndex = -1 + foreach ($policy in $dlpPolicies) { + $policyIndex++ + $workload = Get-PurviewPlanWorkload -Policy $policy + if ($workload -in @('Exchange', 'SharePointOneDrive', 'Endpoint')) { + continue + } + $customIndex++ + $customIntent = 'Excluded' + $actions.Add((New-PurviewPlanAction ` + -ActionId "purview.dlp.custom-$customIndex" ` + -Module 'Setup-DLP' ` + -Title "Additional configured DLP policy $customIndex" ` + -Intent $customIntent ` + -ConfigPath "DlpPolicies[$policyIndex]" ` + -Gate $(if ($skipDlp) { '-SkipDLP' } else { 'Unsupported by Setup-DLP' }) ` + -RuntimeCondition 'Setup-DLP supports only Exchange, SharePointOneDrive, and Endpoint workloads.' ` + -Rationale 'The configured workload is outside the current Setup-DLP implementation and would otherwise terminate that module.' ` + -SafeDefault 'Excluded because no supported deployment path exists.' ` + -TenantImpact 'No tenant change is planned for this unsupported workload.')) + } + + $retentionLocations = Get-PurviewRetentionLocationClassification -Value $( + if ($Config.Contains('Retention') -and $Config.Retention) { + Get-PurviewPlanValue $Config.Retention 'Locations' @() + } else { + @() + } + ) + $retentionIntent = if (-not $Config.Contains('Retention') -or -not $Config.Retention) { + 'NotConfigured' + } elseif (-not $applyRetention) { + 'Excluded' + } elseif ($retentionLocations.Supported.Count -eq 0) { + 'Excluded' + } else { + 'Included' + } + $actions.Add((New-PurviewPlanAction ` + -ActionId 'purview.retention.exchange' ` + -Module 'Setup-Retention' ` + -Title 'Exchange retention policy' ` + -Intent $retentionIntent ` + -ConfigPath 'Retention' ` + -Gate $(if (-not $applyRetention) { + 'Explicit opt-in not selected' + } elseif ($retentionLocations.Supported.Count -eq 0) { + 'No supported Retention.Locations' + } else { + '-ApplyRetention' + }) ` + -Rationale $(if ($applyRetention -and + $retentionLocations.Supported.Count -eq 0 -and + $retentionLocations.Unsupported.Count -gt 0) { + 'Configured retention destinations are unsupported, so no deployable retention action is planned.' + } elseif ($applyRetention -and $retentionLocations.Supported.Count -eq 0) { + 'No retention destinations are configured, so no deployable retention action is planned.' + } else { + 'Applies the configured retain-then-delete policy only after explicit opt-in.' + }) ` + -SafeDefault $(if ($applyRetention -and $retentionLocations.Supported.Count -eq 0) { + 'Excluded because creating a locationless retention policy is unsafe.' + } else { + 'Excluded because retention can be destructive and difficult to reverse.' + }) ` + -TenantImpact $(if ($retentionLocations.Supported.Count -gt 0) { + 'May retain and later delete content in the supported configured locations.' + } else { + 'No tenant change is planned until a supported retention location is configured.' + }))) + + $aiPolicies = @( + if ($Config.Contains('AIGovernance') -and + $Config.AIGovernance -is [System.Collections.IDictionary] -and + $Config.AIGovernance.Contains('DlpPolicies')) { + $Config.AIGovernance.DlpPolicies + } + ) + $aiIntent = if ($skipAi -or $bpOnly) { + 'Excluded' + } elseif ($aiPolicies.Count -eq 0) { + 'NotConfigured' + } else { + 'Conditional' + } + $aiCondition = if ($noLicenseDetect) { + 'Operator must confirm E5 or Microsoft Purview Suite entitlement because auto-detection is disabled.' + } else { + 'Runtime license detection must confirm E5 or Microsoft Purview Suite entitlement.' + } + $actions.Add((New-PurviewPlanAction ` + -ActionId 'purview.ai.copilot-dlp' ` + -Module 'Setup-AIGovernance' ` + -Title "Copilot DLP policies ($($aiPolicies.Count) configured)" ` + -Intent $aiIntent ` + -ConfigPath 'AIGovernance.DlpPolicies' ` + -Gate $(if ($skipAi) { '-SkipAIControls' } elseif ($bpOnly) { '-BPOnly' } else { 'Default on when entitled' }) ` + -RuntimeCondition $aiCondition ` + -Rationale 'Restricts Copilot grounding on configured high-sensitivity content.' ` + -SafeDefault 'License-gated and subject to simulation configuration.' ` + -TenantImpact 'May create or reconcile Microsoft 365 Copilot DLP policies.')) + + Test-PurviewDeploymentPlanMapping ` + -GuideMapping $GuideMapping ` + -Actions $actions ` + -ExpectedRole 'Primary' + foreach ($supportingMapping in $SupportingGuideMappings) { + if ($supportingMapping -isnot [System.Collections.IDictionary]) { + throw 'Supporting guide mappings must be dictionaries.' + } + Test-PurviewDeploymentPlanMapping ` + -GuideMapping $supportingMapping ` + -Actions $actions ` + -ExpectedRole 'Supporting' + } + + $controlsByAction = @{} + foreach ($control in @($GuideMapping.Controls)) { + foreach ($actionId in @($control.ActionIds)) { + if (-not $controlsByAction.ContainsKey([string]$actionId)) { + $controlsByAction[[string]$actionId] = [Collections.Generic.List[object]]::new() + } + $controlsByAction[[string]$actionId].Add($control) + } + } + + foreach ($action in $actions) { + if (-not $controlsByAction.ContainsKey($action.ActionId)) { + continue + } + $mappedControls = @($controlsByAction[$action.ActionId]) + $action.GuideControlIds = @($mappedControls | ForEach-Object { [string]$_.Id } | Sort-Object) + $action.PrimaryGuideRecommendations = @( + $mappedControls | + Sort-Object @{ + Expression = { + switch ([string]$_.Level) { + 'Good' { 1 } + 'Better' { 2 } + 'Best' { 3 } + } + } + }, Id | + ForEach-Object { + [pscustomobject][ordered]@{ + ControlId = [string]$_.Id + Level = [string]$_.Level + Summary = [string]$_.Summary + } + } + ) + $mappedLevels = @( + $mappedControls | + ForEach-Object { [string]$_.Level } | + Select-Object -Unique + ) + $action.PrimaryGuideLevel = if ($mappedLevels.Count -eq 1) { + [string]$mappedLevels[0] + } elseif ($mappedLevels.Count -gt 1) { + $mappedLevels -join ', ' + } else { + 'Extension' + } + $action.ComparisonStatus = Get-PurviewPlanComparisonStatus -Controls $mappedControls + } + + foreach ($supportingMapping in $SupportingGuideMappings) { + $supportingControlsByAction = @{} + foreach ($control in @($supportingMapping.Controls)) { + foreach ($actionId in @($control.ActionIds)) { + if (-not $supportingControlsByAction.ContainsKey([string]$actionId)) { + $supportingControlsByAction[[string]$actionId] = + [Collections.Generic.List[object]]::new() + } + $supportingControlsByAction[[string]$actionId].Add($control) + } + } + foreach ($action in $actions) { + if (-not $supportingControlsByAction.ContainsKey($action.ActionId)) { + continue + } + $mappedControls = @($supportingControlsByAction[$action.ActionId]) + $action.SupportingGuideMappings = @( + @($action.SupportingGuideMappings) + + [pscustomobject][ordered]@{ + GuideId = [string]$supportingMapping.Guide.Id + GuideTitle = [string]$supportingMapping.Guide.Title + ControlIds = @( + $mappedControls | + ForEach-Object { [string]$_.Id } | + Sort-Object + ) + ComparisonStatus = Get-PurviewPlanComparisonStatus -Controls $mappedControls + } + ) + $action.SupportingGuideMappings = @( + $action.SupportingGuideMappings | + ForEach-Object { + $_.ControlIds = @($_.ControlIds | Sort-Object -Unique) + $_ + } | + Sort-Object GuideId + ) + } + } + + $moduleOrder = @( + 'Setup-TenantSettings', + 'Setup-SensitivityLabels', + 'Setup-DLP', + 'Setup-Retention', + 'Setup-AIGovernance' + ) + $moduleSummaries = foreach ($module in $moduleOrder) { + $moduleActions = @($actions | Where-Object Module -eq $module) + $intent = if (@($moduleActions | Where-Object Intent -eq 'Included').Count -gt 0) { + 'Included' + } elseif (@($moduleActions | Where-Object Intent -eq 'Conditional').Count -gt 0) { + 'Conditional' + } elseif (@($moduleActions | Where-Object Intent -eq 'Excluded').Count -gt 0) { + 'Excluded' + } else { + 'NotConfigured' + } + [pscustomobject][ordered]@{ + Module = $module + Intent = $intent + Included = @($moduleActions | Where-Object Intent -eq 'Included').Count + Conditional = @($moduleActions | Where-Object Intent -eq 'Conditional').Count + Excluded = @($moduleActions | Where-Object Intent -eq 'Excluded').Count + NotConfigured = @($moduleActions | Where-Object Intent -eq 'NotConfigured').Count + } + } + + $actionsById = @{} + foreach ($action in $actions) { + $actionsById[$action.ActionId] = $action + } + $guideControls = foreach ($control in @($GuideMapping.Controls)) { + $related = @( + foreach ($actionId in @($control.ActionIds)) { + if ($actionsById.ContainsKey([string]$actionId)) { + $actionsById[[string]$actionId] + } + } + ) + $intent = if ($related.Count -eq 0) { + 'NotConfigured' + } elseif (@($related | Where-Object Intent -eq 'Included').Count -gt 0) { + 'Included' + } elseif (@($related | Where-Object Intent -eq 'Conditional').Count -gt 0) { + 'Conditional' + } elseif (@($related | Where-Object Intent -eq 'Excluded').Count -gt 0) { + 'Excluded' + } else { + 'NotConfigured' + } + [pscustomobject][ordered]@{ + ControlId = [string]$control.Id + Level = [string]$control.Level + Section = [string]$control.Section + SourceSection = [string]$control.SourceSection + Summary = [string]$control.Summary + ComparisonStatus = [string]$control.Status + Intent = $intent + ActionIds = @($control.ActionIds | Sort-Object -Unique) + ConfigPaths = @($control.ConfigPaths | Sort-Object -Unique) + Rationale = [string]$control.Rationale + ReferenceUris = @($control.ReferenceUris | Sort-Object -Unique) + } + } + + $supportingGuides = foreach ($supportingMapping in @( + $SupportingGuideMappings | Sort-Object { [string]$_.Guide.Id } + )) { + $supportingControls = foreach ($control in @( + $supportingMapping.Controls | Sort-Object { [string]$_.Id } + )) { + $related = @( + foreach ($actionId in @($control.ActionIds)) { + if ($actionsById.ContainsKey([string]$actionId)) { + $actionsById[[string]$actionId] + } + } + ) + $intent = if ($related.Count -eq 0) { + 'NotConfigured' + } elseif (@($related | Where-Object Intent -eq 'Included').Count -gt 0) { + 'Included' + } elseif (@($related | Where-Object Intent -eq 'Conditional').Count -gt 0) { + 'Conditional' + } elseif (@($related | Where-Object Intent -eq 'Excluded').Count -gt 0) { + 'Excluded' + } else { + 'NotConfigured' + } + [pscustomobject][ordered]@{ + ControlId = [string]$control.Id + Section = [string]$control.Section + Summary = [string]$control.Summary + ComparisonStatus = [string]$control.Status + Intent = $intent + ActionIds = @($control.ActionIds | Sort-Object -Unique) + ConfigPaths = @($control.ConfigPaths | Sort-Object -Unique) + Rationale = [string]$control.Rationale + ReferenceUris = @($control.ReferenceUris | Sort-Object -Unique) + } + } + [pscustomobject][ordered]@{ + Guide = [pscustomobject][ordered]@{ + Id = [string]$supportingMapping.Guide.Id + Title = [string]$supportingMapping.Guide.Title + Edition = [string]$supportingMapping.Guide.Edition + RevisionDate = [string]$supportingMapping.Guide.RevisionDate + VerifiedDate = [string]$supportingMapping.Guide.VerifiedDate + Publisher = [string]$supportingMapping.Guide.Publisher + SourceClassification = [string]$supportingMapping.Guide.SourceClassification + SourceUri = [string]$supportingMapping.Guide.SourceUri + SourceCommit = [string]$supportingMapping.Guide.SourceCommit + Owner = [string]$supportingMapping.Guide.Owner + } + GuideControls = @($supportingControls | Sort-Object ControlId) + } + } + + $effectiveParameters = [ordered]@{} + foreach ($name in @( + 'SkipTenantSettings', 'SkipLabels', 'SkipDLP', 'ApplyRetention', + 'SkipAIControls', 'SkipContainerLabels', 'EnablePremiumAudit', + 'AdoptExisting', 'EnableLabelCoAuthoring', 'NonInteractive', + 'AutoInstallModules', 'BPOnly', 'NoLicenseAutoDetect' + )) { + $effectiveParameters[$name] = Test-PurviewPlanParameter -Parameters $Parameters -Name $name + } + + $premiumAuditMailboxCount = 0 + if ($premiumAudit -and $Parameters -and + $Parameters.Keys -contains 'PremiumAuditMailbox') { + $premiumAuditMailboxCount = @( + @($Parameters['PremiumAuditMailbox']) | + Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } + ).Count + } + $intendedState = Get-PurviewPlanIntendedState ` + -Config $Config ` + -Actions $actions ` + -SkipContainerLabels $skipContainers ` + -PremiumAuditMailboxCount $premiumAuditMailboxCount + + $intendedKeysByAction = @{} + function AddIntendedKey([string] $ActionId, [string] $Key) { + if (-not $intendedKeysByAction.ContainsKey($ActionId)) { + $intendedKeysByAction[$ActionId] = [Collections.Generic.List[string]]::new() + } + if (-not $intendedKeysByAction[$ActionId].Contains($Key)) { + $intendedKeysByAction[$ActionId].Add($Key) + } + } + foreach ($item in $intendedState.TenantSettings) { + AddIntendedKey -ActionId $item.ActionId -Key $item.Key + } + foreach ($item in $intendedState.Labels) { + foreach ($actionId in @( + 'purview.labels.taxonomy', + 'purview.labels.priority', + 'purview.labels.content-marking', + 'purview.labels.encryption', + 'purview.labels.container-scope' + )) { + AddIntendedKey -ActionId $actionId -Key "labels/$($item.Key)" + } + } + foreach ($actionId in @( + 'purview.labels.publish', + 'purview.labels.attachment-inheritance' + )) { + AddIntendedKey -ActionId $actionId -Key $intendedState.LabelPolicy.Key + } + foreach ($item in $intendedState.DlpPolicies) { + AddIntendedKey -ActionId $item.ActionId -Key $item.Key + } + AddIntendedKey -ActionId $intendedState.Retention.ActionId -Key $intendedState.Retention.Key + AddIntendedKey -ActionId $intendedState.Encryption.ActionId -Key $intendedState.Encryption.Key + foreach ($item in $intendedState.AIGovernance) { + AddIntendedKey -ActionId $item.ActionId -Key $item.Key + } + foreach ($action in $actions) { + $action.IntendedStateKeys = if ($intendedKeysByAction.ContainsKey($action.ActionId)) { + @($intendedKeysByAction[$action.ActionId] | Sort-Object) + } else { + @() + } + } + $intendedStateJson = $intendedState | ConvertTo-Json -Depth 20 -Compress + $intendedStateFingerprint = Get-PurviewPlanSha256 -Text $intendedStateJson + + $configHash = (Get-FileHash -LiteralPath $ConfigPath -Algorithm SHA256).Hash.ToLowerInvariant() + $defaultConfigPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'Config\PurviewConfig.psd1' + $configIdentity = if ( + [IO.Path]::GetFullPath($ConfigPath) -eq [IO.Path]::GetFullPath($defaultConfigPath) + ) { + 'BuiltInDefault' + } else { + 'OperatorProvided' + } + $resolvedPlanReference = if ([string]::IsNullOrWhiteSpace($PlanReference)) { + New-PurviewPlanReference -PlanId $PlanId -GeneratedAt $GeneratedAt + } else { + $PlanReference + } + if ($resolvedPlanReference -notmatch '^PUR-\d{8}-\d{6}-[0-9A-F]{8}$') { + throw "Plan reference '$resolvedPlanReference' is not valid." + } + + $fingerprintInput = [ordered]@{ + Product = 'Purview' + ManagedByTag = $managedByTag + EffectiveParameters = $effectiveParameters + IntendedState = $intendedState + Guide = [ordered]@{ + Id = [string]$GuideMapping.Guide.Id + Edition = [string]$GuideMapping.Guide.Edition + RevisionDate = [string]$GuideMapping.Guide.RevisionDate + SourceModifiedDate = [string]$GuideMapping.Guide.SourceModifiedDate + Publisher = [string]$GuideMapping.Guide.Publisher + SourceClassification = [string]$GuideMapping.Guide.SourceClassification + SourceFileName = [string]$GuideMapping.Guide.SourceFileName + Levels = @( + $GuideMapping.Guide.Levels | + Sort-Object { + switch ([string]$_.Id) { + 'Good' { 1 } + 'Better' { 2 } + 'Best' { 3 } + } + } | + ForEach-Object { + [pscustomobject][ordered]@{ + Id = [string]$_.Id + Title = [string]$_.Title + Summary = [string]$_.Summary + Inherits = @($_.Inherits | Sort-Object -Unique) + } + } + ) + } + SupportingGuides = @( + $supportingGuides | + Sort-Object { $_.Guide.Id } | + ForEach-Object { + [pscustomobject][ordered]@{ + Guide = [pscustomobject][ordered]@{ + Id = [string]$_.Guide.Id + Edition = [string]$_.Guide.Edition + RevisionDate = [string]$_.Guide.RevisionDate + Publisher = [string]$_.Guide.Publisher + SourceClassification = [string]$_.Guide.SourceClassification + SourceUri = [string]$_.Guide.SourceUri + SourceCommit = [string]$_.Guide.SourceCommit + } + GuideControls = @($_.GuideControls | Sort-Object ControlId) + } + } + ) + Modules = @($moduleSummaries) + Actions = @($actions | Sort-Object ActionId) + GuideControls = @($guideControls | Sort-Object ControlId) + } + $planFingerprint = Get-PurviewPlanSha256 -Text ( + $fingerprintInput | ConvertTo-Json -Depth 20 -Compress + ) + + return [pscustomobject][ordered]@{ + SchemaVersion = '1.2' + ArtifactType = 'PurviewDeploymentPlan' + Product = 'Purview' + ToolkitVersion = $ScriptVersion + ManagedByTag = $managedByTag + PlanId = $PlanId.ToString() + PlanReference = $resolvedPlanReference + GeneratedAtUtc = $GeneratedAt.ToUniversalTime().ToString('o') + IntentOnly = $true + Disclaimer = 'This offline plan describes configuration intent only. It does not inspect tenant state, predict changes, or declare compliance.' + Configuration = [pscustomobject][ordered]@{ + Identity = $configIdentity + Sha256 = $configHash + } + PlanInputSha256 = $planFingerprint + IntendedStateSha256 = $intendedStateFingerprint + IntendedState = $intendedState + Handoff = [pscustomobject][ordered]@{ + MinimumValidatorSchema = '1.0' + Capabilities = @( + 'IntendedState', + 'ManagedOwnership', + 'PublicationMode', + 'EncryptionSettings', + 'OpaquePrincipalDigests', + 'CanonicalPublicLocations', + 'OpaqueLocationDigests', + 'RetentionLocationDisposition', + 'MultipleRecordsPerAction' + ) + } + EffectiveParameters = [pscustomobject]$effectiveParameters + Guide = [pscustomobject][ordered]@{ + Id = [string]$GuideMapping.Guide.Id + Title = [string]$GuideMapping.Guide.Title + Edition = [string]$GuideMapping.Guide.Edition + RevisionDate = [string]$GuideMapping.Guide.RevisionDate + SourceModifiedDate = [string]$GuideMapping.Guide.SourceModifiedDate + VerifiedDate = [string]$GuideMapping.Guide.VerifiedDate + Publisher = [string]$GuideMapping.Guide.Publisher + SourceClassification = [string]$GuideMapping.Guide.SourceClassification + SourceFileName = [string]$GuideMapping.Guide.SourceFileName + SourceUri = '' + SourceCommit = '' + Owner = [string]$GuideMapping.Guide.Owner + Levels = @( + $GuideMapping.Guide.Levels | + Sort-Object { + switch ([string]$_.Id) { + 'Good' { 1 } + 'Better' { 2 } + 'Best' { 3 } + } + } | + ForEach-Object { + [pscustomobject][ordered]@{ + Id = [string]$_.Id + Title = [string]$_.Title + Summary = [string]$_.Summary + Inherits = @($_.Inherits | Sort-Object -Unique) + } + } + ) + } + Modules = @($moduleSummaries) + Actions = @($actions) + GuideControls = @($guideControls) + SupportingGuides = @($supportingGuides) + Diagnostics = @($intendedState.Diagnostics) + } +} + +function ConvertTo-PurviewDeploymentPlanHtml { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [psobject] $Model + ) + + function Encode([object] $Value) { + return [Net.WebUtility]::HtmlEncode([string]$Value) + } + + $includedCount = @($Model.Actions | Where-Object Intent -eq 'Included').Count + $conditionalCount = @($Model.Actions | Where-Object Intent -eq 'Conditional').Count + $excludedCount = @($Model.Actions | Where-Object Intent -eq 'Excluded').Count + $notConfiguredCount = @($Model.Actions | Where-Object Intent -eq 'NotConfigured').Count + $selectedCount = $includedCount + $conditionalCount + $mappedCount = @($Model.Actions | Where-Object PrimaryGuideLevel -ne 'Extension').Count + $intendedStateJson = $Model.IntendedState | ConvertTo-Json -Depth 20 + + $decisionActionIds = @( + 'purview.dlp.exchange', + 'purview.retention.exchange', + 'purview.tenant.label-coauthoring', + 'purview.dlp.endpoint' + ) + $decisionActions = @( + foreach ($actionId in $decisionActionIds) { + $Model.Actions | Where-Object ActionId -eq $actionId | Select-Object -First 1 + } + ) + + $sb = [Text.StringBuilder]::new() + $null = $sb.AppendLine('') + $null = $sb.AppendLine('') + $null = $sb.AppendLine('') + $null = $sb.AppendLine('Microsoft Purview Deployment Plan') + $null = $sb.AppendLine(@' + + +'@) + $null = $sb.AppendLine('
') + $null = $sb.AppendLine('
') + $null = $sb.AppendLine('

Pre-connection intent artifact

') + $null = $sb.AppendLine('

Microsoft Purview Deployment Plan

') + $null = $sb.AppendLine('

Review configuration scope, safety gates, and guide alignment before authentication.

') + $null = $sb.AppendLine(('
Intent only

{0}

' -f (Encode $Model.Disclaimer))) + $null = $sb.AppendLine('
') + $null = $sb.AppendLine(('Plan reference {0}' -f (Encode $Model.PlanReference))) + $null = $sb.AppendLine(('Generated {0}' -f (Encode $Model.GeneratedAtUtc))) + $null = $sb.AppendLine(('Source {0}, {1}' -f (Encode $Model.Guide.Title), (Encode $Model.Guide.Edition))) + $null = $sb.AppendLine('
') + + $null = $sb.AppendLine('

At a glance

Configuration-derived action intent

') + foreach ($metric in @( + @($selectedCount, 'Included or conditional actions'), + @($conditionalCount, 'Runtime-dependent actions'), + @(($excludedCount + $notConfiguredCount), 'Excluded or not configured'), + @($mappedCount, 'Actions mapped to the SMB guide') + )) { + $null = $sb.AppendLine(('
{0}{1}
' -f (Encode $metric[1]), $metric[0])) + } + $null = $sb.AppendLine('
') + + $null = $sb.AppendLine('

Key configuration decisions

Review before sign-in

') + foreach ($action in $decisionActions) { + if ($null -eq $action) { continue } + $null = $sb.AppendLine(('
{1}

{2}

{3}

Gate: {4}

' -f ` + (Encode $action.Intent), (Encode (Get-PurviewPlanStatusLabel $action.Intent)), (Encode $action.Title), ` + (Encode $action.SafeDefault), (Encode $action.Gate))) + } + $null = $sb.AppendLine('
') + + $null = $sb.AppendLine('

Module intent

Counts come from the action records below

') + foreach ($module in $Model.Modules) { + $null = $sb.AppendLine(('
{1}

{2}

{3} included, {4} conditional, {5} excluded, {6} not configured

' -f ` + (Encode $module.Intent), (Encode (Get-PurviewPlanStatusLabel $module.Intent)), (Encode $module.Module), ` + $module.Included, $module.Conditional, $module.Excluded, $module.NotConfigured)) + } + $null = $sb.AppendLine('
') + + $null = $sb.AppendLine('

Action Preview

Filter by deployment intent

') + $null = $sb.AppendLine('
How to read the Action Preview
') + $null = $sb.AppendLine('

Intent

Intent describes what the current configuration and invocation parameters ask the toolkit to do. It does not describe existing tenant state or predict whether an object will be created or updated.

') + $null = $sb.AppendLine('
') + $null = $sb.AppendLine('') + $null = $sb.AppendLine('') + $null = $sb.AppendLine('') + $null = $sb.AppendLine('') + $null = $sb.AppendLine('
StatusMeaning
IncludedThe action is configured and in scope for this deployment. Runtime checks and -WhatIf still apply.
ConditionalThe action is in scope only if a runtime requirement is satisfied, such as licensing, service availability, or another prerequisite that cannot be verified offline.
ExcludedThe action will not run because it was disabled by a switch, blocked by a safety gate, or requires an explicit opt-in that was not selected.
Not configuredThe configuration does not contain the settings or objects needed to plan this action.
') + $null = $sb.AppendLine('

Deployment Priority Level

The level identifies the minimum priority where the action appears in the primary Data Security Deployment Guide. The priority levels are cumulative.

') + $null = $sb.AppendLine('
') + $null = $sb.AppendLine('') + $null = $sb.AppendLine('') + $null = $sb.AppendLine('') + $null = $sb.AppendLine('') + $null = $sb.AppendLine('
LevelMeaning
Priority 1 (Good)Foundational protection that establishes baseline visibility, classification, and data-loss controls.
Priority 2 (Better)Protection that builds on Priority 1. Completing Priority 2 assumes the applicable Priority 1 tasks are also addressed.
Priority 3 (Best)Advanced protection that builds on Priorities 1 and 2 and commonly requires Purview Suite capabilities or additional operational maturity.
Toolkit extensionA toolkit capability that the primary guide does not assign to a deployment priority. A supporting source such as Microsoft Learn may still recommend it.
') + $null = $sb.AppendLine('

Recommendation and comparison

The recommendation contains the stable primary-guide control ID and its normalized summary. Toolkit extension means that no primary-guide control is mapped to the action. The badge beneath the recommendation describes how toolkit coverage compares with the source.

') + $null = $sb.AppendLine('
') + $null = $sb.AppendLine('') + $null = $sb.AppendLine('') + $null = $sb.AppendLine('') + $null = $sb.AppendLine('') + $null = $sb.AppendLine('') + $null = $sb.AppendLine('
StatusMeaning
AlignedThe toolkit action directly implements the mapped recommendation at the level of intent described by the guide.
ConditionalThe toolkit supports some or all of the recommendation, but coverage depends on a prerequisite, explicit opt-in, licensing, or a documented implementation difference.
ExtendedThe action is a toolkit capability outside the primary guide''s deployment priorities.
Not implementedThe primary guide recommends the capability, but the toolkit does not currently provide an action that implements it. This normally appears in the guide-coverage tables.
Not applicableThe recommendation is intentionally outside the Purview toolkit''s product or automation boundary.
') + $null = $sb.AppendLine('

Read the columns together: Intent shows whether the current run includes the action; Deployment Priority Level shows where the primary guide introduces it; comparison shows how closely the toolkit represents that recommendation. None of these values is a tenant-compliance result.

') + $null = $sb.AppendLine('
') + $null = $sb.AppendLine('
') + foreach ($filter in @('All', 'Included', 'Conditional', 'Excluded', 'NotConfigured')) { + $active = if ($filter -eq 'All') { ' active' } else { '' } + $label = Get-PurviewPlanStatusLabel $filter + $null = $sb.AppendLine(('' -f $active, (Encode $filter), (Encode $label))) + } + $null = $sb.AppendLine('
') + foreach ($action in $Model.Actions) { + $recommendations = if (@($action.PrimaryGuideRecommendations).Count -gt 0) { + @($action.PrimaryGuideRecommendations | ForEach-Object { '{0}: {1}' -f $_.ControlId, $_.Summary }) -join '; ' + } else { + 'Toolkit extension' + } + $null = $sb.AppendLine(('' -f ` + (Encode $action.Intent), (Encode $action.ActionId), (Encode $action.Title), (Encode $action.Rationale), ` + (Encode $action.SafeDefault), (Encode $action.TenantImpact), (Encode $action.Module), ` + (Encode (Get-PurviewPlanStatusLabel $action.Intent)), (Encode (Get-PurviewPlanLevelLabel $action.PrimaryGuideLevel)), ` + (Encode $recommendations), (Encode $action.ComparisonStatus), ` + (Encode (Get-PurviewPlanStatusLabel $action.ComparisonStatus)), (Encode $action.Gate), ` + (Encode $action.RuntimeCondition))) + } + $null = $sb.AppendLine('
ActionModuleIntentDeployment Priority LevelRecommendationGate
{1}
{2}
Rationale and impact

{3}

Safe default: {4}
Tenant impact: {5}

{6}{7}{8}{9}
{11}
{12}
{13}
') + + $null = $sb.AppendLine('

Data Security Deployment Guide for Small Business

Recommendations are grouped by minimum level

') + foreach ($level in $Model.Guide.Levels) { + $levelControls = @($Model.GuideControls | Where-Object Level -eq $level.Id) + $inheritance = if (@($level.Inherits).Count -gt 0) { + ' Includes {0}.' -f (@($level.Inherits) -join ' and ') + } else { + '' + } + $null = $sb.AppendLine(('
{1} — {2} recommendations

{3}{4}

' -f ` + $(if ($level.Id -eq 'Good') { ' open' } else { '' }), (Encode (Get-PurviewPlanLevelLabel $level.Id)), $levelControls.Count, ` + (Encode $level.Summary), (Encode $inheritance))) + foreach ($control in $levelControls) { + $actionText = if (@($control.ActionIds).Count -gt 0) { @($control.ActionIds) -join ', ' } else { '(none)' } + $null = $sb.AppendLine(('' -f ` + (Encode $control.ControlId), (Encode $control.Section), (Encode $control.Summary), (Encode $control.SourceSection), ` + (Encode $control.ComparisonStatus), (Encode (Get-PurviewPlanStatusLabel $control.ComparisonStatus)), ` + (Encode $control.Rationale), (Encode $control.Intent), ` + (Encode (Get-PurviewPlanStatusLabel $control.Intent)), (Encode $actionText))) + } + $null = $sb.AppendLine('
ControlRecommendationComparisonPlan intentMapped actions
{0}
{1}
{2}
Source: {3}
{5}
{6}
{8}{9}
') + } + $null = $sb.AppendLine('
') + + if (@($Model.SupportingGuides).Count -gt 0) { + $null = $sb.AppendLine('

Supporting Microsoft Learn reference

Separate from the primary SMB guide

') + foreach ($supporting in $Model.SupportingGuides) { + $null = $sb.AppendLine(('
{0}, revision {1}

Guide ID: {2}. Classification: {3}. Verified: {4}.

Open the pinned source. Source commit: {6}. Comparison describes toolkit coverage, not tenant state.

' -f ` + (Encode $supporting.Guide.Title), (Encode $supporting.Guide.RevisionDate), ` + (Encode $supporting.Guide.Id), (Encode $supporting.Guide.SourceClassification), ` + (Encode $supporting.Guide.VerifiedDate), (Encode $supporting.Guide.SourceUri), ` + (Encode $supporting.Guide.SourceCommit))) + foreach ($control in $supporting.GuideControls) { + $null = $sb.AppendLine(('' -f ` + (Encode $control.ControlId), (Encode $control.Section), (Encode $control.Summary), ` + (Encode $control.ComparisonStatus), (Encode (Get-PurviewPlanStatusLabel $control.ComparisonStatus)), ` + (Encode $control.Intent), (Encode (Get-PurviewPlanStatusLabel $control.Intent)))) + } + $null = $sb.AppendLine('
ControlRecommendationComparisonPlan intent
{0}
{1}
{2}{4}{6}
') + } + $null = $sb.AppendLine('
') + } + + $null = $sb.AppendLine('

Intended-state snapshot

Sanitized comparison input for tenant validation

') + $null = $sb.AppendLine(('

This deterministic snapshot records the selected policy settings without tenant identity, resolved label GUIDs, mailbox UPNs, or local paths. SHA-256: {0}. The formatted block below is for reading; the fingerprint is computed over canonical compact JSON in the sidecar.

' -f (Encode $Model.IntendedStateSha256))) + $null = $sb.AppendLine('
Show normalized intended state
') + $null = $sb.AppendLine(('
{0}
' -f (Encode $intendedStateJson))) + $null = $sb.AppendLine('
') + + $null = $sb.AppendLine('

Validation handoff

Reserved for the later read-only validator

') + $null = $sb.AppendLine(('

Provide the matching JSON sidecar to the future validator with -PlanPath. It will read plan reference {0}, Plan ID {1}, plan-input fingerprint {2}, and intended-state fingerprint {3} from the artifact.

' -f ` + (Encode $Model.PlanReference), (Encode $Model.PlanId), ` + (Encode $Model.PlanInputSha256), (Encode $Model.IntendedStateSha256))) + $null = $sb.AppendLine('

This plan is not bound to a tenant. The validator must confirm tenant identity after authentication.

') + + $null = $sb.AppendLine('

Technical appendix

Identity, fingerprints, switches, and diagnostics

') + foreach ($item in @( + @('Artifact type', $Model.ArtifactType), + @('Schema version', $Model.SchemaVersion), + @('Toolkit version', $Model.ToolkitVersion), + @('Managed-object tag', $Model.ManagedByTag), + @('Plan ID', $Model.PlanId), + @('Plan reference', $Model.PlanReference), + @('Configuration source', $Model.Configuration.Identity), + @('Configuration SHA-256', $Model.Configuration.Sha256), + @('Plan input SHA-256', $Model.PlanInputSha256), + @('Intended state SHA-256', $Model.IntendedStateSha256), + @('Minimum validator schema', $Model.Handoff.MinimumValidatorSchema), + @('Handoff capabilities', (@($Model.Handoff.Capabilities) -join ', ')), + @('Primary guide ID', $Model.Guide.Id), + @('Primary guide edition', $Model.Guide.Edition), + @('Primary guide revision', $Model.Guide.RevisionDate), + @('Primary guide publisher', $Model.Guide.Publisher), + @('Primary guide source classification', $Model.Guide.SourceClassification), + @('Primary guide source file', $Model.Guide.SourceFileName), + @('Primary guide mapping owner', $Model.Guide.Owner), + @('Primary guide source modified', $Model.Guide.SourceModifiedDate), + @('Primary guide verified', $Model.Guide.VerifiedDate) + )) { + $null = $sb.AppendLine(('
{0}
{1}
' -f (Encode $item[0]), (Encode $item[1]))) + } + $null = $sb.AppendLine('

Effective switches

') + foreach ($property in $Model.EffectiveParameters.PSObject.Properties) { + $null = $sb.AppendLine(('' -f (Encode $property.Name), (Encode ([string]$property.Value).ToLowerInvariant()))) + } + $null = $sb.AppendLine('
SwitchSelected
-{0}{1}

Generation diagnostics

') + if (@($Model.Diagnostics).Count -eq 0) { + $null = $sb.AppendLine('

No generation diagnostics.

') + } else { + $null = $sb.AppendLine('
    ') + foreach ($diagnostic in $Model.Diagnostics) { + $null = $sb.AppendLine(('
  • {0}
  • ' -f (Encode $diagnostic))) + } + $null = $sb.AppendLine('
') + } + $null = $sb.AppendLine('
') + $null = $sb.AppendLine(('
Generated from local configuration only. Plan input SHA-256: {0}
' -f (Encode $Model.PlanInputSha256))) + $null = $sb.AppendLine(@' + +'@) + $null = $sb.AppendLine('
') + return $sb.ToString() +} + +function Write-PurviewDeploymentPlan { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [psobject] $Model, + + [Parameter(Mandatory)] + [string] $OutputPath + ) + + $extension = [IO.Path]::GetExtension($OutputPath) + if ($extension -notin @('.html', '.htm')) { + throw [NotSupportedException]::new( + "Deployment Plan output path must use an .html or .htm extension: $OutputPath" + ) + } + $jsonPath = [IO.Path]::ChangeExtension($OutputPath, '.json') + $resolvedHtmlPath = [IO.Path]::GetFullPath($OutputPath) + $resolvedJsonPath = [IO.Path]::GetFullPath($jsonPath) + if ($resolvedHtmlPath.Equals($resolvedJsonPath, [StringComparison]::OrdinalIgnoreCase)) { + throw 'Deployment Plan HTML and JSON output paths must be different.' + } + + $directory = Split-Path -Parent $OutputPath + if ($directory -and -not (Test-Path -LiteralPath $directory)) { + New-Item -ItemType Directory -Path $directory -Force -WhatIf:$false | Out-Null + } + + $html = ConvertTo-PurviewDeploymentPlanHtml -Model $Model + $json = $Model | ConvertTo-Json -Depth 20 + + Set-Content -LiteralPath $OutputPath -Value $html -Encoding UTF8 -NoNewline -WhatIf:$false + Set-Content -LiteralPath $jsonPath -Value $json -Encoding UTF8 -NoNewline -WhatIf:$false + + return [pscustomobject]@{ + HtmlPath = $OutputPath + JsonPath = $jsonPath + PlanId = $Model.PlanId + PlanReference = $Model.PlanReference + ConfigurationSha256 = $Model.Configuration.Sha256 + PlanInputSha256 = $Model.PlanInputSha256 + IntendedStateSha256 = $Model.IntendedStateSha256 + } +} diff --git a/Products/Purview/Modules/Write-PurviewHtmlReport.ps1 b/Products/Purview/Modules/Write-PurviewHtmlReport.ps1 index b965fa7..6bb7733 100644 --- a/Products/Purview/Modules/Write-PurviewHtmlReport.ps1 +++ b/Products/Purview/Modules/Write-PurviewHtmlReport.ps1 @@ -1,660 +1,665 @@ -#requires -Version 7.0 -<# -.SYNOPSIS - Renders a self-contained HTML deployment report for the Purview Best - Practice toolkit. - -.DESCRIPTION - Tier 1 report: task-status table + run metadata (tenant identity, - admin UPN, timestamps, parameters used, PS version, host, RunId). - Pure renderer -- inline CSS, no JS, no external assets, no network - calls. Output is a single .html file safe to forward over email. - - Tier 2 (future) will extend this with per-action sections fed from a - structured RunLog. The Tier 1 surface is designed to be stable so - Tier 2 is additive (new sections appended after the task table). - -.NOTES - Security: - * All user-supplied strings are HTML-escaped via [System.Net.WebUtility]::HtmlEncode. - * Parameters hashtable is filtered against a denylist - (*password*, *token*, *secret*, *credential*, *key*) before render - so a future param named e.g. -ClientSecret never lands in the - report. - * Caller controls the output path; no auto-overwrite logic here - beyond Set-Content's default replace. - - The function is intentionally idempotent: call it from the - orchestrator's finally block once, on success or on failure. -#> - -function Write-PurviewHtmlReport { - [CmdletBinding()] - param( - [Parameter(Mandatory)] [System.Collections.Specialized.OrderedDictionary] $Summary, - [Parameter(Mandatory)] [string] $OutputPath, - [Parameter(Mandatory)] [datetime] $StartTime, - [Parameter(Mandatory)] [datetime] $EndTime, - [Parameter(Mandatory)] [guid] $RunId, - - [Parameter()] [psobject] $TenantIdentity, - [Parameter()] [string] $TenantAdminUpn, - [Parameter()] [hashtable] $Parameters, - [Parameter()] [string] $ScriptVersion = 'unknown', - - # Tier 2: per-action decision-point log. Each entry is a hashtable - # with keys: Timestamp, Module, Action, Target, Status, Detail, - # Attempt (opt), ElapsedMs (opt). Pass the output of - # Get-PurviewRunLog from the orchestrator's finally block. - [Parameter()] [hashtable[]] $RunLog - ) - - # ----- helpers -------------------------------------------------------- - function _Encode([string]$s) { - if ($null -eq $s) { return '' } - return [System.Net.WebUtility]::HtmlEncode($s) - } - - function _FormatDuration([timespan]$span) { - if ($span.TotalHours -ge 1) { - return ('{0}h {1}m {2}s' -f [int]$span.TotalHours, $span.Minutes, $span.Seconds) - } elseif ($span.TotalMinutes -ge 1) { - return ('{0}m {1}s' -f [int]$span.TotalMinutes, $span.Seconds) - } else { - return ('{0}s' -f [int]$span.TotalSeconds) - } - } - - function _StatusClass([string]$value) { - switch -Wildcard ($value) { - 'OK' { 'status-ok' ; break } - 'Skipped*' { 'status-skipped' ; break } - 'FAILED:*' { 'status-failed' ; break } - default { 'status-other' } - } - } - - function _FilterParameters([hashtable]$params) { - if (-not $params) { return @{} } - $deny = @('*password*','*token*','*secret*','*credential*','*key*') - $clean = @{} - foreach ($k in $params.Keys) { - $hit = $false - foreach ($pattern in $deny) { - if ($k -like $pattern) { $hit = $true; break } - } - if ($hit) { - $clean[$k] = '*** (redacted) ***' - } else { - $v = $params[$k] - if ($null -eq $v) { - $clean[$k] = '(null)' - } elseif ($v -is [switch]) { - $clean[$k] = if ($v.IsPresent) { 'true' } else { 'false' } - } elseif ($v -is [array]) { - $clean[$k] = ($v -join ', ') - } else { - $clean[$k] = [string]$v - } - } - } - return $clean - } - - # ----- data prep ------------------------------------------------------ - $duration = $EndTime - $StartTime - $cleanParams = _FilterParameters $Parameters - - $okCount = @($Summary.Values | Where-Object { $_ -eq 'OK' }).Count - $skippedCount = @($Summary.Values | Where-Object { $_ -like 'Skipped*' }).Count - $failedCount = @($Summary.Values | Where-Object { $_ -like 'FAILED:*' }).Count - $totalCount = $Summary.Count - - $overallStatus = if ($failedCount -gt 0) { - 'Completed with errors' - } elseif ($okCount -eq 0 -and $totalCount -gt 0) { - 'All tasks skipped' - } elseif ($totalCount -eq 0) { - 'No tasks recorded' - } else { - 'Completed successfully' - } - $overallClass = if ($failedCount -gt 0) { 'overall-failed' } - elseif ($okCount -eq 0) { 'overall-skipped' } - else { 'overall-ok' } - - # ----- assemble HTML -------------------------------------------------- - $sb = [System.Text.StringBuilder]::new() - $null = $sb.AppendLine('') - $null = $sb.AppendLine('') - $null = $sb.AppendLine('') - $null = $sb.AppendLine(('Purview Best Practice Deployment Report — {0}' -f (_Encode $StartTime.ToString('yyyy-MM-dd HH:mm')))) - $null = $sb.AppendLine(@' - -'@) - $null = $sb.AppendLine('
') - - # Header - $headerTitle = if ($TenantIdentity -and $TenantIdentity.DisplayName) { - _Encode $TenantIdentity.DisplayName - } else { - 'Tenant (identity unresolved)' - } - $headerDomain = if ($TenantIdentity -and $TenantIdentity.DefaultDomain) { - _Encode $TenantIdentity.DefaultDomain - } elseif ($TenantIdentity -and $TenantIdentity.InitialDomain) { - _Encode $TenantIdentity.InitialDomain - } else { '' } - - $null = $sb.AppendLine('
') - $null = $sb.AppendLine(('

Microsoft Purview Best Practice Deployment

')) - $null = $sb.AppendLine(('
{0}{1}
' -f $headerTitle, $(if ($headerDomain) { " · $headerDomain" } else { '' }))) - $null = $sb.AppendLine('
') - - # Overall banner - $null = $sb.AppendLine(('
{1}
' -f $overallClass, (_Encode $overallStatus))) - - # Stats - $null = $sb.AppendLine('

At a glance

') - $null = $sb.AppendLine(('
{0}Completed
' -f $okCount)) - $null = $sb.AppendLine(('
{0}Skipped
' -f $skippedCount)) - $null = $sb.AppendLine(('
{0}Failed
' -f $failedCount)) - $null = $sb.AppendLine(('
{0}Duration
' -f (_Encode (_FormatDuration $duration)))) - $null = $sb.AppendLine('
') - - # Task status table - $null = $sb.AppendLine('

Task results

') - $null = $sb.AppendLine('') - if ($Summary.Count -eq 0) { - $null = $sb.AppendLine('') - } else { - foreach ($entry in $Summary.GetEnumerator()) { - $value = [string]$entry.Value - $cls = _StatusClass $value - - $label = $value - $detail = $null - if ($value -like 'FAILED:*') { - $label = 'FAILED' - $detail = $value.Substring(7).Trim() - } elseif ($value -like 'Skipped*') { - # "Skipped" plus optional "(reason)" parenthetical - if ($value -match '^Skipped\s*(.*)$') { - $label = 'Skipped' - $rest = $Matches[1].Trim() - if ($rest) { $detail = $rest } - } - } - - $null = $sb.AppendLine('') - $null = $sb.AppendLine(('' -f (_Encode $entry.Key))) - $null = $sb.Append(('') - $null = $sb.AppendLine('') - } - } - $null = $sb.AppendLine('
TaskStatus
No tasks recorded — run aborted before any step started.
{0}{1}' -f $cls, (_Encode $label))) - if ($detail) { - $null = $sb.Append(('
{0}
' -f (_Encode $detail))) - } - $null = $sb.AppendLine('
') - - # ---------- Module currency — pre-req warnings surfaced from the run log ----- - # Connect-PurviewServices.ps1 emits SessionGuard:CopilotDlp entries with - # reason=exo-version-below-copilot-dlp-minimum when the locally installed - # ExchangeOnlineManagement module is older than the threshold needed for - # Microsoft 365 Copilot DLP (Locations/EnforcementPlanes parameters). The - # check is non-blocking; this section gives the operator the install - # commands so the NEXT run doesn't hit the AI step backstop. We render - # the section only when at least one such warning was logged. - if ($RunLog -and $RunLog.Count -gt 0) { - $modWarnings = @($RunLog | Where-Object { - $_.Module -eq 'Connect-PurviewServices' -and - $_.Action -like 'SessionGuard:*' -and - $_.Status -in @('Retried','Failed','Skipped') -and - $_.Detail -like '*-below-*-minimum*' - }) - if ($modWarnings.Count -gt 0) { - $null = $sb.AppendLine('

Module currency

') - $null = $sb.AppendLine('
') - $null = $sb.AppendLine(('One or more PowerShell modules on the machine that ran this deploy are older than the recommended minimum. The deploy was NOT stopped — this is observation only — but the affected step(s) will fail until the module is updated.')) - $null = $sb.AppendLine('
') - - foreach ($w in $modWarnings) { - # Parse 'reason=...; k1=v1; k2=v2' detail string into a hashtable. - $detail = @{} - if ($w.Detail) { - foreach ($part in ($w.Detail -split ';')) { - $kv = $part.Trim() -split '=', 2 - if ($kv.Count -eq 2) { $detail[$kv[0]] = $kv[1] } - } - } - - $svc = ($w.Action -replace '^SessionGuard:','') - $modName = if ($detail.moduleName) { $detail.moduleName } else { '(module)' } - $installed = if ($detail.installed) { $detail.installed } else { 'unknown' } - $minReq = if ($detail.minimumRequired) { $detail.minimumRequired } else { 'unknown' } - $modPath = if ($detail.installedPath) { $detail.installedPath } else { $null } - - $null = $sb.AppendLine('
') - $null = $sb.AppendLine(('
{0} — {1}
' -f (_Encode $svc), (_Encode $modName))) - $null = $sb.AppendLine('
') - $null = $sb.AppendLine(('
Installed
v{0}
' -f (_Encode $installed))) - $null = $sb.AppendLine(('
Minimum required
v{0}
' -f (_Encode $minReq))) - if ($modPath) { - $null = $sb.AppendLine(('
Loaded from
{0}
' -f (_Encode $modPath))) - } - $null = $sb.AppendLine('
') - - # Module-specific install guide. - if ($modName -eq 'ExchangeOnlineManagement') { - $null = $sb.AppendLine('

Why it matters

') - $null = $sb.AppendLine(('

Microsoft 365 Copilot DLP policies require New-DlpCompliancePolicy -Locations $loc -EnforcementPlanes @("CopilotExperiences"). Those parameters are populated by the IPPS REST proxy that Connect-IPPSSession builds at connect time. ExchangeOnlineManagement v{0} does not emit those parameters; step [5/5] AI governance will throw with a clear "module does not expose the Copilot DLP parameters" message until you upgrade.

' -f (_Encode $installed))) - - $null = $sb.AppendLine('

Install guide (run on the machine that executes this script)

') - $null = $sb.AppendLine('
    ') - $null = $sb.AppendLine('
  1. Close every PowerShell window that has connected to Exchange Online or IPPS (the assemblies are pinned in-process and cannot be hot-swapped).
  2. ') - $null = $sb.AppendLine('
  3. Right-click PowerShell → Run as administrator.
  4. ') - $null = $sb.AppendLine('
  5. Run ONE of these:
  6. ') - $null = $sb.AppendLine('
') - $null = $sb.AppendLine('
# Option A — refresh the existing install in-place')
-                    $null = $sb.AppendLine('Update-Module ExchangeOnlineManagement -Force')
-                    $null = $sb.AppendLine('')
-                    $null = $sb.AppendLine('# Option B — install/replace for all users (recommended when multiple side-by-side versions are installed)')
-                    $null = $sb.AppendLine('Install-Module ExchangeOnlineManagement -Scope AllUsers -Force -AllowClobber')
-                    $null = $sb.AppendLine('')
-                    $null = $sb.AppendLine('# Verify the new version is the highest available')
-                    $null = $sb.AppendLine('Get-Module ExchangeOnlineManagement -ListAvailable | Sort-Object Version -Descending | Select-Object -First 3 Name, Version, ModuleBase
') - $null = $sb.AppendLine('
    ') - $null = $sb.AppendLine('
  1. Open a fresh PowerShell window and re-run Deploy-PurviewBestPractice.ps1 with the same parameters you used today. Earlier steps that succeeded are idempotent and will fast-path; AI governance will then provision the Copilot DLP policies in PurviewConfig.psd1 -> AIGovernance.
  2. ') - $null = $sb.AppendLine('
') - $null = $sb.AppendLine('

References: New-DlpCompliancePolicy (Microsoft Learn) · DLP for Microsoft 365 Copilot · PSGallery: ExchangeOnlineManagement.

') - } else { - # Generic fallback for any future SessionGuard module-currency warning. - $null = $sb.AppendLine(('
Update-Module {0} -Force
' -f $modName)) - } - - $null = $sb.AppendLine('
') - } - $null = $sb.AppendLine('
') - } - } - - # ---------- "What's next" — contextual next-steps panel ------------------ - # Drives off $Summary outcomes so the partner sees a deploy-specific - # to-do list. The Activity Explorer walkthrough comes from - # docs/DLP-Simulation-Exit-Runbook.md so anything the partner reads here - # is consistent with the canonical runbook. - $next = [System.Collections.Generic.List[hashtable]]::new() - - $labelsStatus = if ($Summary.Contains('Sensitivity labels')) { [string]$Summary['Sensitivity labels'] } else { '' } - $dlpStatus = if ($Summary.Contains('DLP policies')) { [string]$Summary['DLP policies'] } else { '' } - $retentionStatus = if ($Summary.Contains('Retention')) { [string]$Summary['Retention'] } else { '' } - $aiStatus = if ($Summary.Contains('AI governance')) { [string]$Summary['AI governance'] } else { '' } - $tenantStatus = if ($Summary.Contains('Tenant settings')) { [string]$Summary['Tenant settings'] } else { '' } - - # --- DLP simulation exit --------------------------------------------- - if ($dlpStatus -eq 'OK') { - $next.Add(@{ - When = 'Day 0 - 30' - What = 'Let the DLP policies accumulate telemetry in simulation mode.' - How = "DLP policies are deployed in simulation (TestWithoutNotifications) by default. They detect and log matches, but do not block or notify users. Nothing breaks; telemetry accumulates. Do not promote to enforce until you've reviewed the evidence." - Link = $null - Why = $null - }) - $next.Add(@{ - When = 'Day 30 (recommended)' - What = 'Pull the "what would have leaked" report from Activity Explorer.' - How = @' -1. Open the Microsoft Purview portal at purview.microsoft.com.
-2. Solutions → Data loss prevention → Activity explorer.
-3. Filter by: Activity = DLP rule match · Policy = (the policy you're reviewing) · Date range = since deploy.
-4. Click Export → CSV and save alongside your customer record. This is the audit trail behind your promote-or-extend decision. -'@ - Link = 'docs/DLP-Simulation-Exit-Runbook.md' - Why = 'A policy with zero telemetry is a policy you can't validate. Most partners skip this step and either flip enforce blindly or sit in simulation forever - both bad outcomes.' - }) - $next.Add(@{ - When = 'Day 30 (recommended)' - What = 'Decide per policy: promote, extend, refine, or drop.' - How = 'For each policy, weigh the Activity Explorer evidence against business expectations. Document the decision, the cited evidence (hit count over the 30-day window), the operator, and the next-review date in the customer record.' - Link = 'docs/DLP-Simulation-Exit-Runbook.md' - Why = 'Make the promote-from-simulation call per policy, not globally. Skip the per-policy reasoning and you will tank legitimate sharing on day 31.' - }) - } elseif ($dlpStatus -like 'FAILED*') { - $next.Add(@{ - When = 'Immediate' - What = 'Re-run DLP deploy after resolving the failure.' - How = "DLP step reported: $(_Encode $dlpStatus). Inspect the run log above (Setup-DLP module) for the failing action, then re-run the script. The toolkit is idempotent - already-created policies will be detected and updated." - Link = $null - Why = $null - }) - } - - # --- Sensitivity labels propagation ---------------------------------- - if ($labelsStatus -eq 'OK') { - $next.Add(@{ - When = '0 - 24 hours after deploy' - What = 'Wait for label and policy propagation; verify in the Purview portal.' - How = @' -Sensitivity labels and label policies can take up to 24 hours to fully propagate to every Office client. After the wait:
-1. Purview portal → Information protection → Labels - confirm the labels are listed and ordered correctly.
-2. Open Word/Excel/Outlook as a published-policy user. The label picker (Home tab → Sensitivity) should show Personal, Public, General, Confidential, Highly Confidential.
-3. If the picker is missing, confirm the user has a Microsoft 365 Business Premium / E3 / E5 licence with Information Protection, and that they are inside the label policy scope. -'@ - Link = $null - Why = $null - }) - } - - # --- Retention: opt-in nudge ------------------------------------------ - if ($retentionStatus -like 'Skipped*') { - $next.Add(@{ - When = 'Before go-live' - What = 'Decide whether retention applies in this engagement.' - How = 'Retention is opt-in by default. To enable, re-run with -ApplyRetention. Review the configured duration and scope first - the toolkit ships a 7-year mailbox retain-then-delete default which aligns with most SMB regulatory frameworks (ATO / IRS / SEC / ASIC) but may not be appropriate for every customer.' - Link = 'docs/Retention-Default-Risk.md' - Why = 'Retention policies are notoriously hard to roll back. The Retention-Default-Risk doc covers the decision points (litigation risk, regulatory baseline, scope) you should walk through with the customer before enabling.' - }) - } - - # --- AI governance follow-up ------------------------------------------ - if ($aiStatus -eq 'OK') { - $next.Add(@{ - When = 'Day 0 - 30' - What = 'Verify Copilot DLP rule matches in Activity Explorer.' - How = 'Activity Explorer also surfaces Copilot DLP rule matches under the same filter set. After a few days of typical Copilot usage, pull the same report and check whether Highly Confidential-classified content is being shielded from Copilot as expected.' - Link = 'docs/DLP-Simulation-Exit-Runbook.md' - Why = $null - }) - } elseif ($aiStatus -like 'Skipped (-SkipAIControls*' -or $aiStatus -like 'Skipped (-ApplyAIControls*') { - $next.Add(@{ - When = 'Optional' - What = 'Provision the Copilot DLP policies if the customer is on E5 / Purview Suite.' - How = 'AI governance is now default-on for E5 / Purview Suite tenants. Either remove -SkipAIControls from your re-run, or (if you originally passed the deprecated -ApplyAIControls) just re-run without it — the policies in PurviewConfig.psd1 -> AIGovernance will deploy automatically. Without these, Copilot can summarise / reason over Highly Confidential content unrestricted.' - Link = $null - Why = $null - }) - } elseif ($aiStatus -like 'Skipped (E5 / Purview Suite required*') { - $next.Add(@{ - When = 'When license available' - What = 'Upgrade to E5 or add Purview Suite to enable Copilot DLP.' - How = 'Copilot DLP (block Copilot grounding on Highly Confidential content) is part of the Microsoft 365 E5 / Purview Suite policy plane. Once the customer holds an eligible SKU, re-run the toolkit without -BPOnly — AI governance will run automatically. See Microsoft Learn: DLP for Microsoft 365 Copilot.' - Link = $null - Why = $null - }) - } - - # --- Audit log readiness ---------------------------------------------- - if ($tenantStatus -eq 'OK') { - $next.Add(@{ - When = 'Anytime' - What = 'Cross-check DLP activity with the unified audit log.' - How = @' -For the wider DLP picture (Exchange + SharePoint + Endpoint hits in one query), run this in pwsh while connected to Exchange Online:
-
Search-UnifiedAuditLog -StartDate (Get-Date).AddDays(-30) -EndDate (Get-Date) `
-    -RecordType ComplianceDLPExchange,ComplianceDLPSharePoint,DLPEndpoint `
-    -ResultSize 5000 |
-  Group-Object UserIds | Sort-Object Count -Descending |
-  Select-Object Count, Name -First 20
-Top-N users by hit count gives you the list of people to talk to before flipping enforce on. -'@ - Link = 'docs/DLP-Simulation-Exit-Runbook.md' - Why = 'Two or three conversations beforehand save twenty helpdesk tickets after.' - }) - } - - # --- Universal close-out ----------------------------------------------- - $next.Add(@{ - When = 'Anytime' - What = 'Archive this report alongside the customer record.' - How = "Keep this HTML and its .json sidecar in the customer's deploy folder. The sidecar's runId is the join key if you later need to correlate against Activity Explorer exports or the unified audit log." - Link = $null - Why = $null - }) - - if ($next.Count -gt 0) { - # Collapsible (open by default — these are the actionable follow-ups, so we - # surface them, but the operator can collapse if they only want the run summary). - $null = $sb.AppendLine('
') - $null = $sb.AppendLine('
') - $null = $sb.AppendLine(('What's next{0} recommended follow-up(s)' -f $next.Count)) - $null = $sb.AppendLine('

Based on what this run deployed. Items link to the canonical runbooks in the repo.

') - $null = $sb.AppendLine('') - foreach ($n in $next) { - $null = $sb.AppendLine('') - $null = $sb.AppendLine(('' -f (_Encode $n.When))) - $null = $sb.AppendLine(('' -f (_Encode $n.What))) - # 'How' fields contain intentional HTML markup (links, ,
) — do NOT re-encode.
-            $body = $n.How
-            if ($n.Why) { $body += ('
Why it matters: {0}
' -f $n.Why) } - if ($n.Link) { - $body += ('' -f (_Encode $n.Link)) - } - $null = $sb.AppendLine(('
' -f $body)) - $null = $sb.AppendLine('') - } - $null = $sb.AppendLine('
WhenWhatHow / why
{0}{0}{0}
') - $null = $sb.AppendLine('

Reference: Get started with Activity Explorer · Test or simulate DLP policies.

') - $null = $sb.AppendLine('
') - $null = $sb.AppendLine('
') - } - - # ---------- Tier 2: per-action run log (collapsible per-module) ---------- - if ($RunLog -and $RunLog.Count -gt 0) { - # Group while preserving first-seen module order. - $moduleOrder = [System.Collections.Generic.List[string]]::new() - $byModule = @{} - foreach ($e in $RunLog) { - $m = if ($e.Module) { [string]$e.Module } else { '(unknown)' } - if (-not $byModule.ContainsKey($m)) { - $byModule[$m] = [System.Collections.Generic.List[hashtable]]::new() - $moduleOrder.Add($m) - } - $byModule[$m].Add($e) - } - - $null = $sb.AppendLine('

Per-action detail

') - $null = $sb.AppendLine(('

{0} entries across {1} module(s). Click a module to expand.

' -f $RunLog.Count, $moduleOrder.Count)) - - foreach ($modName in $moduleOrder) { - $entries = $byModule[$modName] - - # Per-module counts by status (case-insensitive). - $cFail = @($entries | Where-Object { $_.Status -eq 'Failed' }).Count - $cRet = @($entries | Where-Object { $_.Status -eq 'Retried' }).Count - $cAdo = @($entries | Where-Object { $_.Status -eq 'Adopted' }).Count - $cCre = @($entries | Where-Object { $_.Status -in @('Created','Succeeded') }).Count - $cUpd = @($entries | Where-Object { $_.Status -eq 'Updated' }).Count - $cSki = @($entries | Where-Object { $_.Status -eq 'Skipped' }).Count - - $countBits = @() - if ($cCre) { $countBits += "$cCre ok" } - if ($cUpd) { $countBits += "$cUpd updated" } - if ($cAdo) { $countBits += "$cAdo adopted" } - if ($cSki) { $countBits += "$cSki skipped" } - if ($cRet) { $countBits += "$cRet retried" } - if ($cFail){ $countBits += "$cFail failed" } - $counts = if ($countBits.Count -gt 0) { $countBits -join ' · ' } else { "$($entries.Count) entries" } - - # Auto-expand modules with failures so operators see them immediately. - $openAttr = if ($cFail -gt 0) { ' open' } else { '' } - - $null = $sb.AppendLine(('
' -f $openAttr)) - $null = $sb.AppendLine(('{0}{1}' -f (_Encode $modName), $counts)) - $null = $sb.AppendLine('') - $null = $sb.AppendLine('') - $null = $sb.AppendLine('') - - foreach ($e in $entries) { - $ts = '' - if ($e.Timestamp -is [datetime]) { - $ts = $e.Timestamp.ToLocalTime().ToString('HH:mm:ss') - } - $statusLower = if ($e.Status) { ([string]$e.Status).ToLowerInvariant() } else { 'info' } - $statusCls = "status-$statusLower" - $attempt = if ($e.ContainsKey('Attempt')) { [string]$e.Attempt } else { '' } - $elapsedTxt = if ($e.ContainsKey('ElapsedMs') -and $null -ne $e.ElapsedMs) { [string]$e.ElapsedMs } else { '' } - - $null = $sb.AppendLine('') - $null = $sb.AppendLine(('' -f (_Encode $ts))) - $null = $sb.AppendLine(('' -f (_Encode ([string]$e.Action)))) - $null = $sb.AppendLine(('' -f (_Encode ([string]$e.Target)))) - $null = $sb.AppendLine(('' -f $statusCls, (_Encode ([string]$e.Status)))) - $null = $sb.AppendLine(('' -f (_Encode ([string]$e.Detail)))) - $null = $sb.AppendLine(('' -f (_Encode $attempt))) - $null = $sb.AppendLine(('' -f (_Encode $elapsedTxt))) - $null = $sb.AppendLine('') - } - $null = $sb.AppendLine('
TimeActionTargetStatusDetailAtt.ms
{0}{0}{0}{1}{0}{0}{0}
') - } - $null = $sb.AppendLine('
') - } - - # Tenant - $null = $sb.AppendLine('

Target tenant

') - if ($TenantIdentity) { - $null = $sb.AppendLine(('
Display name
{0}
' -f (_Encode $TenantIdentity.DisplayName))) - $null = $sb.AppendLine(('
Tenant ID
{0}
' -f (_Encode $TenantIdentity.TenantId))) - $null = $sb.AppendLine(('
Default domain
{0}
' -f (_Encode $TenantIdentity.DefaultDomain))) - $null = $sb.AppendLine(('
Initial domain
{0}
' -f (_Encode $TenantIdentity.InitialDomain))) - $null = $sb.AppendLine(('
Source
{0}
' -f (_Encode $TenantIdentity.Source))) - if ($TenantIdentity.AllDomains -and $TenantIdentity.AllDomains.Count -gt 0) { - $null = $sb.AppendLine('
Verified domains
') - $primary = $TenantIdentity.DefaultDomain - $domHtml = foreach ($d in $TenantIdentity.AllDomains) { - $enc = _Encode $d - if ($d -eq $primary) { "$enc" } else { $enc } - } - $null = $sb.AppendLine(($domHtml -join ', ')) - $null = $sb.AppendLine('
') - } - } else { - $null = $sb.AppendLine('
(not resolved)
tenant identity not captured before report generation
') - } - if ($TenantAdminUpn) { - $null = $sb.AppendLine(('
Admin UPN
{0}
' -f (_Encode $TenantAdminUpn))) - } - $null = $sb.AppendLine('
') - - # Run metadata - $null = $sb.AppendLine('

Run metadata

') - $null = $sb.AppendLine(('
Started
{0}
' -f (_Encode $StartTime.ToString('yyyy-MM-dd HH:mm:ss zzz')))) - $null = $sb.AppendLine(('
Ended
{0}
' -f (_Encode $EndTime.ToString('yyyy-MM-dd HH:mm:ss zzz')))) - $null = $sb.AppendLine(('
Duration
{0}
' -f (_Encode (_FormatDuration $duration)))) - $null = $sb.AppendLine(('
Run ID
{0}
' -f (_Encode $RunId.ToString()))) - $null = $sb.AppendLine(('
Script version
{0}
' -f (_Encode $ScriptVersion))) - $null = $sb.AppendLine(('
PowerShell
{0}
' -f (_Encode ("$($PSVersionTable.PSVersion) ($($PSVersionTable.PSEdition))")))) - $null = $sb.AppendLine(('
Host OS
{0}
' -f (_Encode ([System.Environment]::OSVersion.VersionString)))) - $null = $sb.AppendLine(('
Machine
{0}
' -f (_Encode ([System.Environment]::MachineName)))) - $null = $sb.AppendLine('
') - - # Parameters - if ($cleanParams.Count -gt 0) { - $null = $sb.AppendLine('

Parameters used

') - foreach ($key in ($cleanParams.Keys | Sort-Object)) { - $null = $sb.AppendLine(('
-{0}
{1}
' -f (_Encode $key), (_Encode ([string]$cleanParams[$key])))) - } - $null = $sb.AppendLine('
') - } - - # Footer - $null = $sb.AppendLine(('
Generated {0} · SMBBestPracticeTool {1} · Run {2}
' -f ` - (_Encode (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')), (_Encode $ScriptVersion), (_Encode $RunId.ToString()))) - $null = $sb.AppendLine('
') - - # ----- write ---------------------------------------------------------- - $dir = Split-Path -Parent $OutputPath - if ($dir -and -not (Test-Path $dir)) { - New-Item -ItemType Directory -Path $dir -Force | Out-Null - } - Set-Content -Path $OutputPath -Value $sb.ToString() -Encoding UTF8 -NoNewline:$false - return $OutputPath -} +#requires -Version 7.0 +<# +.SYNOPSIS + Renders a self-contained HTML deployment report for the Purview Best + Practice toolkit. + +.DESCRIPTION + Tier 1 report: task-status table + run metadata (tenant identity, + admin UPN, timestamps, parameters used, PS version, host, RunId). + Pure renderer -- inline CSS, no JS, no external assets, no network + calls. Output is a single .html file safe to forward over email. + + Tier 2 (future) will extend this with per-action sections fed from a + structured RunLog. The Tier 1 surface is designed to be stable so + Tier 2 is additive (new sections appended after the task table). + +.NOTES + Security: + * All user-supplied strings are HTML-escaped via [System.Net.WebUtility]::HtmlEncode. + * Parameters hashtable is filtered against a denylist + (*password*, *token*, *secret*, *credential*, *key*) before render + so a future param named e.g. -ClientSecret never lands in the + report. + * Caller controls the output path; no auto-overwrite logic here + beyond Set-Content's default replace. + + The function is intentionally idempotent: call it from the + orchestrator's finally block once, on success or on failure. +#> + +function Write-PurviewHtmlReport { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [System.Collections.Specialized.OrderedDictionary] $Summary, + [Parameter(Mandatory)] [string] $OutputPath, + [Parameter(Mandatory)] [datetime] $StartTime, + [Parameter(Mandatory)] [datetime] $EndTime, + [Parameter(Mandatory)] [guid] $RunId, + + [Parameter()] [psobject] $TenantIdentity, + [Parameter()] [string] $TenantAdminUpn, + [Parameter()] [hashtable] $Parameters, + [Parameter()] [string] $ScriptVersion = 'unknown', + + # Tier 2: per-action decision-point log. Each entry is a hashtable + # with keys: Timestamp, Module, Action, Target, Status, Detail, + # Attempt (opt), ElapsedMs (opt). Pass the output of + # Get-PurviewRunLog from the orchestrator's finally block. + [Parameter()] [hashtable[]] $RunLog + ) + + # ----- helpers -------------------------------------------------------- + function _Encode([string]$s) { + if ($null -eq $s) { return '' } + return [System.Net.WebUtility]::HtmlEncode($s) + } + + function _FormatDuration([timespan]$span) { + if ($span.TotalHours -ge 1) { + return ('{0}h {1}m {2}s' -f [int]$span.TotalHours, $span.Minutes, $span.Seconds) + } elseif ($span.TotalMinutes -ge 1) { + return ('{0}m {1}s' -f [int]$span.TotalMinutes, $span.Seconds) + } else { + return ('{0}s' -f [int]$span.TotalSeconds) + } + } + + function _StatusClass([string]$value) { + switch -Wildcard ($value) { + 'OK' { 'status-ok' ; break } + 'Skipped*' { 'status-skipped' ; break } + 'FAILED:*' { 'status-failed' ; break } + default { 'status-other' } + } + } + + function _FilterParameters([hashtable]$params) { + if (-not $params) { return @{} } + $deny = @('*password*','*token*','*secret*','*credential*','*key*') + $clean = @{} + foreach ($k in $params.Keys) { + $hit = $false + foreach ($pattern in $deny) { + if ($k -like $pattern) { $hit = $true; break } + } + if ($hit) { + $clean[$k] = '*** (redacted) ***' + } else { + $v = $params[$k] + if ($null -eq $v) { + $clean[$k] = '(null)' + } elseif ($v -is [switch]) { + $clean[$k] = if ($v.IsPresent) { 'true' } else { 'false' } + } elseif ($v -is [array]) { + $clean[$k] = ($v -join ', ') + } else { + $clean[$k] = [string]$v + } + } + } + return $clean + } + + # ----- data prep ------------------------------------------------------ + $duration = $EndTime - $StartTime + $cleanParams = _FilterParameters $Parameters + + $okCount = @($Summary.Values | Where-Object { $_ -eq 'OK' }).Count + $skippedCount = @($Summary.Values | Where-Object { $_ -like 'Skipped*' }).Count + $failedCount = @($Summary.Values | Where-Object { $_ -like 'FAILED:*' }).Count + $totalCount = $Summary.Count + + $overallStatus = if ($failedCount -gt 0) { + 'Completed with errors' + } elseif ($okCount -eq 0 -and $totalCount -gt 0) { + 'All tasks skipped' + } elseif ($totalCount -eq 0) { + 'No tasks recorded' + } else { + 'Completed successfully' + } + $overallClass = if ($failedCount -gt 0) { 'overall-failed' } + elseif ($okCount -eq 0) { 'overall-skipped' } + else { 'overall-ok' } + + # ----- assemble HTML -------------------------------------------------- + $sb = [System.Text.StringBuilder]::new() + $null = $sb.AppendLine('') + $null = $sb.AppendLine('') + $null = $sb.AppendLine('') + $null = $sb.AppendLine(('Purview Best Practice Deployment Report — {0}' -f (_Encode $StartTime.ToString('yyyy-MM-dd HH:mm')))) + $null = $sb.AppendLine(@' + +'@) + $null = $sb.AppendLine('
') + + # Header + $headerTitle = if ($TenantIdentity -and $TenantIdentity.DisplayName) { + _Encode $TenantIdentity.DisplayName + } else { + 'Tenant (identity unresolved)' + } + $headerDomain = if ($TenantIdentity -and $TenantIdentity.DefaultDomain) { + _Encode $TenantIdentity.DefaultDomain + } elseif ($TenantIdentity -and $TenantIdentity.InitialDomain) { + _Encode $TenantIdentity.InitialDomain + } else { '' } + + $null = $sb.AppendLine('
') + $null = $sb.AppendLine(('

Microsoft Purview Best Practice Deployment

')) + $null = $sb.AppendLine(('
{0}{1}
' -f $headerTitle, $(if ($headerDomain) { " · $headerDomain" } else { '' }))) + $null = $sb.AppendLine('
') + + # Overall banner + $null = $sb.AppendLine(('
{1}
' -f $overallClass, (_Encode $overallStatus))) + + # Stats + $null = $sb.AppendLine('

At a glance

') + $null = $sb.AppendLine(('
{0}Completed
' -f $okCount)) + $null = $sb.AppendLine(('
{0}Skipped
' -f $skippedCount)) + $null = $sb.AppendLine(('
{0}Failed
' -f $failedCount)) + $null = $sb.AppendLine(('
{0}Duration
' -f (_Encode (_FormatDuration $duration)))) + $null = $sb.AppendLine('
') + + # Task status table + $null = $sb.AppendLine('

Task results

') + $null = $sb.AppendLine('') + if ($Summary.Count -eq 0) { + $null = $sb.AppendLine('') + } else { + foreach ($entry in $Summary.GetEnumerator()) { + $value = [string]$entry.Value + $cls = _StatusClass $value + + $label = $value + $detail = $null + if ($value -like 'FAILED:*') { + $label = 'FAILED' + $detail = $value.Substring(7).Trim() + } elseif ($value -like 'Skipped*') { + # "Skipped" plus optional "(reason)" parenthetical + if ($value -match '^Skipped\s*(.*)$') { + $label = 'Skipped' + $rest = $Matches[1].Trim() + if ($rest) { $detail = $rest } + } + } + + $null = $sb.AppendLine('') + $null = $sb.AppendLine(('' -f (_Encode $entry.Key))) + $null = $sb.Append(('') + $null = $sb.AppendLine('') + } + } + $null = $sb.AppendLine('
TaskStatus
No tasks recorded — run aborted before any step started.
{0}{1}' -f $cls, (_Encode $label))) + if ($detail) { + $null = $sb.Append(('
{0}
' -f (_Encode $detail))) + } + $null = $sb.AppendLine('
') + + # ---------- Module currency — pre-req warnings surfaced from the run log ----- + # Connect-PurviewServices.ps1 emits SessionGuard:CopilotDlp entries with + # reason=exo-version-below-copilot-dlp-minimum when the locally installed + # ExchangeOnlineManagement module is older than the threshold needed for + # Microsoft 365 Copilot DLP (Locations/EnforcementPlanes parameters). The + # check is non-blocking; this section gives the operator the install + # commands so the NEXT run doesn't hit the AI step backstop. We render + # the section only when at least one such warning was logged. + if ($RunLog -and $RunLog.Count -gt 0) { + $modWarnings = @($RunLog | Where-Object { + $_.Module -eq 'Connect-PurviewServices' -and + $_.Action -like 'SessionGuard:*' -and + $_.Status -in @('Retried','Failed','Skipped') -and + $_.Detail -like '*-below-*-minimum*' + }) + if ($modWarnings.Count -gt 0) { + $null = $sb.AppendLine('

Module currency

') + $null = $sb.AppendLine('
') + $null = $sb.AppendLine(('One or more PowerShell modules on the machine that ran this deploy are older than the recommended minimum. The deploy was NOT stopped — this is observation only — but the affected step(s) will fail until the module is updated.')) + $null = $sb.AppendLine('
') + + foreach ($w in $modWarnings) { + # Parse 'reason=...; k1=v1; k2=v2' detail string into a hashtable. + $detail = @{} + if ($w.Detail) { + foreach ($part in ($w.Detail -split ';')) { + $kv = $part.Trim() -split '=', 2 + if ($kv.Count -eq 2) { $detail[$kv[0]] = $kv[1] } + } + } + + $svc = ($w.Action -replace '^SessionGuard:','') + $modName = if ($detail.moduleName) { $detail.moduleName } else { '(module)' } + $installed = if ($detail.installed) { $detail.installed } else { 'unknown' } + $minReq = if ($detail.minimumRequired) { $detail.minimumRequired } else { 'unknown' } + $modPath = if ($detail.installedPath) { $detail.installedPath } else { $null } + + $null = $sb.AppendLine('
') + $null = $sb.AppendLine(('
{0} — {1}
' -f (_Encode $svc), (_Encode $modName))) + $null = $sb.AppendLine('
') + $null = $sb.AppendLine(('
Installed
v{0}
' -f (_Encode $installed))) + $null = $sb.AppendLine(('
Minimum required
v{0}
' -f (_Encode $minReq))) + if ($modPath) { + $null = $sb.AppendLine(('
Loaded from
{0}
' -f (_Encode $modPath))) + } + $null = $sb.AppendLine('
') + + # Module-specific install guide. + if ($modName -eq 'ExchangeOnlineManagement') { + $null = $sb.AppendLine('

Why it matters

') + $null = $sb.AppendLine(('

Microsoft 365 Copilot DLP policies require New-DlpCompliancePolicy -Locations $loc -EnforcementPlanes @("CopilotExperiences"). Those parameters are populated by the IPPS REST proxy that Connect-IPPSSession builds at connect time. ExchangeOnlineManagement v{0} does not emit those parameters; step [5/5] AI governance will throw with a clear "module does not expose the Copilot DLP parameters" message until you upgrade.

' -f (_Encode $installed))) + + $null = $sb.AppendLine('

Install guide (run on the machine that executes this script)

') + $null = $sb.AppendLine('
    ') + $null = $sb.AppendLine('
  1. Close every PowerShell window that has connected to Exchange Online or IPPS (the assemblies are pinned in-process and cannot be hot-swapped).
  2. ') + $null = $sb.AppendLine('
  3. Right-click PowerShell → Run as administrator.
  4. ') + $null = $sb.AppendLine('
  5. Run ONE of these:
  6. ') + $null = $sb.AppendLine('
') + $null = $sb.AppendLine('
# Option A — refresh the existing install in-place')
+                    $null = $sb.AppendLine('Update-Module ExchangeOnlineManagement -Force')
+                    $null = $sb.AppendLine('')
+                    $null = $sb.AppendLine('# Option B — install/replace for all users (recommended when multiple side-by-side versions are installed)')
+                    $null = $sb.AppendLine('Install-Module ExchangeOnlineManagement -Scope AllUsers -Force -AllowClobber')
+                    $null = $sb.AppendLine('')
+                    $null = $sb.AppendLine('# Verify the new version is the highest available')
+                    $null = $sb.AppendLine('Get-Module ExchangeOnlineManagement -ListAvailable | Sort-Object Version -Descending | Select-Object -First 3 Name, Version, ModuleBase
') + $null = $sb.AppendLine('
    ') + $null = $sb.AppendLine('
  1. Open a fresh PowerShell window and re-run Deploy-PurviewBestPractice.ps1 with the same parameters you used today. Earlier steps that succeeded are idempotent and will fast-path; AI governance will then provision the Copilot DLP policies in PurviewConfig.psd1 -> AIGovernance.
  2. ') + $null = $sb.AppendLine('
') + $null = $sb.AppendLine('

References: New-DlpCompliancePolicy (Microsoft Learn) · DLP for Microsoft 365 Copilot · PSGallery: ExchangeOnlineManagement.

') + } else { + # Generic fallback for any future SessionGuard module-currency warning. + $null = $sb.AppendLine(('
Update-Module {0} -Force
' -f $modName)) + } + + $null = $sb.AppendLine('
') + } + $null = $sb.AppendLine('
') + } + } + + # ---------- "What's next" — contextual next-steps panel ------------------ + # Drives off $Summary outcomes so the partner sees a deploy-specific + # to-do list. The Activity Explorer walkthrough comes from + # docs/DLP-Simulation-Exit-Runbook.md so anything the partner reads here + # is consistent with the canonical runbook. + $next = [System.Collections.Generic.List[hashtable]]::new() + + $labelsStatus = if ($Summary.Contains('Sensitivity labels')) { [string]$Summary['Sensitivity labels'] } else { '' } + $dlpStatus = if ($Summary.Contains('DLP policies')) { [string]$Summary['DLP policies'] } else { '' } + $retentionStatus = if ($Summary.Contains('Retention')) { [string]$Summary['Retention'] } else { '' } + $aiStatus = if ($Summary.Contains('AI governance')) { [string]$Summary['AI governance'] } else { '' } + $tenantStatus = if ($Summary.Contains('Tenant settings')) { [string]$Summary['Tenant settings'] } else { '' } + + # --- DLP simulation exit --------------------------------------------- + if ($dlpStatus -eq 'OK') { + $next.Add(@{ + When = 'Day 0 - 30' + What = 'Let the DLP policies accumulate telemetry in simulation mode.' + How = "DLP policies are deployed in simulation (TestWithoutNotifications) by default. They detect and log matches, but do not block or notify users. Nothing breaks; telemetry accumulates. Do not promote to enforce until you've reviewed the evidence." + Link = $null + Why = $null + }) + $next.Add(@{ + When = 'Day 30 (recommended)' + What = 'Pull the "what would have leaked" report from Activity Explorer.' + How = @' +1. Open the Microsoft Purview portal at purview.microsoft.com.
+2. Solutions → Data loss prevention → Activity explorer.
+3. Filter by: Activity = DLP rule match · Policy = (the policy you're reviewing) · Date range = since deploy.
+4. Click Export → CSV and save alongside your customer record. This is the audit trail behind your promote-or-extend decision. +'@ + Link = 'docs/DLP-Simulation-Exit-Runbook.md' + Why = 'A policy with zero telemetry is a policy you can't validate. Most partners skip this step and either flip enforce blindly or sit in simulation forever - both bad outcomes.' + }) + $next.Add(@{ + When = 'Day 30 (recommended)' + What = 'Decide per policy: promote, extend, refine, or drop.' + How = 'For each policy, weigh the Activity Explorer evidence against business expectations. Document the decision, the cited evidence (hit count over the 30-day window), the operator, and the next-review date in the customer record.' + Link = 'docs/DLP-Simulation-Exit-Runbook.md' + Why = 'Make the promote-from-simulation call per policy, not globally. Skip the per-policy reasoning and you will tank legitimate sharing on day 31.' + }) + } elseif ($dlpStatus -like 'FAILED*') { + $next.Add(@{ + When = 'Immediate' + What = 'Re-run DLP deploy after resolving the failure.' + How = "DLP step reported: $(_Encode $dlpStatus). Inspect the run log above (Setup-DLP module) for the failing action, then re-run the script. The toolkit is idempotent - already-created policies will be detected and updated." + Link = $null + Why = $null + }) + } + + # --- Sensitivity labels propagation ---------------------------------- + if ($labelsStatus -eq 'OK') { + $next.Add(@{ + When = '0 - 24 hours after deploy' + What = 'Wait for label and policy propagation; verify in the Purview portal.' + How = @' +Sensitivity labels and label policies can take up to 24 hours to fully propagate to every Office client. After the wait:
+1. Purview portal → Information protection → Labels - confirm the labels are listed and ordered correctly.
+2. Open Word/Excel/Outlook as a published-policy user. The label picker (Home tab → Sensitivity) should show Personal, Public, General, Confidential, Highly Confidential.
+3. If the picker is missing, confirm the user has a Microsoft 365 Business Premium / E3 / E5 licence with Information Protection, and that they are inside the label policy scope. +'@ + Link = $null + Why = $null + }) + } + + # --- Retention: opt-in nudge ------------------------------------------ + if ($retentionStatus -like 'Skipped*') { + $next.Add(@{ + When = 'Before go-live' + What = 'Decide whether retention applies in this engagement.' + How = 'Retention is opt-in by default. To enable, re-run with -ApplyRetention. Review the configured duration and scope first - the toolkit ships a 7-year mailbox retain-then-delete default which aligns with most SMB regulatory frameworks (ATO / IRS / SEC / ASIC) but may not be appropriate for every customer.' + Link = 'docs/Retention-Default-Risk.md' + Why = 'Retention policies are notoriously hard to roll back. The Retention-Default-Risk doc covers the decision points (litigation risk, regulatory baseline, scope) you should walk through with the customer before enabling.' + }) + } + + # --- AI governance follow-up ------------------------------------------ + if ($aiStatus -eq 'OK') { + $next.Add(@{ + When = 'Day 0 - 30' + What = 'Verify Copilot DLP rule matches in Activity Explorer.' + How = 'Activity Explorer also surfaces Copilot DLP rule matches under the same filter set. After a few days of typical Copilot usage, pull the same report and check whether Highly Confidential-classified content is being shielded from Copilot as expected.' + Link = 'docs/DLP-Simulation-Exit-Runbook.md' + Why = $null + }) + } elseif ($aiStatus -like 'Skipped (-SkipAIControls*' -or $aiStatus -like 'Skipped (-ApplyAIControls*') { + $next.Add(@{ + When = 'Optional' + What = 'Provision the Copilot DLP policies if the customer is on E5 / Purview Suite.' + How = 'AI governance is now default-on for E5 / Purview Suite tenants. Either remove -SkipAIControls from your re-run, or (if you originally passed the deprecated -ApplyAIControls) just re-run without it — the policies in PurviewConfig.psd1 -> AIGovernance will deploy automatically. Without these, Copilot can summarise / reason over Highly Confidential content unrestricted.' + Link = $null + Why = $null + }) + } elseif ($aiStatus -like 'Skipped (E5 / Purview Suite required*') { + $next.Add(@{ + When = 'When license available' + What = 'Upgrade to E5 or add Purview Suite to enable Copilot DLP.' + How = 'Copilot DLP (block Copilot grounding on Highly Confidential content) is part of the Microsoft 365 E5 / Purview Suite policy plane. Once the customer holds an eligible SKU, re-run the toolkit without -BPOnly — AI governance will run automatically. See Microsoft Learn: DLP for Microsoft 365 Copilot.' + Link = $null + Why = $null + }) + } + + # --- Audit log readiness ---------------------------------------------- + if ($tenantStatus -eq 'OK') { + $next.Add(@{ + When = 'Anytime' + What = 'Cross-check DLP activity with the unified audit log.' + How = @' +For the wider DLP picture (Exchange + SharePoint + Endpoint hits in one query), run this in pwsh while connected to Exchange Online:
+
Search-UnifiedAuditLog -StartDate (Get-Date).AddDays(-30) -EndDate (Get-Date) `
+    -RecordType ComplianceDLPExchange,ComplianceDLPSharePoint,DLPEndpoint `
+    -ResultSize 5000 |
+  Group-Object UserIds | Sort-Object Count -Descending |
+  Select-Object Count, Name -First 20
+Top-N users by hit count gives you the list of people to talk to before flipping enforce on. +'@ + Link = 'docs/DLP-Simulation-Exit-Runbook.md' + Why = 'Two or three conversations beforehand save twenty helpdesk tickets after.' + }) + } + + # --- Universal close-out ----------------------------------------------- + $next.Add(@{ + When = 'Anytime' + What = 'Archive this report alongside the customer record.' + How = "Keep this HTML and its .json sidecar in the customer's deploy folder. The sidecar's runId is the join key if you later need to correlate against Activity Explorer exports or the unified audit log." + Link = $null + Why = $null + }) + + if ($next.Count -gt 0) { + # Collapsible (open by default — these are the actionable follow-ups, so we + # surface them, but the operator can collapse if they only want the run summary). + $null = $sb.AppendLine('
') + $null = $sb.AppendLine('
') + $null = $sb.AppendLine(('What's next{0} recommended follow-up(s)' -f $next.Count)) + $null = $sb.AppendLine('

Based on what this run deployed. Items link to the canonical runbooks in the repo.

') + $null = $sb.AppendLine('') + foreach ($n in $next) { + $null = $sb.AppendLine('') + $null = $sb.AppendLine(('' -f (_Encode $n.When))) + $null = $sb.AppendLine(('' -f (_Encode $n.What))) + # 'How' fields contain intentional HTML markup (links, ,
) — do NOT re-encode.
+            $body = $n.How
+            if ($n.Why) { $body += ('
Why it matters: {0}
' -f $n.Why) } + if ($n.Link) { + $body += ('' -f (_Encode $n.Link)) + } + $null = $sb.AppendLine(('
' -f $body)) + $null = $sb.AppendLine('') + } + $null = $sb.AppendLine('
WhenWhatHow / why
{0}{0}{0}
') + $null = $sb.AppendLine('

Reference: Get started with Activity Explorer · Test or simulate DLP policies.

') + $null = $sb.AppendLine('
') + $null = $sb.AppendLine('
') + } + + # ---------- Tier 2: per-action run log (collapsible per-module) ---------- + if ($RunLog -and $RunLog.Count -gt 0) { + # Group while preserving first-seen module order. + $moduleOrder = [System.Collections.Generic.List[string]]::new() + $byModule = @{} + foreach ($e in $RunLog) { + $m = if ($e.Module) { [string]$e.Module } else { '(unknown)' } + if (-not $byModule.ContainsKey($m)) { + $byModule[$m] = [System.Collections.Generic.List[hashtable]]::new() + $moduleOrder.Add($m) + } + $byModule[$m].Add($e) + } + + $null = $sb.AppendLine('

Per-action detail

') + $null = $sb.AppendLine(('

{0} entries across {1} module(s). Click a module to expand.

' -f $RunLog.Count, $moduleOrder.Count)) + + foreach ($modName in $moduleOrder) { + $entries = $byModule[$modName] + + # Per-module counts by status (case-insensitive). + $cFail = @($entries | Where-Object { $_.Status -eq 'Failed' }).Count + $cRet = @($entries | Where-Object { $_.Status -eq 'Retried' }).Count + $cAdo = @($entries | Where-Object { $_.Status -eq 'Adopted' }).Count + $cCre = @($entries | Where-Object { $_.Status -in @('Created','Succeeded') }).Count + $cUpd = @($entries | Where-Object { $_.Status -eq 'Updated' }).Count + $cSki = @($entries | Where-Object { $_.Status -eq 'Skipped' }).Count + + $countBits = @() + if ($cCre) { $countBits += "$cCre ok" } + if ($cUpd) { $countBits += "$cUpd updated" } + if ($cAdo) { $countBits += "$cAdo adopted" } + if ($cSki) { $countBits += "$cSki skipped" } + if ($cRet) { $countBits += "$cRet retried" } + if ($cFail){ $countBits += "$cFail failed" } + $counts = if ($countBits.Count -gt 0) { $countBits -join ' · ' } else { "$($entries.Count) entries" } + + # Auto-expand modules with failures so operators see them immediately. + $openAttr = if ($cFail -gt 0) { ' open' } else { '' } + + $null = $sb.AppendLine(('
' -f $openAttr)) + $null = $sb.AppendLine(('{0}{1}' -f (_Encode $modName), $counts)) + $null = $sb.AppendLine('') + $null = $sb.AppendLine('') + $null = $sb.AppendLine('') + + foreach ($e in $entries) { + $ts = '' + if ($e.Timestamp -is [datetime]) { + $ts = $e.Timestamp.ToLocalTime().ToString('HH:mm:ss') + } + $statusLower = if ($e.Status) { ([string]$e.Status).ToLowerInvariant() } else { 'info' } + $statusCls = "status-$statusLower" + $attempt = if ($e.ContainsKey('Attempt')) { [string]$e.Attempt } else { '' } + $elapsedTxt = if ($e.ContainsKey('ElapsedMs') -and $null -ne $e.ElapsedMs) { [string]$e.ElapsedMs } else { '' } + + $null = $sb.AppendLine('') + $null = $sb.AppendLine(('' -f (_Encode $ts))) + $null = $sb.AppendLine(('' -f (_Encode ([string]$e.Action)))) + $null = $sb.AppendLine(('' -f (_Encode ([string]$e.Target)))) + $null = $sb.AppendLine(('' -f $statusCls, (_Encode ([string]$e.Status)))) + $null = $sb.AppendLine(('' -f (_Encode ([string]$e.Detail)))) + $null = $sb.AppendLine(('' -f (_Encode $attempt))) + $null = $sb.AppendLine(('' -f (_Encode $elapsedTxt))) + $null = $sb.AppendLine('') + } + $null = $sb.AppendLine('
TimeActionTargetStatusDetailAtt.ms
{0}{0}{0}{1}{0}{0}{0}
') + } + $null = $sb.AppendLine('
') + } + + # Tenant + $null = $sb.AppendLine('

Target tenant

') + if ($TenantIdentity) { + $null = $sb.AppendLine(('
Display name
{0}
' -f (_Encode $TenantIdentity.DisplayName))) + $null = $sb.AppendLine(('
Tenant ID
{0}
' -f (_Encode $TenantIdentity.TenantId))) + $null = $sb.AppendLine(('
Default domain
{0}
' -f (_Encode $TenantIdentity.DefaultDomain))) + $null = $sb.AppendLine(('
Initial domain
{0}
' -f (_Encode $TenantIdentity.InitialDomain))) + $null = $sb.AppendLine(('
Source
{0}
' -f (_Encode $TenantIdentity.Source))) + if ($TenantIdentity.AllDomains -and $TenantIdentity.AllDomains.Count -gt 0) { + $null = $sb.AppendLine('
Verified domains
') + $primary = $TenantIdentity.DefaultDomain + $domHtml = foreach ($d in $TenantIdentity.AllDomains) { + $enc = _Encode $d + if ($d -eq $primary) { "$enc" } else { $enc } + } + $null = $sb.AppendLine(($domHtml -join ', ')) + $null = $sb.AppendLine('
') + } + } else { + $null = $sb.AppendLine('
(not resolved)
tenant identity not captured before report generation
') + } + if ($TenantAdminUpn) { + $null = $sb.AppendLine(('
Admin UPN
{0}
' -f (_Encode $TenantAdminUpn))) + } + $null = $sb.AppendLine('
') + + # Run metadata + $null = $sb.AppendLine('

Run metadata

') + $null = $sb.AppendLine(('
Started
{0}
' -f (_Encode $StartTime.ToString('yyyy-MM-dd HH:mm:ss zzz')))) + $null = $sb.AppendLine(('
Ended
{0}
' -f (_Encode $EndTime.ToString('yyyy-MM-dd HH:mm:ss zzz')))) + $null = $sb.AppendLine(('
Duration
{0}
' -f (_Encode (_FormatDuration $duration)))) + $null = $sb.AppendLine(('
Run ID
{0}
' -f (_Encode $RunId.ToString()))) + $null = $sb.AppendLine(('
Script version
{0}
' -f (_Encode $ScriptVersion))) + $null = $sb.AppendLine(('
PowerShell
{0}
' -f (_Encode ("$($PSVersionTable.PSVersion) ($($PSVersionTable.PSEdition))")))) + $null = $sb.AppendLine(('
Host OS
{0}
' -f (_Encode ([System.Environment]::OSVersion.VersionString)))) + $null = $sb.AppendLine(('
Machine
{0}
' -f (_Encode ([System.Environment]::MachineName)))) + $null = $sb.AppendLine('
') + + # Parameters + if ($cleanParams.Count -gt 0) { + $null = $sb.AppendLine('

Parameters used

') + foreach ($key in ($cleanParams.Keys | Sort-Object)) { + $null = $sb.AppendLine(('
-{0}
{1}
' -f (_Encode $key), (_Encode ([string]$cleanParams[$key])))) + } + $null = $sb.AppendLine('
') + } + + # Footer + $null = $sb.AppendLine(('
Generated {0} · SMBBestPracticeTool {1} · Run {2}
' -f ` + (_Encode (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')), (_Encode $ScriptVersion), (_Encode $RunId.ToString()))) + $null = $sb.AppendLine('
') + + # ----- write ---------------------------------------------------------- + $dir = Split-Path -Parent $OutputPath + if ($dir -and -not (Test-Path $dir)) { + New-Item -ItemType Directory -Path $dir -Force -WhatIf:$false | Out-Null + } + # -WhatIf:$false because this is local evidence output, not a tenant change. + # This function does not declare SupportsShouldProcess, so inheriting + # $WhatIfPreference from the orchestrator was never intended. Without it a + # -WhatIf run writes no report while still returning the path below, so the + # orchestrator reports "HTML report written" for a file that does not exist. + Set-Content -Path $OutputPath -Value $sb.ToString() -Encoding UTF8 -NoNewline:$false -WhatIf:$false + return $OutputPath +} diff --git a/Products/Purview/Modules/Write-PurviewValidationReport.ps1 b/Products/Purview/Modules/Write-PurviewValidationReport.ps1 new file mode 100644 index 0000000..3e47ded --- /dev/null +++ b/Products/Purview/Modules/Write-PurviewValidationReport.ps1 @@ -0,0 +1,446 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Renders the Purview configuration-validation HTML report and JSON sidecar. + +.DESCRIPTION + Both artifacts come from one canonical model. The renderer computes no + facts of its own, so the HTML and the JSON can never disagree. + + The HTML is self-contained: no external stylesheet, font, or script. All + result content is server-rendered and HTML-encoded. The only script is a + filter that toggles visibility of already-rendered rows, so no tenant value + is ever interpolated into JavaScript. + + EXPORTS (via dot-source): + * ConvertTo-PurviewValidationHtml + * Write-PurviewValidationReport +#> + +Set-StrictMode -Version Latest + +function Get-PurviewValidationStatusClass { + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)] [string] $Status) + + switch ($Status) { + 'Matched' { return 'matched' } + 'Drift' { return 'drift' } + 'Not evaluated' { return 'not-evaluated' } + 'Informational' { return 'informational' } + 'Collection failed' { return 'collection-failed' } + default { return 'informational' } + } +} + +function ConvertTo-PurviewValidationHtml { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] [psobject] $Model + ) + + function Encode([object] $Value) { + return [Net.WebUtility]::HtmlEncode([string]$Value) + } + + $sb = [Text.StringBuilder]::new() + $null = $sb.AppendLine('') + $null = $sb.AppendLine('') + $null = $sb.AppendLine('') + $null = $sb.AppendLine('Purview Tenant Validation') + $null = $sb.AppendLine(@' + +'@) + $null = $sb.AppendLine('
') + + $null = $sb.AppendLine('
') + $null = $sb.AppendLine('
Clawpilot read-only tenant assessment
') + $null = $sb.AppendLine('

Purview Tenant Validation

') + $contextText = if ($Model.AssessmentMode -eq 'PlanComparison') { + "Compared with Deployment Plan $($Model.Plan.PlanReference)." + } else { + 'Guide-only Good, Better, and Best assessment. No configuration intent was assumed.' + } + $null = $sb.AppendLine(('

{0} Proven level: {1}. Provisional level: {2}.

' -f ` + (Encode $contextText), (Encode $Model.Summary.ProvenLevel), (Encode $Model.Summary.ProvisionalLevel))) + $null = $sb.AppendLine('
') + + $null = $sb.AppendLine(('
Configuration alignment, not compliance. {0}
Allow propagation time. Purview label, DLP, and tenant settings can take up to 24 hours to become visible to a read. Run validation after that window, not immediately after a deployment, or a still-propagating setting will be reported as drift.
' -f ` + (Encode $Model.Disclaimer))) + + $null = $sb.AppendLine('
') + foreach ($metric in @( + @('matched', 'Matched', $Model.Summary.Matched, 'Every managed field matched'), + @('drift', 'Drift', $Model.Summary.Drift, 'Final mismatch after retries'), + @('not-evaluated', 'Not evaluated', $Model.Summary.NotEvaluated, ( + 'Prerequisite unmet {0}, unreadable {1}' -f $Model.Summary.PrerequisiteUnmet, $Model.Summary.PrerequisiteUnknown)), + @('informational', 'Informational', $Model.Summary.Informational, 'Excluded or not configured by the plan'), + @('collection-failed', 'Collection failed', $Model.Summary.CollectionFailed, 'Read did not succeed') + )) { + $null = $sb.AppendLine(('
{1}
{2}
{3}
' -f ` + $metric[0], (Encode $metric[1]), $metric[2], (Encode $metric[3]))) + } + $null = $sb.AppendLine('
') + + $null = $sb.AppendLine('

Validation context

') + $null = $sb.AppendLine('

Correlates plan identity, tenant identity, collector coverage, and observation time.

') + $null = $sb.AppendLine('
') + + $null = $sb.AppendLine('

Service status

Connection and capability status used for this read-only observation.

') + + $null = $sb.AppendLine('

Guide tiers

Proven requires readable evidence for every cumulative control. Provisional allows indeterminate blockers but not a known gap.

') + $null = $sb.AppendLine(('
Proven: {0}
Provisional: {1}
' -f ` + (Encode $Model.Summary.ProvenLevel), (Encode $Model.Summary.ProvisionalLevel))) + $null = $sb.AppendLine('') + foreach ($control in @($Model.Guide.Controls)) { + $null = $sb.AppendLine(('' -f ` + (Encode $control.ControlId), (Encode $control.Level), (Encode $control.Baseline), (Encode $control.Summary))) + } + $null = $sb.AppendLine('
ControlTierBaselineSummary
{0}{1}{2}{3}
') + + $null = $sb.AppendLine('

Module summary

') + $null = $sb.AppendLine('

Result counts for each toolkit module represented in the plan.

') + $null = $sb.AppendLine('') + foreach ($module in $Model.Modules) { + $null = $sb.AppendLine(('' -f ` + (Encode $module.Module), $module.Total, $module.Matched, $module.Drift, ` + $module.NotEvaluated, $module.Informational, $module.CollectionFailed)) + } + $null = $sb.AppendLine('
ModuleActionsMatchedDriftNot evaluatedInformationalCollection failed
{0}{1}{2}{3}{4}{5}{6}
') + + $null = $sb.AppendLine('

Configuration results

') + $null = $sb.AppendLine('

Expected and observed values are normalized before comparison. Only fields the toolkit owns are scored.

') + $null = $sb.AppendLine('
') + $null = $sb.AppendLine(('' -f $Model.Summary.TotalActions)) + foreach ($filter in @( + @('matched', 'Matched', $Model.Summary.Matched), + @('drift', 'Drift', $Model.Summary.Drift), + @('not-evaluated', 'Not evaluated', $Model.Summary.NotEvaluated), + @('informational', 'Informational', $Model.Summary.Informational), + @('collection-failed', 'Collection failed', $Model.Summary.CollectionFailed) + )) { + $null = $sb.AppendLine(('' -f ` + $filter[0], (Encode $filter[1]), $filter[2])) + } + $null = $sb.AppendLine('') + $null = $sb.AppendLine('
') + + foreach ($module in $Model.Modules) { + $moduleResults = @($Model.Results | Where-Object Module -eq $module.Module) + $null = $sb.AppendLine(('
{1} ({2} actions)' -f ` + (Encode $module.Module), (Encode $module.Module), $moduleResults.Count)) + + foreach ($result in $moduleResults) { + $statusClass = Get-PurviewValidationStatusClass -Status $result.Status + $searchText = ('{0} {1} {2} {3} {4}' -f $result.Module, $result.ActionId, $result.Title, + $result.ExpectedSummary, $result.ObservedSummary).ToLowerInvariant() + + $null = $sb.AppendLine(('
' -f ` + $statusClass, (Encode $searchText))) + $null = $sb.AppendLine('
') + $null = $sb.AppendLine(('
{0}
{1}
' -f ` + (Encode $result.Title), (Encode $result.ActionId))) + $null = $sb.AppendLine(('
{1}
' -f ` + $statusClass, (Encode $result.Status))) + $null = $sb.AppendLine(('
Expected
{0}
' -f ` + (Encode $result.ExpectedSummary))) + $null = $sb.AppendLine(('
Observed
{0}
' -f ` + (Encode $result.ObservedSummary))) + $null = $sb.AppendLine('
') + $null = $sb.AppendLine(('
Assessment axesObservation: {0}
Intended state: {1}
Guide baseline: {2}
' -f ` + (Encode $result.Observation), (Encode $result.Intended), (Encode $result.Baseline))) + + $readDetail = if ($result.Query) { + '{0}
{1}' -f (Encode $result.Query), (Encode $result.Source) + } else { + 'No tenant read was attempted for this action.' + } + $null = $sb.AppendLine(('
Tenant read{0}
Attempts: {1} | Elapsed: {2} ms | Prerequisite: {3}
' -f ` + $readDetail, $result.Attempts, $result.ElapsedMs, (Encode $result.PrerequisiteDisposition))) + $null = $sb.AppendLine(('
Evidence{0}{1}
' -f ` + (Encode $result.Reason), + $(if ($result.Error) { '
' + (Encode $result.Error) + '' } else { '' }))) + $null = $sb.AppendLine(('
Next step{0}
' -f ` + (Encode $result.NextStep))) + + $unscored = @($result.UnscoredDifferences) + if ($unscored.Count -gt 0) { + $null = $sb.AppendLine('
Unscored differences
    ') + foreach ($difference in $unscored) { + $null = $sb.AppendLine(('
  • {0}: expected {1}, observed {2}
  • ' -f ` + (Encode $difference.Field), (Encode $difference.Expected), (Encode $difference.Observed))) + } + $null = $sb.AppendLine('
') + } else { + $null = $sb.AppendLine('
Unscored differencesNone recorded.
') + } + + $fields = @($result.Fields) + if ($fields.Count -gt 0) { + $null = $sb.AppendLine('
Managed fields
    ') + foreach ($field in $fields) { + $null = $sb.AppendLine(('
  • {0} [{1}] {2}: expected {3}, observed {4}
  • ' -f ` + (Encode $field.Field), (Encode $field.Comparator), + $(if ($field.Matched) { 'matched' } else { 'differs' }), + (Encode $field.ExpectedText), (Encode $field.ObservedText))) + } + $null = $sb.AppendLine('
') + } + + $null = $sb.AppendLine('
') + } + + $null = $sb.AppendLine('
') + } + + $null = $sb.AppendLine('
') + + $null = $sb.AppendLine('

Recommended next actions

') + $null = $sb.AppendLine('

Derived from final drift, unmet prerequisites, and read failures. This report makes no tenant changes.

') + if (@($Model.Recommendations).Count -eq 0 -and + @($Model.Guide.Blockers).Count -eq 0 -and + @($Model.ManualChecks).Count -eq 0) { + $null = $sb.AppendLine('
No follow-up is required. Every scored action matched the plan and no guide blockers remain.
') + } elseif (@($Model.Recommendations).Count -eq 0) { + $null = $sb.AppendLine('
No drift or read-failure follow-up is required, but guide blockers or manual checks remain in the sections below.
') + } else { + $null = $sb.AppendLine('
') + foreach ($recommendation in $Model.Recommendations) { + $null = $sb.AppendLine(('
{0}

{1}

{2}

{3}

' -f ` + (Encode $recommendation.Priority), (Encode $recommendation.Title), + (Encode $recommendation.Detail), (Encode $recommendation.ActionId))) + } + $null = $sb.AppendLine('
') + } + $null = $sb.AppendLine('
') + + $null = $sb.AppendLine('

Extensions and manual checks

Extensions are outside the primary guide mapping. Manual checks cannot be proven by the available read APIs.

') + $null = $sb.AppendLine(('
Extensions: {0}
Manual checks: {1}
' -f ` + @($Model.Extensions).Count, @($Model.ManualChecks).Count)) + $null = $sb.AppendLine('
') + + $null = $sb.AppendLine('

Technical appendix

Artifact identity, schema, mode, fingerprints, and diagnostics.

') + + $null = $sb.AppendLine(('
{0}. Validation run {1}. Read-only assessment; no tenant state was changed.
' -f ` + (Encode $Model.ArtifactReference), (Encode $Model.ValidationRunId))) + $null = $sb.AppendLine('
') + + # The only script in the artifact. It filters rows that are already present + # and encoded in the document; it never receives tenant data as JavaScript. + $null = $sb.AppendLine(@' + +'@) + $null = $sb.AppendLine('') + return $sb.ToString() +} + +function Write-PurviewValidationReport { + <# + Writes the HTML report and the JSON sidecar next to it. + + Writes use -WhatIf:$false because local evidence is not a tenant + change. A validation run that produced no artifact would be exactly the + run an operator most needs to read. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [psobject] $Model, + [Parameter(Mandatory)] [string] $OutputPath + ) + + $extension = [IO.Path]::GetExtension($OutputPath) + if ($extension -notin @('.html', '.htm')) { + throw [NotSupportedException]::new( + "Validation report output path must use an .html or .htm extension: $OutputPath" + ) + } + $jsonPath = [IO.Path]::ChangeExtension($OutputPath, '.json') + $resolvedHtmlPath = [IO.Path]::GetFullPath($OutputPath) + $resolvedJsonPath = [IO.Path]::GetFullPath($jsonPath) + if ($resolvedHtmlPath.Equals($resolvedJsonPath, [StringComparison]::OrdinalIgnoreCase)) { + throw 'Validation report HTML and JSON output paths must be different.' + } + + $directory = Split-Path -Parent $OutputPath + if ($directory -and -not (Test-Path -LiteralPath $directory)) { + New-Item -ItemType Directory -Path $directory -Force -WhatIf:$false | Out-Null + } + + $html = ConvertTo-PurviewValidationHtml -Model $Model + $json = $Model | ConvertTo-Json -Depth 25 + + Set-Content -LiteralPath $OutputPath -Value $html -Encoding UTF8 -NoNewline -WhatIf:$false + Set-Content -LiteralPath $jsonPath -Value $json -Encoding UTF8 -NoNewline -WhatIf:$false + + return [pscustomobject]@{ + HtmlPath = $OutputPath + JsonPath = $jsonPath + ValidationRunId = $Model.ValidationRunId + PlanId = $Model.Plan.PlanId + } +} diff --git a/Products/Purview/README.md b/Products/Purview/README.md index 54a5ff2..cc624f5 100644 --- a/Products/Purview/README.md +++ b/Products/Purview/README.md @@ -1,658 +1,802 @@ ---- -title: Purview -layout: default -nav_order: 2 -has_children: true -permalink: /purview/ ---- - -# Microsoft Purview Best Practice Deployment Toolkit - -PowerShell automation that applies Microsoft's recommended **Data Security -baseline** to a Microsoft 365 **Business Premium** tenant. Built for anyone -who needs a repeatable, idempotent way to bring a tenant to the Purview -baseline — partners/MSPs and in-house IT teams alike. - -The configuration applied here is taken directly from the Microsoft "Data -Security Best Practice Deployment" guide for Business Premium. - -> 🗺️ **Want the big picture first?** See the -> [**visual Deployment Framework**](docs/Deployment-Framework.html) — an -> interactive one-screen model of the whole method, from pre-deployment to -> post-deployment, showing how every tenant-wide change is piloted, applied -> idempotently, simulated, and only enforced after a Day-30 gate. Best viewed -> on the [docs site](https://microsoft.github.io/BestPractice_Deploy-Scripts/purview/deployment-framework/). -> -> 🧰 **On the support team / service desk?** See -> [**What's Changing — Support Team Guide**](docs/What-This-Tool-Does.html) — a -> visual, colour-coded summary of what changes in the tenant, what's on / -> opt-in / auto-detected, and what does *not* change, so you're ready for -> rollout support calls. Best viewed on the -> [docs site](https://microsoft.github.io/BestPractice_Deploy-Scripts/purview/whats-changing/). -> -> 📖 **New here?** Read [`docs/Scenarios.md`](docs/Scenarios.md) first — -> it explains every scenario the script covers, what changes when the -> customer's licensing changes, and what's deliberately out of scope. -> No PowerShell expertise required. -> -> 📋 **About to deploy this at a customer?** Read -> [`docs/Change-Management-Playbook.md`](docs/Change-Management-Playbook.md) -> — the script handles the engineering correctly; the playbook is what -> makes it safe in production (pre-deploy, day-of, day 1–5, day-30 -> promote-from-simulation gate). Pairs with -> [`docs/Retention-Default-Risk.md`](docs/Retention-Default-Risk.md) and -> [`docs/DLP-Simulation-Exit-Runbook.md`](docs/DLP-Simulation-Exit-Runbook.md). -> -> 👥 **Communicating to end users?** Send -> [`docs/End-User-Adoption-Guide.md`](docs/End-User-Adoption-Guide.md) -> to your users **before** the rollout. It explains what they'll see, -> what changes after 30 days, and includes ready-to-edit email -> templates for T-7 / T-0 / T+30. - -> ⚠️ **Always pilot in a test tenant before applying to production.** -> Sensitivity-label and DLP policies are tenant-wide and can affect every -> user. Some changes can take **up to 24 hours** to fully propagate. - -> 🚨 **Retention default deletes mail at 7 years.** The default policy -> deletes Exchange mail older than 7 years tenant-wide. This duration -> aligns with most common SMB regulatory frameworks (ATO / IRS / SEC / -> ASIC) but is **not appropriate for every customer** — some verticals -> (e.g. paediatric healthcare records) require longer; some customers -> want no automatic deletion at all. See -> [`docs/Retention-Default-Risk.md`](docs/Retention-Default-Risk.md) -> before deploying into a regulated tenant. - ---- - -## What this toolkit does - -| # | Task | Default state | -|---|---------------------|----------------------------------------------------------------------------------------------------------------------| -| 1 | **Tenant settings** | Enables Unified Audit Log, SharePoint AIP integration, PDF labelling | -| 2 | **Sensitivity labels** | Creates `Public`, `General` (label **group** + 2 assignable children), `Confidential` (parent + 2 sub-labels), `Highly Confidential` (parent + 2 sub-labels) — identified/adopted by Microsoft's stable internal signature so the taxonomy lands correctly on any tenant/locale/scheme. Encryption: `Highly Confidential\All Employees` uses Co-Author rights (Template); `Confidential\TrustedPeople` uses UserDefined/Encrypt-Only; `Highly Confidential\Specific People` uses UserDefined/Do-Not-Forward; all rights scoped to your tenant only via `{TenantDomain}`. Labels ordered, then published with `General \ Anyone (unrestricted)` as the email default. The 4 published labels (`General \ Anyone (unrestricted)`, `Confidential\All Employees`, `Highly Confidential\All Employees`, `Public`) carry container scope (`Site, UnifiedGroup`) so they appear in the Purview portal's Scope picker for Teams, Microsoft 365 Groups, and SharePoint sites — `-SkipContainerLabels` strips those bits without dropping the labels. "Inherit label from attachments" (`AttachmentAction = 'Automatic'`) is on by default. | -| 3 | **DLP policies** | On Business Premium, two policies (per Microsoft guidance): one for Exchange and one for SharePoint + OneDrive, both blocking external sharing of content labelled `Confidential\AllEmployees`, `Confidential\TrustedPeople`, `Highly Confidential\HCAllEmps`, or `Highly Confidential\HCSpecificPeople`. On **E5 / Microsoft Purview Suite**, license detection adds a third **Endpoint DLP** policy (device copy / print / USB / network-share auditing) matching the same 4 labels. All are created in **simulation** by default. Match condition uses the label **GUID**, not the display name. | -| 4 | **Retention** | **Opt-in** (pass `-ApplyRetention`). Exchange mailbox retention — keep 7 years, then delete (measured from item creation). | - -### Optional add-ons - -| Switch | What it does | Pre-requisite | -|----------------------------|----------------------------------------------------------------------------------------------------|------------------------------------------------------------------------| -| `-SkipContainerLabels` | Opt out of `Group.Unified` `EnableMIPLabels=True` AND strip the container-scope bits (`Site`, `UnifiedGroup`) from the 3 published labels' `ContentType`. Container labels run by default (BP includes Entra ID P1, the AAD-side requirement). | None — opt-out only. | -| `-EnablePremiumAudit` | Adds `SearchQueryInitiated` to the supplied mailbox(es) `AuditOwner`. | Microsoft 365 Audit (**Premium**) licence | -| `-AdoptExisting` | Allows updating labels / policies that already exist but were not created by this toolkit. | Audit existing config first. | - ---- - -## Prerequisites - -### Licensing - -The toolkit is designed for **Microsoft 365 Business Premium** as the -baseline. Some optional features require a higher SKU: - -| Feature | Minimum license | How it's gated | -|-----------------------------------------------------------|---------------------------------------|----------------------------------------| -| Sensitivity labels (Personal / Public / General / Confidential / Highly Confidential) | Business Premium | Always on | -| Encryption + content marking on labels | Business Premium | Always on | -| DLP for Exchange Online | Business Premium | Always on | -| DLP for SharePoint + OneDrive | Business Premium | Always on | -| Retention policies (Exchange) | Business Premium | Always on | -| Unified audit log (standard, 90-day retention) | Business Premium | Always on | -| Container labels (Group.Unified `EnableMIPLabels`) | Business Premium (AAD P1+) | Default on (BP includes Entra ID P1, the AAD-side requirement); opt out with `-SkipContainerLabels`. License auto-detect skips it automatically when no recognised BP/E5/Purview Suite SKU is found. | -| Container scope on the 4 published labels (`Site`, `UnifiedGroup` on `General \ Anyone (unrestricted)` / `Confidential\All Employees` / `Highly Confidential\All Employees`) | Business Premium (AAD P1+) | Default on; gated by the same `-SkipContainerLabels` switch. When stripped, the labels still ship with `File, Email` scope (no failure). Adoption uses UNION-not-replace so a customer-added scope is never lost. | -| Premium Audit (1-year retention, `SearchQueryInitiated`) | E5 / Audit (Premium) add-on | Opt-in via `-EnablePremiumAudit` | -| Endpoint DLP (Devices) | E5 / Purview Suite | **In the default config; auto-created in simulation** when license auto-detect finds an E5 / Purview Suite SKU. Soft-skipped on Business Premium and under `-BPOnly` (the Exchange + SPO/ODB policies still deploy). | -| DLP for Defender for Cloud Apps / on-prem / Power BI | E5 / Purview Suite | Not configured by default; rejected by `-BPOnly` | - -**Pass `-BPOnly`** to hard-block any E5-only opt-ins. The script refuses to -run if `-EnablePremiumAudit` is set, and the DLP module rejects custom -config workloads that require E5. Container labels still run under -`-BPOnly` because Business Premium includes Entra ID P1 (the AAD-side -requirement) — pass `-SkipContainerLabels` if you need to opt out. - -```powershell -# Business Premium customer — strict mode -.\Deploy-PurviewBestPractice.ps1 ` - -TenantAdminUpn admin@contoso.onmicrosoft.com ` - -BPOnly -``` - -### Admin role on the customer tenant - -The signing-in admin needs: - -* **Compliance Administrator** *and* **Compliance Data Administrator** - (sensitivity labels, DLP, retention) -* **SharePoint Administrator** (tenant settings) -* **Exchange Administrator** (audit log, mailbox audit) -* **Groups Administrator** *or* **Global Administrator** — required for - container labels (`Group.Unified` directory setting). Pass - `-SkipContainerLabels` to omit this role requirement. - -> Or sign in as **Global Administrator** for simplicity. - -### PowerShell modules - -> The toolkit's `Connect-PurviewServices` helper auto-detects missing modules -> and offers to install them from PSGallery (or installs silently when the -> deploy script is invoked with `-AutoInstallModules`). You can also install -> them manually beforehand: - -| Module | Required for | Install | -|---------------------------------------------------------|-----------------------------------------|--------------------------------------------------------------------------------------| -| `ExchangeOnlineManagement` | Always (EXO + IPPS) | `Install-Module ExchangeOnlineManagement -Scope CurrentUser` | -| `Microsoft.Online.SharePoint.PowerShell` | Tenant settings + label policy publish | `Install-Module Microsoft.Online.SharePoint.PowerShell -Scope CurrentUser` | -| `Microsoft.Graph.Authentication` | Whenever Graph is used (license auto-detect, tenant-identity confirm, container labels) | `Install-Module Microsoft.Graph.Authentication -Scope CurrentUser` | -| `Microsoft.Graph.Beta.Identity.DirectoryManagement` | Container labels (default; skipped only when `-SkipContainerLabels` is passed) | `Install-Module Microsoft.Graph.Beta.Identity.DirectoryManagement -Scope CurrentUser` | - -### Microsoft Graph delegated scopes (least-privilege) - -The script requests only the scopes it actually needs for the run you ask for: - -| When Graph is used | Scopes requested | -|-------------------------------------------------------------------|-------------------------------------------------| -| License auto-detect + tenant-identity confirm (always when Graph is connected) | `Organization.Read.All` *(read-only)* | -| Container labels (default; skipped only when `-SkipContainerLabels` is passed) | `Organization.Read.All` + `Directory.ReadWrite.All` *(requested upfront in one consent)* | - -`Directory.ReadWrite.All` is the historically-consented, known-working scope on most tenants for reading `/directorySettingTemplates` and writing `/settings` where `Group.Unified.EnableMIPLabels` lives. We did experiment with the narrower `GroupSettings.ReadWrite.All` but it triggered `403 Authorization_RequestDenied` on tenants whose admins had only ever consented to `Directory.ReadWrite.All`. The license auto-detect itself (`/subscribedSkus`, `/organization`) is read-only via `Organization.Read.All`. - -### PowerShell version - -**PowerShell 7+ (`pwsh.exe`) is required.** The deploy script hard-fails on -Windows PowerShell 5.1 because the Exchange Online v3 REST channel and the -Microsoft.Graph SDK rely on .NET Core APIs that PS 5.1 does not expose, which -causes silent auth and cmdlet-discovery failures mid-run. - -```powershell -# Install PowerShell 7 (one-time): -winget install --id Microsoft.PowerShell --source winget -# Or download from: https://aka.ms/PowerShell-Release -``` - -Then run the toolkit from a `pwsh` prompt (not `powershell`). The -`Microsoft.Online.SharePoint.PowerShell` module is loaded via -`Import-Module -UseWindowsPowerShell` automatically when running under PS 7, -so no separate PS 5.1 step is needed. - -### Sign-in prompts (WAM broker) - -Each customer-tenant deploy needs sign-in tokens for up to four services -(Exchange Online, Security & Compliance / IPPS, SharePoint Online, Microsoft -Graph). The first run on a new admin account therefore shows several prompts. -On subsequent runs the **Windows Authentication Manager (WAM) broker** turns -most of those into single-click "Continue" confirmations instead of full -browser sign-ins. - -WAM is on by default in current PowerShell modules -(`Microsoft.Graph.Authentication` 2.x and `ExchangeOnlineManagement` 3.3+) -when **all** of the following are true: - -| Requirement | Why | -|---|---| -| Running on **Windows 10 1507 (build 10240)** or later, or **Windows Server 2019 (build 17763)** or later | WAM is a Windows-only OS component. | -| `pwsh.exe` was launched in your normal **interactive** desktop session (no RunAs, no scheduled task / SYSTEM, no SSH/remote PowerShell) | WAM uses the desktop user's signed-in tokens. | -| `Microsoft.Graph.Authentication` ≥ 2.0 and `ExchangeOnlineManagement` ≥ 3.3 are installed | Older versions don't have WAM integration. Refresh with `Update-Module`. | - -The connect helper checks all of these at start-up and warns when WAM is not -available — `Get-PurviewRunLog` records the result as a -`SessionGuard:Startup` entry with `reason=wam-ready` or `wam-not-ready` plus -the diagnostic fields it inspected. - -Even when WAM is available, the following still cause additional prompts: - -* **First-time consent** — granting `Organization.Read.All` (or - `Directory.ReadWrite.All` for container labels) on a new tenant always - shows the full consent screen once. -* **GDAP tenant switches** — the Graph guard reauths when it detects the - cached session belongs to a different customer tenant - (`reason=tenant-domain-mismatch` in the run log). -* **Missing scopes** — adding a new scope to an existing cached session - triggers a re-consent prompt (`reason=missing-scopes`). -* **SharePoint Online** — `Connect-SPOService` does not expose WAM, so the - SPO connect always uses its own MSAL flow. - -When you see more prompts than expected, run -`Get-PurviewRunLog | Where-Object Action -like 'SessionGuard:*'` after the -deploy — every prompt is preceded by a structured log entry that names -which check failed and what was expected vs observed. - -### MSAL DLL mismatch (Connect-MgGraph 'Method not found') - -`ExchangeOnlineManagement` and `Microsoft.Graph.Authentication` each ship -their own copy of `Microsoft.Identity.Client.dll` (MSAL) under their module -folders. When both modules are imported into the same PowerShell process, -the CLR binds to whichever copy was loaded FIRST and ignores the other. - -If the two modules were compiled against different MSAL versions and a -method signature changed between them (a recurring source of breakage in -MSAL 4.x — e.g. `BaseAbstractApplicationBuilder.WithLogging(IIdentity -Logger, Boolean)` moved between 4.82 and 4.83), `Connect-MgGraph` throws: - -```text -InteractiveBrowserCredential authentication failed: -Method not found: '!0 Microsoft.Identity.Client..(...)'. -``` - -That message reads like an auth failure but is purely a DLL-binding -mismatch — no credential / scope / tenant change will fix it. - -**Detection.** The connect helper runs `Test-MsalDllCompatibility` at -start-up: it locates the `Microsoft.Identity.Client.dll` bundled by each -module (PS edition-aware: `netCore` / `Dependencies\Core` on PS 7, -`netFramework` / `Dependencies\Desktop` on PS 5.1), reads each DLL's -`FileVersion`, and also checks whether MSAL is already loaded in the -process. The outcome is logged as a `SessionGuard:Startup` entry with -`reason=msal-aligned` or `msal-mismatch` plus the DLL paths/versions. - -**Mitigation 1: Graph-first connect order.** The connect helper imports -`Microsoft.Graph.Authentication` and calls `Connect-MgGraph` **before** -`Connect-ExchangeOnline`, mirroring the pattern used by -[`microsoft/zerotrustassessment`](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/public/Connect-ZtAssessment.ps1). -When Microsoft Graph loads its bundled MSAL into the AppDomain first, -`Connect-MgGraph` no longer throws `Method not found`. Verified -end-to-end on EXO 3.10.0 + Microsoft.Graph.Authentication 2.37.0 (which -ship MSAL 4.83.1.0 and 4.82.1.0 respectively). - -**Mitigation 2: `-DisableWAM` for Exchange Online / IPPS.** Connecting -Graph first solves the `Connect-MgGraph` failure but introduces a new -problem: when `Connect-ExchangeOnline` runs against Graph's older -MSAL 4.82, EXO 3.10's WAM (Windows Account Manager) broker code path -throws: - -```text -System.NullReferenceException: Object reference not set to an instance of an object. - at Microsoft.Identity.Client.Platforms.Features.RuntimeBroker.RuntimeBroker..ctor(...) -``` - -EXO's WAM `RuntimeBroker` constructor expects MSAL 4.83's surface, and -the older MSAL doesn't satisfy it. When the helper's pre-flight check -detects a mismatch (`SessionGuard:Startup reason=msal-mismatch`), it -automatically passes `-DisableWAM` to both `Connect-ExchangeOnline` and -`Connect-IPPSSession`. WAM is bypassed in favour of the standard -interactive browser flow, which doesn't invoke `RuntimeBroker`. The -operator still gets a one-click browser sign-in via the system default -browser; only the WAM 1-click pop-up is suppressed. Logged as -`SessionGuard:EXO/IPPS reason=disable-wam-msal-mismatch`. Auto-disables -the moment the bundled MSAL versions realign (WAM returns automatically). - -> **What about `-UseDeviceCode` for Graph?** Doesn't help — Graph 2.37's -> `DeviceCodeCredential` and `InteractiveBrowserCredential` constructors -> both invoke the same broken MSAL method overload, so switching auth -> flow doesn't change the JIT lookup. `microsoft/zerotrustassessment` -> has the same `#TODO: UseDeviceCode does not work with ExchangeOnline` -> caveat for the EXO side. - -> **What about pre-loading Graph's older MSAL?** Doesn't work either. -> `ExchangeOnlineManagement` 3.10's module manifest strong-binds to its -> bundled MSAL 4.83.1.0, so pre-loading Graph's 4.82.1.0 with -> `[Reflection.Assembly]::LoadFrom` causes `Connect-ExchangeOnline` to -> throw `FileLoadException: manifest does not match` (HRESULT -> 0x80131040). - -**Manual fallback — restart PowerShell.** If another module loaded MSAL -into the process *before* this script started running (e.g. you ran -`Connect-ExchangeOnline` manually, or imported `Az.*`, `PnP.PowerShell`, -or `Microsoft.Graph.*` first), neither mitigation can help — .NET -cannot swap a loaded assembly. The connect helper detects this in its -pre-flight check (`loadedMsalVersion` is set in the -`SessionGuard:Startup` entry) and tells the operator to: - -1. **Close** the PowerShell window. -2. **Open a fresh `pwsh`** with no other Microsoft modules pre-imported. -3. **Re-run** the script. - -If `Connect-MgGraph` still throws `Method not found: 'Microsoft.Identity…'`, -the helper catches the `MissingMethodException` and emits restart-pwsh -guidance with `SessionGuard:Graph reason=msal-method-not-found-restart-pwsh`. -Same for an EXO `NullReferenceException` in `RuntimeBroker` — -`SessionGuard:EXO reason=wam-runtimebroker-nullref`. - -**Last resort — pin a matching EXO/Graph pair.** Microsoft realigns the -bundled MSAL versions between releases, so updating both modules with -`Update-Module ExchangeOnlineManagement -Force; Update-Module Microsoft.Graph.Authentication -Force` -will eventually close the gap. At the time of writing, no shipped -`ExchangeOnlineManagement` version on PSGallery bundles the same MSAL -as `Microsoft.Graph.Authentication` 2.37.0 (`4.82.1.0`) — EXO -3.8/3.9.0/3.9.2/3.10 ship 4.66.1/4.68.0/4.74.1/4.83.1 respectively — so -this fallback is rarely useful today. If you do want to try pinning a -matching pair manually: - -```powershell -# Find an installed EXO version whose MSAL DLL matches Graph's -Get-Module ExchangeOnlineManagement -ListAvailable | ForEach-Object { - $dll = Get-ChildItem $_.ModuleBase -Filter Microsoft.Identity.Client.dll -Recurse | - Where-Object FullName -match 'netCore' | Select-Object -First 1 - [pscustomobject]@{ - EXO = $_.Version - MSAL = if ($dll) { $dll.VersionInfo.FileVersion } else { '(none)' } - } -} - -# Then pin in a fresh window before running the script: -Import-Module ExchangeOnlineManagement -RequiredVersion -Force -``` - -The Graph-first connect order + auto `-DisableWAM` for EXO/IPPS is the -primary fix; the pwsh-restart is the only reliable manual fallback when -MSAL is already loaded with the wrong version. - -### Microsoft.Graph.Beta sub-module version pin (`Assembly with same name is already loaded`) - -After updating `Microsoft.Graph.Authentication` (e.g. to fix the MSAL -mismatch above) the deployment can later fail at **step [5/5] container -labels** with: - -``` -The 'Get-MgBetaDirectorySetting' command was found in the module -'Microsoft.Graph.Beta.Identity.DirectoryManagement', but the module could -not be loaded due to the following error: [Could not load file or -assembly 'Microsoft.Graph.Authentication, Version=2.36.1.0, …'. -Assembly with same name is already loaded] -``` - -**Why it happens.** Every `Microsoft.Graph.Beta.*` sub-module's manifest -strict-pins `Microsoft.Graph.Authentication` to an **exact** version in -`RequiredModules` (not a minimum). When you update `Authentication` to a -newer version without also updating the matching Beta sub-modules, -PowerShell tries to load the older Auth version that Beta requires, but -the newer Auth is already in the AppDomain — only one version of an -assembly can be loaded per AppDomain, so `Import-Module` throws. - -**Auto-detect + self-heal.** The connect helper runs `Test-GraphBetaCompat` -in the pre-flight (just before importing the Beta sub-module). It -compares the Beta module's `RequiredModules` pin against the loaded / -installed `Microsoft.Graph.Authentication` version and: - -* When the pin matches: logs `SessionGuard:GraphBeta version-pin-aligned` - and force-imports the matching version via `Import-Module - -RequiredVersion` (so the pick is deterministic even with multiple - Beta versions installed side-by-side). -* When the pin **does not** match: **unconditionally** installs the - matching Beta version side-by-side - (`Install-Module … -RequiredVersion -Scope CurrentUser -Force - -AllowClobber`), re-validates, then force-imports it. No - `-AutoInstallModules` flag is required — this is a deterministic, - non-destructive recovery (one specific sub-module version, installed - side-by-side; existing versions are untouched). Matches the existing - PSGallery trust restore pattern. -* If `Install-Module` fails (PSGallery unreachable, locked-down - environment, etc.): fails fast at pre-flight with the exact - `Install-Module` command to run manually. Operators in CLM / - no-internet environments can pre-pin the matching Beta version before - running the script. - -Log keys: `version-pin-aligned`, `version-pin-mismatch`, -`auto-installed-matching-version`, `auto-install-failed`, -`imported-matching-version`, `no-matching-version-installed`. - -**Manual fix.** If you don't want the auto-install, run -`Update-Module Microsoft.Graph.Beta -Force` (updates the whole family in -one shot) or the targeted `Install-Module` printed in the warning, then -re-run the deploy. - ---- - -## File layout - -``` -Configuration/Purview/ -├── Deploy-PurviewBestPractice.ps1 <- master orchestrator (start here) -├── Config/ -│ └── PurviewConfig.psd1 <- all label / DLP / retention names & values -├── Modules/ -│ ├── Connect-PurviewServices.ps1 <- service connection helper -│ ├── Setup-TenantSettings.ps1 <- task 1 -│ ├── Setup-SensitivityLabels.ps1 <- task 2 -│ ├── Setup-DLP.ps1 <- task 3 -│ └── Setup-Retention.ps1 <- task 4 -└── README.md -``` - -Each `Setup-*.ps1` script is self-contained and can be run on its own; the -master script just orchestrates them. - ---- - -## Quick start - -```powershell -# 1. Clone / download the repo and open a PowerShell prompt in this folder. - -# 2. Preview every change WITHOUT applying (SharePoint URL is auto-derived): -.\Deploy-PurviewBestPractice.ps1 ` - -TenantAdminUpn admin@contoso.onmicrosoft.com ` - -WhatIf - -# 3. Apply the baseline: -.\Deploy-PurviewBestPractice.ps1 ` - -TenantAdminUpn admin@contoso.onmicrosoft.com -``` - -The SharePoint admin URL is auto-derived from the tenant's initial -`onmicrosoft.com` domain after Exchange Online connects. Override only when -auto-derivation fails (e.g. multi-geo or unusual domain configurations): - -```powershell -.\Deploy-PurviewBestPractice.ps1 ` - -TenantAdminUpn admin@contoso.onmicrosoft.com ` - -SharePointAdminUrl https://contoso-admin.sharepoint.com -``` - -### Delegated admin (GDAP) scenario - -```powershell -.\Deploy-PurviewBestPractice.ps1 ` - -TenantAdminUpn delegatedadmin@fabrikam.onmicrosoft.com ` - -DelegatedOrganization contoso.onmicrosoft.com -``` - -### Selective deployment - -```powershell -# Just labels and DLP — skip tenant settings (retention is opt-in) -.\Deploy-PurviewBestPractice.ps1 ` - -TenantAdminUpn admin@contoso.onmicrosoft.com ` - -SkipTenantSettings -``` - -### Premium audit (E5 add-on) - -```powershell -.\Deploy-PurviewBestPractice.ps1 ` - -TenantAdminUpn admin@contoso.onmicrosoft.com ` - -EnablePremiumAudit -PremiumAuditMailbox 'admin@contoso.onmicrosoft.com' -``` - ---- - -## Customisation - -All names, durations, label texts, encryption rights, and DLP behaviour live -in `Config/PurviewConfig.psd1`. Fork the repo, edit that file, and re-run. - -The most common customisations: - -* Change the default applied label (`LabelPolicy.DefaultLabel`) -* Add SharePoint / OneDrive to retention scope (`Retention.Locations`) -* Change retention from the 7-year default to N years (`Retention.DurationDays`, in days — `2555` = 7 years) -* Tighten encryption rights (`EncryptionRightsDefinitions`) - -> 📖 **Full key-by-key guide:** see the -> [Configuration Reference](docs/Configuration-Reference.md) — every tunable in -> `PurviewConfig.psd1`, its default, and how to change it safely. - -> **Don't change** the `ManagedByTag` after a deployment — it's how the -> toolkit recognises objects it owns on subsequent runs. - ---- - -## Idempotency & safety - -* Every action does a `Get-*` first and skips when the desired state is - already in place. -* Every object created by the toolkit is stamped with - `[Managed by SMBTool Purview Toolkit]` in its `Comment` / description. -* If a label, DLP policy, retention policy, or rule with the toolkit's name - **already exists but is not stamped**, the run **stops with a clear error**. - Pass `-AdoptExisting` to take ownership and update the existing object. -* `-WhatIf` prints every state-changing call without executing it. - When run end-to-end against a clean tenant, downstream modules (DLP, - retention, label policy publish) reference labels that do not yet exist — - these are surfaced as `What if: ... would be created earlier in this run` - notices with a placeholder GUID, so the preview is complete and never - errors out. In apply mode the labels really are created in the right order. -* `-Confirm` is honoured by the underlying cmdlets but **does not** prompt - per high-impact change in the orchestrator. The toolkit is designed for - unattended runs (`SupportsShouldProcess` is wired with `ConfirmImpact = 'None'`, - and every internal call passes `-Confirm:$false`). To preview without - applying use `-WhatIf`; to gate the run on operator confirmation, leave - the preflight `y/N` prompt enabled (i.e. do **not** pass `-NonInteractive`). -* The master script prints a preflight summary and asks for `y/N` - confirmation before any change (skip with `-NonInteractive`). - ---- - -## Encryption rights — tenant-scoped by default - -One Template-protected label applies usage rights (the other two encrypted -sub-labels are UserDefined — the author picks recipients each time, not a -fixed rights bundle): - -* **`Highly Confidential \ All Employees`** — uses Microsoft's wider - **Co-Author** bundle (per-label override): - - ``` - VIEW, VIEWRIGHTSDATA, DOCEDIT, EDIT, EXTRACT, PRINT, OBJMODEL, REPLY, REPLYALL, FORWARD - ``` - - Co-Author adds **EXTRACT** (copy), **PRINT**, and **OBJMODEL** (Office - object-model access — needed for macros and simultaneous editing). - Granting OBJMODEL is **necessary but not sufficient** for Office - multi-user co-authoring on encrypted files: the tenant-wide switch - `Set-PolicyConfig -EnableLabelCoauth:$true` (toolkit param - `-EnableLabelCoAuthoring`, opt-in) must also be on. - -The global default **Reviewer** bundle (no Copy, no Print, no OBJMODEL) -applies to any OTHER Template-encrypted label that does NOT carry a -per-label override: - - ``` - VIEW, VIEWRIGHTSDATA, DOCEDIT, EDIT, REPLY, REPLYALL, FORWARD - ``` - -To change the default, edit `EncryptionRightsDefinitions` in -`PurviewConfig.psd1`. To override for a specific label, add an -`EncryptionRightsDefinitions = '...'` field on that label hashtable -(same token semantics as the global default). - -The rights are scoped to **all users in your tenant only** via the -`{TenantDomain}` token at the start of `EncryptionRightsDefinitions`. At -run time the toolkit resolves the token against your auto-discovered -tenant identity (default verified domain, falling back to your initial -`*.onmicrosoft.com`). Per Entra rights-management semantics, ANY verified -domain expands to ALL verified domains in the tenant — so specifying one -is enough to cover the whole tenant, and it explicitly EXCLUDES external -`AuthenticatedUsers` from other Microsoft 365 tenants, B2B guests -attached to other tenants, social/MSA accounts, and OTP users. - -If you intentionally need the broader cross-tenant scope (e.g. you -collaborate via the label with partner organizations that do NOT yet have -a B2B trust into your tenant), edit `EncryptionRightsDefinitions` to use -`{AuthenticatedUsers}` (or the literal `AuthenticatedUsers`) and document -the decision. The toolkit refuses to silently fall back to that scope — -if the `{TenantDomain}` token cannot be resolved (no Graph/EXO identity), -the labels module aborts with an actionable error. - -> **Retroactive scope changes only apply to NEW labelling.** Existing -> protected files carry the use-license they had at the moment of -> labelling. Tightening the rights bundle does NOT retroactively revoke -> external access to files that were already labelled and shared. To -> tighten retroactively, re-label / re-protect the affected files. See -> [`docs/Change-Management-Playbook.md`](docs/Change-Management-Playbook.md#known-sharp-edges). - ---- - -## Known sharp edges (read before promoting to production) - -These are **not bugs**. They are correct defaults that require a customer -conversation. Full detail and mitigations are in -[`docs/Change-Management-Playbook.md`](docs/Change-Management-Playbook.md#known-sharp-edges). - -| Sharp edge | Default behaviour | Why it matters | -|---|---|---| -| **Encrypted-label scope = your tenant only (not retroactive)** | The Template-encrypted `Highly Confidential\All Employees` sub-label grants rights to all users in your tenant via the `{TenantDomain}` token. | Files labelled BEFORE this toolkit ran (or before you tightened the scope) keep their old rights — re-label them if you need the new scope applied. B2B guests from OTHER tenants are excluded by default; if you need cross-tenant collaboration, switch to `{AuthenticatedUsers}` deliberately. | -| **`Highly Confidential\Specific People` prompts users** | Word/Excel/PowerPoint asks the user to pick who can open the file. Outlook auto-applies Do Not Forward. | Most users do not know how to respond to this dialog. **Not** published to end users by default — keep it that way unless you ship user training. | -| **`Confidential\TrustedPeople` also prompts users** | UserDefined encryption — Outlook auto-applies Encrypt-Only (recipients can reshare); Office desktop apps prompt the recipient list. | Same usability caveat as the HC variant. **Not** published by default. Encryption is enforced once published. | -| **Container labels are one-way** | `Group.Unified` `EnableMIPLabels=True` is set by default on every run (BP is the licensing floor and BP includes Entra ID P1). Pass `-SkipContainerLabels` to opt out before the first run. | Microsoft does not officially support reverting once set. Treat the default-on behaviour as decision-grade — confirm with the customer up front. | -| **Endpoint DLP without device onboarding is theatre** | Endpoint DLP policy is created on E5 tenants when not `-BPOnly`. | Without Defender / Purview device onboarding, the policy enforces nothing. Looks deployed; protects nothing. | -| **7-year retention deletes mail** | Tenant-wide retention deletes Exchange mail older than 7 years. | Aligns with most SMB regulatory frameworks (ATO / IRS / SEC / ASIC), but still wrong for some verticals (e.g. paediatric healthcare) and for customers who want no automatic deletion. See [`docs/Retention-Default-Risk.md`](docs/Retention-Default-Risk.md). | -| **DLP starts in simulation** | `DlpStartInSimulation = $true`. Telemetry only — nothing is blocked. | Has to be **explicitly promoted** at day 30 via the [`docs/DLP-Simulation-Exit-Runbook.md`](docs/DLP-Simulation-Exit-Runbook.md), or it is permanent shelfware. | - ---- - -## Troubleshooting - -| Symptom | Cause / fix | -|-------------------------------------------------------------|----------------------------------------------------------------------------------------------| -| `Required PowerShell module 'X' is not installed` | Re-run with `-AutoInstallModules` to install missing modules automatically, or run the matching `Install-Module` command from the prerequisites table. | -| `Connect-SPOService failed ... Fallback (UseWindowsPowerShell) also failed: ... no valid module file was found` | PS 7's `Install-Module` only installs to the PS 7 module path; the SPO module's Windows-PowerShell-proxy fallback can't see it. Open `powershell.exe` (Windows PowerShell 5.1) once and run `Install-Module Microsoft.Online.SharePoint.PowerShell -Scope CurrentUser -Force -AllowClobber`, then re-run this script from `pwsh`. | -| `Label '...' already exists but is not managed by toolkit` | An existing label with the same name was found. Audit it, then re-run with `-AdoptExisting`. | -| `Get-SPOTenant: ... not connected` | Wrong `-SharePointAdminUrl`, or SPO module failed to load under PS 7 — verify `Microsoft.Online.SharePoint.PowerShell` is installed and re-run from a fresh `pwsh` prompt. | -| Users don't see new labels in Office | Labels can take up to 24 h to propagate; sign out / back in to force refresh. | -| DLP not blocking external sends | Allow up to ~1 h enforcement window; verify rule is `Mode=Enable` in the Purview portal. | -| `Connect-IPPSSession` fails after `Connect-ExchangeOnline` | They are separate sessions — both must succeed; modern auth + MFA both required. | - ---- - -## Out of scope - -This toolkit deliberately implements **only** the Business Premium baseline -from the source deck. The following are out of scope and require separate, -more advanced tooling: - -* Insider Risk Management -* Communication Compliance -* eDiscovery -* Auto-labelling (service-side & client-side) -* Multi-geo configurations -* Customer-specific protection scopes (per-domain, per-group) - -For the extended Purview guide referenced in the deck, see -. - ---- - -## Contributing - -Issues and PRs welcome. Please: - -* Keep changes idempotent. -* Don't introduce display-name-based matching for labels in DLP rules — use - GUIDs. -* Validate every change in a pilot tenant before opening a PR. - ---- - -## License - -Provided as-is, without warranty of any kind. Review and test before applying -to production tenants. See the repository root for licence information. - -## Disclaimer - -This sample script is not supported under any Microsoft standard support -program or service. The sample script is provided AS IS without warranty of -any kind. Microsoft further disclaims all implied warranties including, -without limitation, any implied warranties of merchantability or of fitness -for a particular purpose. The entire risk arising out of the use or -performance of the sample scripts and documentation remains with you. In no -event shall Microsoft, its authors, or anyone else involved in the creation, -production, or delivery of the scripts be liable for any damages whatsoever -(including, without limitation, damages for loss of business profits, -business interruption, loss of business information, or other pecuniary loss) -arising out of the use of or inability to use the sample scripts or -documentation, even if Microsoft has been advised of the possibility of such -damages. - -Please do not contact Microsoft support with any issues or concerns regarding -this script. +--- +title: Purview +layout: default +nav_order: 2 +has_children: true +permalink: /purview/ +--- + +# Microsoft Purview Best Practice Deployment Toolkit + +PowerShell automation that applies Microsoft's recommended **Data Security +baseline** to a Microsoft 365 **Business Premium** tenant. Built for anyone +who needs a repeatable, idempotent way to bring a tenant to the Purview +baseline — partners/MSPs and in-house IT teams alike. + +The configuration applied here is taken directly from the Microsoft "Data +Security Best Practice Deployment" guide for Business Premium. + +> 🗺️ **Want the big picture first?** See the +> [**visual Deployment Framework**](docs/Deployment-Framework.html) — an +> interactive one-screen model of the whole method, from pre-deployment to +> post-deployment, showing how every tenant-wide change is piloted, applied +> idempotently, simulated, and only enforced after a Day-30 gate. Best viewed +> on the [docs site](https://microsoft.github.io/BestPractice_Deploy-Scripts/purview/deployment-framework/). +> +> 🧰 **On the support team / service desk?** See +> [**What's Changing — Support Team Guide**](docs/What-This-Tool-Does.html) — a +> visual, colour-coded summary of what changes in the tenant, what's on / +> opt-in / auto-detected, and what does *not* change, so you're ready for +> rollout support calls. Best viewed on the +> [docs site](https://microsoft.github.io/BestPractice_Deploy-Scripts/purview/whats-changing/). +> +> 📖 **New here?** Read [`docs/Scenarios.md`](docs/Scenarios.md) first — +> it explains every scenario the script covers, what changes when the +> customer's licensing changes, and what's deliberately out of scope. +> No PowerShell expertise required. +> +> 📋 **About to deploy this at a customer?** Read +> [`docs/Change-Management-Playbook.md`](docs/Change-Management-Playbook.md) +> — the script handles the engineering correctly; the playbook is what +> makes it safe in production (pre-deploy, day-of, day 1–5, day-30 +> promote-from-simulation gate). Pairs with +> [`docs/Retention-Default-Risk.md`](docs/Retention-Default-Risk.md) and +> [`docs/DLP-Simulation-Exit-Runbook.md`](docs/DLP-Simulation-Exit-Runbook.md). +> +> 👥 **Communicating to end users?** Send +> [`docs/End-User-Adoption-Guide.md`](docs/End-User-Adoption-Guide.md) +> to your users **before** the rollout. It explains what they'll see, +> what changes after 30 days, and includes ready-to-edit email +> templates for T-7 / T-0 / T+30. + +> ⚠️ **Always pilot in a test tenant before applying to production.** +> Sensitivity-label and DLP policies are tenant-wide and can affect every +> user. Some changes can take **up to 24 hours** to fully propagate. + +> 🚨 **Retention default deletes mail at 7 years.** The default policy +> deletes Exchange mail older than 7 years tenant-wide. This duration +> aligns with most common SMB regulatory frameworks (ATO / IRS / SEC / +> ASIC) but is **not appropriate for every customer** — some verticals +> (e.g. paediatric healthcare records) require longer; some customers +> want no automatic deletion at all. See +> [`docs/Retention-Default-Risk.md`](docs/Retention-Default-Risk.md) +> before deploying into a regulated tenant. + +--- + +## What this toolkit does + +| # | Task | Default state | +|---|---------------------|----------------------------------------------------------------------------------------------------------------------| +| 1 | **Tenant settings** | Enables Unified Audit Log, SharePoint AIP integration, PDF labelling | +| 2 | **Sensitivity labels** | Creates `Public`, `General` (label **group** + 2 assignable children), `Confidential` (parent + 2 sub-labels), `Highly Confidential` (parent + 2 sub-labels) — identified/adopted by Microsoft's stable internal signature so the taxonomy lands correctly on any tenant/locale/scheme. Encryption: `Highly Confidential\All Employees` uses Co-Author rights (Template); `Confidential\TrustedPeople` uses UserDefined/Encrypt-Only; `Highly Confidential\Specific People` uses UserDefined/Do-Not-Forward; all rights scoped to your tenant only via `{TenantDomain}`. Labels ordered, then published with `General \ Anyone (unrestricted)` as the email default. The 4 published labels (`General \ Anyone (unrestricted)`, `Confidential\All Employees`, `Highly Confidential\All Employees`, `Public`) carry container scope (`Site, UnifiedGroup`) so they appear in the Purview portal's Scope picker for Teams, Microsoft 365 Groups, and SharePoint sites — `-SkipContainerLabels` strips those bits without dropping the labels. "Inherit label from attachments" (`AttachmentAction = 'Automatic'`) is on by default. | +| 3 | **DLP policies** | On Business Premium, two policies (per Microsoft guidance): one for Exchange and one for SharePoint + OneDrive, both blocking external sharing of content labelled `Confidential\AllEmployees`, `Confidential\TrustedPeople`, `Highly Confidential\HCAllEmps`, or `Highly Confidential\HCSpecificPeople`. On **E5 / Microsoft Purview Suite**, license detection adds a third **Endpoint DLP** policy (device copy / print / USB / network-share auditing) matching the same 4 labels. All are created in **simulation** by default. Match condition uses the label **GUID**, not the display name. | +| 4 | **Retention** | **Opt-in** (pass `-ApplyRetention`). Exchange mailbox retention — keep 7 years, then delete (measured from item creation). | + +### Optional add-ons + +| Switch | What it does | Pre-requisite | +|----------------------------|----------------------------------------------------------------------------------------------------|------------------------------------------------------------------------| +| `-SkipContainerLabels` | Opt out of `Group.Unified` `EnableMIPLabels=True` AND strip the container-scope bits (`Site`, `UnifiedGroup`) from the 3 published labels' `ContentType`. Container labels run by default (BP includes Entra ID P1, the AAD-side requirement). | None — opt-out only. | +| `-EnablePremiumAudit` | Adds `SearchQueryInitiated` to the supplied mailbox(es) `AuditOwner`. | Microsoft 365 Audit (**Premium**) licence | +| `-AdoptExisting` | Allows updating labels / policies that already exist but were not created by this toolkit. | Audit existing config first. | + +--- + +## Prerequisites + +### Licensing + +The toolkit is designed for **Microsoft 365 Business Premium** as the +baseline. Some optional features require a higher SKU: + +| Feature | Minimum license | How it's gated | +|-----------------------------------------------------------|---------------------------------------|----------------------------------------| +| Sensitivity labels (Personal / Public / General / Confidential / Highly Confidential) | Business Premium | Always on | +| Encryption + content marking on labels | Business Premium | Always on | +| DLP for Exchange Online | Business Premium | Always on | +| DLP for SharePoint + OneDrive | Business Premium | Always on | +| Retention policies (Exchange) | Business Premium | Always on | +| Unified audit log (standard, 90-day retention) | Business Premium | Always on | +| Container labels (Group.Unified `EnableMIPLabels`) | Business Premium (AAD P1+) | Default on (BP includes Entra ID P1, the AAD-side requirement); opt out with `-SkipContainerLabels`. License auto-detect skips it automatically when no recognised BP/E5/Purview Suite SKU is found. | +| Container scope on the 4 published labels (`Site`, `UnifiedGroup` on `General \ Anyone (unrestricted)` / `Confidential\All Employees` / `Highly Confidential\All Employees`) | Business Premium (AAD P1+) | Default on; gated by the same `-SkipContainerLabels` switch. When stripped, the labels still ship with `File, Email` scope (no failure). Adoption uses UNION-not-replace so a customer-added scope is never lost. | +| Premium Audit (1-year retention, `SearchQueryInitiated`) | E5 / Audit (Premium) add-on | Opt-in via `-EnablePremiumAudit` | +| Endpoint DLP (Devices) | E5 / Purview Suite | **In the default config; auto-created in simulation** when license auto-detect finds an E5 / Purview Suite SKU. Soft-skipped on Business Premium and under `-BPOnly` (the Exchange + SPO/ODB policies still deploy). | +| DLP for Defender for Cloud Apps / on-prem / Power BI | E5 / Purview Suite | **Not implemented.** Config entries are skipped with structured evidence; adding configuration or upgrading licensing does not enable these workloads. | + +**Pass `-BPOnly`** to hard-block any E5-only opt-ins. The script refuses to +run if `-EnablePremiumAudit` is set, and the DLP module rejects custom +config workloads that require E5. Container labels still run under +`-BPOnly` because Business Premium includes Entra ID P1 (the AAD-side +requirement) — pass `-SkipContainerLabels` if you need to opt out. + +```powershell +# Business Premium customer — strict mode +.\Deploy-PurviewBestPractice.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com ` + -BPOnly +``` + +### Admin role on the customer tenant + +The signing-in admin needs: + +* **Compliance Administrator** *and* **Compliance Data Administrator** + (sensitivity labels, DLP, retention) +* **SharePoint Administrator** (tenant settings) +* **Exchange Administrator** (audit log, mailbox audit) +* **Groups Administrator** *or* **Global Administrator** — required for + container labels (`Group.Unified` directory setting). Pass + `-SkipContainerLabels` to omit this role requirement. + +> Or sign in as **Global Administrator** for simplicity. + +### PowerShell modules + +> The toolkit's `Connect-PurviewServices` helper auto-detects missing modules +> and offers to install them from PSGallery (or installs silently when the +> deploy script is invoked with `-AutoInstallModules`). You can also install +> them manually beforehand: + +| Module | Required for | Install | +|---------------------------------------------------------|-----------------------------------------|--------------------------------------------------------------------------------------| +| `ExchangeOnlineManagement` | Always (EXO + IPPS) | `Install-Module ExchangeOnlineManagement -Scope CurrentUser` | +| `Microsoft.Online.SharePoint.PowerShell` | Tenant settings + label policy publish | `Install-Module Microsoft.Online.SharePoint.PowerShell -Scope CurrentUser` | +| `Microsoft.Graph.Authentication` | Whenever Graph is used (license auto-detect, tenant-identity confirm, container labels) | `Install-Module Microsoft.Graph.Authentication -Scope CurrentUser` | +| `Microsoft.Graph.Beta.Identity.DirectoryManagement` | Container labels (default; skipped only when `-SkipContainerLabels` is passed) | `Install-Module Microsoft.Graph.Beta.Identity.DirectoryManagement -Scope CurrentUser` | + +### Microsoft Graph delegated scopes (least-privilege) + +The script requests only the scopes it actually needs for the run you ask for: + +| When Graph is used | Scopes requested | +|-------------------------------------------------------------------|-------------------------------------------------| +| License auto-detect + tenant-identity confirm (always when Graph is connected) | `Organization.Read.All` *(read-only)* | +| Container labels (default; skipped only when `-SkipContainerLabels` is passed) | `Organization.Read.All` + `Directory.ReadWrite.All` *(requested upfront in one consent)* | + +`Directory.ReadWrite.All` is the historically-consented, known-working scope on most tenants for reading `/directorySettingTemplates` and writing `/settings` where `Group.Unified.EnableMIPLabels` lives. We did experiment with the narrower `GroupSettings.ReadWrite.All` but it triggered `403 Authorization_RequestDenied` on tenants whose admins had only ever consented to `Directory.ReadWrite.All`. The license auto-detect itself (`/subscribedSkus`, `/organization`) is read-only via `Organization.Read.All`. + +### PowerShell version + +**PowerShell 7+ (`pwsh.exe`) is required.** The deploy script hard-fails on +Windows PowerShell 5.1 because the Exchange Online v3 REST channel and the +Microsoft.Graph SDK rely on .NET Core APIs that PS 5.1 does not expose, which +causes silent auth and cmdlet-discovery failures mid-run. + +```powershell +# Install PowerShell 7 (one-time): +winget install --id Microsoft.PowerShell --source winget +# Or download from: https://aka.ms/PowerShell-Release +``` + +Then run the toolkit from a `pwsh` prompt (not `powershell`). The +`Microsoft.Online.SharePoint.PowerShell` module is loaded via +`Import-Module -UseWindowsPowerShell` automatically when running under PS 7, +so no separate PS 5.1 step is needed. + +### Sign-in prompts (WAM broker) + +Each customer-tenant deploy needs sign-in tokens for up to four services +(Exchange Online, Security & Compliance / IPPS, SharePoint Online, Microsoft +Graph). The first run on a new admin account therefore shows several prompts. +On subsequent runs the **Windows Authentication Manager (WAM) broker** turns +most of those into single-click "Continue" confirmations instead of full +browser sign-ins. + +WAM is on by default in current PowerShell modules +(`Microsoft.Graph.Authentication` 2.x and `ExchangeOnlineManagement` 3.3+) +when **all** of the following are true: + +| Requirement | Why | +|---|---| +| Running on **Windows 10 1507 (build 10240)** or later, or **Windows Server 2019 (build 17763)** or later | WAM is a Windows-only OS component. | +| `pwsh.exe` was launched in your normal **interactive** desktop session (no RunAs, no scheduled task / SYSTEM, no SSH/remote PowerShell) | WAM uses the desktop user's signed-in tokens. | +| `Microsoft.Graph.Authentication` ≥ 2.0 and `ExchangeOnlineManagement` ≥ 3.3 are installed | Older versions don't have WAM integration. Refresh with `Update-Module`. | + +The connect helper checks all of these at start-up and warns when WAM is not +available — `Get-PurviewRunLog` records the result as a +`SessionGuard:Startup` entry with `reason=wam-ready` or `wam-not-ready` plus +the diagnostic fields it inspected. + +Even when WAM is available, the following still cause additional prompts: + +* **First-time consent** — granting `Organization.Read.All` (or + `Directory.ReadWrite.All` for container labels) on a new tenant always + shows the full consent screen once. +* **GDAP tenant switches** — the Graph guard reauths when it detects the + cached session belongs to a different customer tenant + (`reason=tenant-domain-mismatch` in the run log). +* **Missing scopes** — adding a new scope to an existing cached session + triggers a re-consent prompt (`reason=missing-scopes`). +* **SharePoint Online** — `Connect-SPOService` does not expose WAM, so the + SPO connect always uses its own MSAL flow. + +When you see more prompts than expected, run +`Get-PurviewRunLog | Where-Object Action -like 'SessionGuard:*'` after the +deploy — every prompt is preceded by a structured log entry that names +which check failed and what was expected vs observed. + +### MSAL DLL mismatch (Connect-MgGraph 'Method not found') + +`ExchangeOnlineManagement` and `Microsoft.Graph.Authentication` each ship +their own copy of `Microsoft.Identity.Client.dll` (MSAL) under their module +folders. When both modules are imported into the same PowerShell process, +the CLR binds to whichever copy was loaded FIRST and ignores the other. + +If the two modules were compiled against different MSAL versions and a +method signature changed between them (a recurring source of breakage in +MSAL 4.x — e.g. `BaseAbstractApplicationBuilder.WithLogging(IIdentity +Logger, Boolean)` moved between 4.82 and 4.83), `Connect-MgGraph` throws: + +```text +InteractiveBrowserCredential authentication failed: +Method not found: '!0 Microsoft.Identity.Client..(...)'. +``` + +That message reads like an auth failure but is purely a DLL-binding +mismatch — no credential / scope / tenant change will fix it. + +**Detection.** The connect helper runs `Test-MsalDllCompatibility` at +start-up: it locates the `Microsoft.Identity.Client.dll` bundled by each +module (PS edition-aware: `netCore` / `Dependencies\Core` on PS 7, +`netFramework` / `Dependencies\Desktop` on PS 5.1), reads each DLL's +`FileVersion`, and also checks whether MSAL is already loaded in the +process. The outcome is logged as a `SessionGuard:Startup` entry with +`reason=msal-aligned` or `msal-mismatch` plus the DLL paths/versions. + +**Mitigation 1: Graph-first connect order.** The connect helper imports +`Microsoft.Graph.Authentication` and calls `Connect-MgGraph` **before** +`Connect-ExchangeOnline`, mirroring the pattern used by +[`microsoft/zerotrustassessment`](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/public/Connect-ZtAssessment.ps1). +When Microsoft Graph loads its bundled MSAL into the AppDomain first, +`Connect-MgGraph` no longer throws `Method not found`. Verified +end-to-end on EXO 3.10.0 + Microsoft.Graph.Authentication 2.37.0 (which +ship MSAL 4.83.1.0 and 4.82.1.0 respectively). + +**Mitigation 2: `-DisableWAM` for Exchange Online / IPPS.** Connecting +Graph first solves the `Connect-MgGraph` failure but introduces a new +problem: when `Connect-ExchangeOnline` runs against Graph's older +MSAL 4.82, EXO 3.10's WAM (Windows Account Manager) broker code path +throws: + +```text +System.NullReferenceException: Object reference not set to an instance of an object. + at Microsoft.Identity.Client.Platforms.Features.RuntimeBroker.RuntimeBroker..ctor(...) +``` + +EXO's WAM `RuntimeBroker` constructor expects MSAL 4.83's surface, and +the older MSAL doesn't satisfy it. When the helper's pre-flight check +detects a mismatch (`SessionGuard:Startup reason=msal-mismatch`), it +automatically passes `-DisableWAM` to both `Connect-ExchangeOnline` and +`Connect-IPPSSession`. WAM is bypassed in favour of the standard +interactive browser flow, which doesn't invoke `RuntimeBroker`. The +operator still gets a one-click browser sign-in via the system default +browser; only the WAM 1-click pop-up is suppressed. Logged as +`SessionGuard:EXO/IPPS reason=disable-wam-msal-mismatch`. Auto-disables +the moment the bundled MSAL versions realign (WAM returns automatically). + +> **What about `-UseDeviceCode` for Graph?** Doesn't help — Graph 2.37's +> `DeviceCodeCredential` and `InteractiveBrowserCredential` constructors +> both invoke the same broken MSAL method overload, so switching auth +> flow doesn't change the JIT lookup. `microsoft/zerotrustassessment` +> has the same `#TODO: UseDeviceCode does not work with ExchangeOnline` +> caveat for the EXO side. + +> **What about pre-loading Graph's older MSAL?** Doesn't work either. +> `ExchangeOnlineManagement` 3.10's module manifest strong-binds to its +> bundled MSAL 4.83.1.0, so pre-loading Graph's 4.82.1.0 with +> `[Reflection.Assembly]::LoadFrom` causes `Connect-ExchangeOnline` to +> throw `FileLoadException: manifest does not match` (HRESULT +> 0x80131040). + +**Manual fallback — restart PowerShell.** If another module loaded MSAL +into the process *before* this script started running (e.g. you ran +`Connect-ExchangeOnline` manually, or imported `Az.*`, `PnP.PowerShell`, +or `Microsoft.Graph.*` first), neither mitigation can help — .NET +cannot swap a loaded assembly. The connect helper detects this in its +pre-flight check (`loadedMsalVersion` is set in the +`SessionGuard:Startup` entry) and tells the operator to: + +1. **Close** the PowerShell window. +2. **Open a fresh `pwsh`** with no other Microsoft modules pre-imported. +3. **Re-run** the script. + +If `Connect-MgGraph` still throws `Method not found: 'Microsoft.Identity…'`, +the helper catches the `MissingMethodException` and emits restart-pwsh +guidance with `SessionGuard:Graph reason=msal-method-not-found-restart-pwsh`. +Same for an EXO `NullReferenceException` in `RuntimeBroker` — +`SessionGuard:EXO reason=wam-runtimebroker-nullref`. + +**Last resort — pin a matching EXO/Graph pair.** Microsoft realigns the +bundled MSAL versions between releases, so updating both modules with +`Update-Module ExchangeOnlineManagement -Force; Update-Module Microsoft.Graph.Authentication -Force` +will eventually close the gap. At the time of writing, no shipped +`ExchangeOnlineManagement` version on PSGallery bundles the same MSAL +as `Microsoft.Graph.Authentication` 2.37.0 (`4.82.1.0`) — EXO +3.8/3.9.0/3.9.2/3.10 ship 4.66.1/4.68.0/4.74.1/4.83.1 respectively — so +this fallback is rarely useful today. If you do want to try pinning a +matching pair manually: + +```powershell +# Find an installed EXO version whose MSAL DLL matches Graph's +Get-Module ExchangeOnlineManagement -ListAvailable | ForEach-Object { + $dll = Get-ChildItem $_.ModuleBase -Filter Microsoft.Identity.Client.dll -Recurse | + Where-Object FullName -match 'netCore' | Select-Object -First 1 + [pscustomobject]@{ + EXO = $_.Version + MSAL = if ($dll) { $dll.VersionInfo.FileVersion } else { '(none)' } + } +} + +# Then pin in a fresh window before running the script: +Import-Module ExchangeOnlineManagement -RequiredVersion -Force +``` + +The Graph-first connect order + auto `-DisableWAM` for EXO/IPPS is the +primary fix; the pwsh-restart is the only reliable manual fallback when +MSAL is already loaded with the wrong version. + +### Microsoft.Graph.Beta sub-module version pin (`Assembly with same name is already loaded`) + +After updating `Microsoft.Graph.Authentication` (e.g. to fix the MSAL +mismatch above) the deployment can later fail at **step [5/5] container +labels** with: + +``` +The 'Get-MgBetaDirectorySetting' command was found in the module +'Microsoft.Graph.Beta.Identity.DirectoryManagement', but the module could +not be loaded due to the following error: [Could not load file or +assembly 'Microsoft.Graph.Authentication, Version=2.36.1.0, …'. +Assembly with same name is already loaded] +``` + +**Why it happens.** Every `Microsoft.Graph.Beta.*` sub-module's manifest +strict-pins `Microsoft.Graph.Authentication` to an **exact** version in +`RequiredModules` (not a minimum). When you update `Authentication` to a +newer version without also updating the matching Beta sub-modules, +PowerShell tries to load the older Auth version that Beta requires, but +the newer Auth is already in the AppDomain — only one version of an +assembly can be loaded per AppDomain, so `Import-Module` throws. + +**Auto-detect + self-heal.** The connect helper runs `Test-GraphBetaCompat` +in the pre-flight (just before importing the Beta sub-module). It +compares the Beta module's `RequiredModules` pin against the loaded / +installed `Microsoft.Graph.Authentication` version and: + +* When the pin matches: logs `SessionGuard:GraphBeta version-pin-aligned` + and force-imports the matching version via `Import-Module + -RequiredVersion` (so the pick is deterministic even with multiple + Beta versions installed side-by-side). +* When the pin **does not** match: **unconditionally** installs the + matching Beta version side-by-side + (`Install-Module … -RequiredVersion -Scope CurrentUser -Force + -AllowClobber`), re-validates, then force-imports it. No + `-AutoInstallModules` flag is required — this is a deterministic, + non-destructive recovery (one specific sub-module version, installed + side-by-side; existing versions are untouched). Matches the existing + PSGallery trust restore pattern. +* If `Install-Module` fails (PSGallery unreachable, locked-down + environment, etc.): fails fast at pre-flight with the exact + `Install-Module` command to run manually. Operators in CLM / + no-internet environments can pre-pin the matching Beta version before + running the script. + +Log keys: `version-pin-aligned`, `version-pin-mismatch`, +`auto-installed-matching-version`, `auto-install-failed`, +`imported-matching-version`, `no-matching-version-installed`. + +**Manual fix.** If you don't want the auto-install, run +`Update-Module Microsoft.Graph.Beta -Force` (updates the whole family in +one shot) or the targeted `Install-Module` printed in the warning, then +re-run the deploy. + +--- + +## File layout + +``` +Configuration/Purview/ +├── Deploy-PurviewBestPractice.ps1 <- master orchestrator (start here) +├── Test-PurviewTenantConfiguration.ps1 <- standalone read-only validation +├── Config/ +│ └── PurviewConfig.psd1 <- all label / DLP / retention names & values +├── References/ +│ ├── DataSecuritySmbGuideMapping.psd1 <- primary Good / Better / Best mapping +│ └── MicrosoftLearnLightweightDlpMapping.psd1 <- supporting Learn mapping +├── Modules/ +│ ├── Connect-PurviewServices.ps1 <- service connection helper +│ ├── Connect-PurviewValidationServices.ps1 <- read-only connection profile +│ ├── Setup-TenantSettings.ps1 <- task 1 +│ ├── Setup-SensitivityLabels.ps1 <- task 2 +│ ├── Setup-DLP.ps1 <- task 3 +│ ├── Setup-Retention.ps1 <- task 4 +│ ├── Setup-AIGovernance.ps1 <- task 5 +│ ├── Write-PurviewDeploymentPlan.ps1 <- pre-connection HTML + JSON plan +│ └── Write-PurviewHtmlReport.ps1 <- end-of-run HTML + JSON evidence +└── README.md +``` + +Each `Setup-*.ps1` script is self-contained and can be run on its own; the +master script just orchestrates them. + +--- + +## Quick start + +```powershell +# 1. Clone / download the repo and open a PowerShell prompt in this folder. + +# 2. Preview every change WITHOUT applying (SharePoint URL is auto-derived): +.\Deploy-PurviewBestPractice.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com ` + -WhatIf + +# 3. Apply the baseline: +.\Deploy-PurviewBestPractice.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com +``` + +The SharePoint admin URL is auto-derived from the tenant's initial +`onmicrosoft.com` domain after Exchange Online connects. Override only when +auto-derivation fails (e.g. multi-geo or unusual domain configurations): + +```powershell +.\Deploy-PurviewBestPractice.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com ` + -SharePointAdminUrl https://contoso-admin.sharepoint.com +``` + +### Delegated admin (GDAP) scenario + +```powershell +.\Deploy-PurviewBestPractice.ps1 ` + -TenantAdminUpn delegatedadmin@fabrikam.onmicrosoft.com ` + -DelegatedOrganization contoso.onmicrosoft.com +``` + +### Selective deployment + +```powershell +# Just labels and DLP — skip tenant settings (retention is opt-in) +.\Deploy-PurviewBestPractice.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com ` + -SkipTenantSettings +``` + +### Premium audit (E5 add-on) + +```powershell +.\Deploy-PurviewBestPractice.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com ` + -EnablePremiumAudit -PremiumAuditMailbox 'admin@contoso.onmicrosoft.com' +``` + +--- + +## Customisation + +All names, durations, label texts, encryption rights, and DLP behaviour live +in `Config/PurviewConfig.psd1`. Fork the repo, edit that file, and re-run. + +The most common customisations: + +* Change the default applied label (`LabelPolicy.DefaultLabel`) +* Add SharePoint / OneDrive to retention scope (`Retention.Locations`) +* Change retention from the 7-year default to N years (`Retention.DurationDays`, in days — `2555` = 7 years) +* Tighten encryption rights (`EncryptionRightsDefinitions`) + +> 📖 **Full key-by-key guide:** see the +> [Configuration Reference](docs/Configuration-Reference.md) — every tunable in +> `PurviewConfig.psd1`, its default, and how to change it safely. + +> **Don't change** the `ManagedByTag` after a deployment — it's how the +> toolkit recognises objects it owns on subsequent runs. +> +> A non-empty `BuiltInName` must match Microsoft's +> `defa4170-0d19-0005-NNNN-bc88714345d2` signature. Invalid values stop local +> preflight before the Deployment Plan or sign-in so deployment and reporting +> cannot describe different label identities. + +--- + +## Run output: Deployment Plan, HTML report, and JSON sidecars + +Before authentication, the toolkit writes an offline Deployment Plan from the +validated config and effective switches. It includes a module summary, an +action preview, SHA-256 fingerprints, and a comparison to the supplied Data +Security Deployment Guide for Small Business. Recommendations are grouped by +the minimum Deployment Priority Level where they appear: Priority 1 (Good), +Priority 2 (Better), or Priority 3 (Best). A closed **How to read the Action +Preview** panel defines intent, priority, and comparison statuses. A separate +appendix keeps the pinned Microsoft Learn Lightweight guide visible as a +supporting reference. The plan describes intent only. It does not inspect +tenant state, predict create or update outcomes, or declare compliance. + +The supplied guide places audit logging, labels, container and SharePoint/ +OneDrive label enablement, and core DLP in Good; Exchange retention in Better; +and Endpoint, Teams, and Copilot DLP, auto-labeling, encryption, custom +sensitive information types, and DSPM in Best. Other toolkit capabilities are +shown as extensions rather than attributed to a guide level. + +Schema 1.2 adds a sanitized intended-state snapshot to both artifacts. It +records the effective tenant settings, stable label signatures, and policy +settings needed for later comparison. It includes the managed-object tag, +publication mode, encryption lifetime, and privacy-safe one-way digests for +custom principals. The public Microsoft 365 Copilot destination is represented +by the canonical `Microsoft365Copilot` token. Custom or unknown locations keep +only a redacted display value, location class, and deterministic one-way digest +so distinct targets remain distinguishable without exposing them. +Retention records contain only supported destinations and separately identify +unsupported values. An unsupported-only retention configuration is excluded +instead of appearing ready to deploy. The managed-object tag is a separate +top-level schema field and participates in the plan-input fingerprint. The plan +remains private customer-sensitive evidence: +configured policy and label names needed for matching can appear. It excludes +tenant identity, live object GUIDs in free text, Premium Audit mailbox UPNs, +resolved tenant domains, credentials, secrets, and local paths. + +The default files are +`Deploy-PurviewBestPractice-Plan-PUR--.html` and the adjacent +`.json` sidecar. The short `PUR-...` reference identifies one generated pair. +The deterministic plan-input SHA-256 identifies equivalent effective intent +across generations. Use `-DeploymentPlanPath` to choose the HTML path, or +`-NoDeploymentPlan` to suppress both files. Plan generation is best effort: a +local rendering or file-write failure warns, records run-log evidence, and lets +the deployment continue. + +`-DeploymentPlanPath` must end in `.html` or `.htm`; the adjacent JSON sidecar +uses the same basename. Keep the sidecar with the HTML. The read-only +validator accepts the JSON sidecar through `-PlanPath`; see +[Post-deployment configuration validation](#post-deployment-configuration-validation) +for the supported schema, permissions, and limitations. + +After the run, the toolkit writes a separate HTML deployment report and +machine-readable JSON sidecar. These record each action with its status, target, +retry attempts, and the reason for anything skipped. Suppress the end-of-run +artifacts with `-NoReport`, or choose the HTML location with `-ReportPath`. + +**A `-WhatIf` run produces the report too.** That is the point: the dry run is +how you review what the toolkit intends to change before it changes anything. +The report is local evidence, not a tenant change, so it is written regardless +of `-WhatIf`. + +The end-of-run reports contain tenant evidence. Keep them in the approved +private evidence location and do not commit or publish them. The Deployment +Plan excludes tenant identity, but its configuration intent may still be +customer-sensitive and should use the same private storage practice. + +--- + +## Post-deployment configuration validation + +`Test-PurviewTenantConfiguration.ps1` is a separate, read-only command. With +`-PlanPath`, it validates a schema 1.2 artifact and compares live observations +with its `IntendedState`. Without a plan, it assesses the pinned Good, Better, +and Best guide controls without loading default configuration intent. It makes +no changes and has no repair switch. + +```powershell +cd Products/Purview + +.\Test-PurviewTenantConfiguration.ps1 ` + -PlanPath .\Deploy-PurviewBestPractice-Plan-PUR-20260814-091500-1234ABCD.json ` + -TenantAdminUpn admin@contoso.onmicrosoft.com +``` + +Omit `-PlanPath` for guide-only assessment. Each result keeps three separate +axes: observation (`Present`, `Absent`, `Unreadable`, `Unsupported`), intended +state (`Match`, `Mismatch`, `NotInPlan`, `NotEvaluated`), and guide baseline +(`Meets`, `DoesNotMeet`, `Indeterminate`, `NotApplicable`). The report calculates +cumulative proven and provisional levels and lists each blocker. Only +plan-matched or toolkit-managed objects are named. Other objects are counted +and anonymized. + +Exit codes are a bitmask: `0` clean, `2` drift, `4` collection failure, `6` +both, and `1` for a fatal input, schema, tenant-identity, or connection +failure. + +Plan comparison requires a **schema 1.2 or later** artifact. The validator +checks the artifact type, plan reference, schema, and both SHA-256 fingerprints +before authentication. + +Validation uses its own least-privilege read-only connection profile. It +requests only `Organization.Read.All` and `GroupSettings.Read.All`, and +deliberately does not request the deployment profile's write scopes. License +gates remain provisional unless workload reads prove support. The validator +also inspects the effective Graph context and fails before tenant collection if +the token contains any other Graph resource permission. On a workstation where +an existing context has broader consent, close that PowerShell process, start a +fresh `pwsh -NoProfile` session, and rerun so the validator can request a new +token. The new token is checked again. If the shared Microsoft Graph PowerShell +client still returns broader permissions, use an approved tenant-local public +client with only those two delegated permissions: + +```powershell +.\Test-PurviewTenantConfiguration.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com ` + -ClientId '' ` + -UseDeviceAuthentication +``` + +The script does not create an app registration or grant consent. + +Pilot validation against a live tenant has not been performed for this release. +The deterministic fixtures prove the toolkit's own logic, not Microsoft +supportability. See +[docs/Tenant-Validation-Report.md](docs/Tenant-Validation-Report.md) for module +requirements, run commands, output locations, and troubleshooting. See +[docs/Configuration-Validation.md](docs/Configuration-Validation.md) for the +full operator guide. + +--- + +## Idempotency & safety + +* Every action does a `Get-*` first and skips when the desired state is + already in place. +* Every object created by the toolkit is stamped with + `[Managed by SMBTool Purview Toolkit]` in its `Comment` / description. +* If a label, DLP policy, retention policy, or rule with the toolkit's name + **already exists but is not stamped**, the run **stops with a clear error**. + Pass `-AdoptExisting` to take ownership and update the existing object. +* `-WhatIf` prints every state-changing call without executing it. + When run end-to-end against a clean tenant, downstream modules (DLP, + retention, label policy publish) reference labels that do not yet exist — + these are surfaced as `What if: ... would be created earlier in this run` + notices with a placeholder GUID, so the preview is complete and never + errors out. In apply mode the labels really are created in the right order. +* `-Confirm` is honoured by the underlying cmdlets but **does not** prompt + per high-impact change in the orchestrator. The toolkit is designed for + unattended runs (`SupportsShouldProcess` is wired with `ConfirmImpact = 'None'`, + and every internal call passes `-Confirm:$false`). To preview without + applying use `-WhatIf`; to gate the run on operator confirmation, leave + the preflight `y/N` prompt enabled (i.e. do **not** pass `-NonInteractive`). +* The master script prints a preflight summary and asks for `y/N` + confirmation before any change (skip with `-NonInteractive`). + +--- + +## Encryption rights — tenant-scoped by default + +One Template-protected label applies usage rights (the other two encrypted +sub-labels are UserDefined — the author picks recipients each time, not a +fixed rights bundle): + +* **`Highly Confidential \ All Employees`** — uses Microsoft's wider + **Co-Author** bundle (per-label override): + + ``` + VIEW, VIEWRIGHTSDATA, DOCEDIT, EDIT, EXTRACT, PRINT, OBJMODEL, REPLY, REPLYALL, FORWARD + ``` + + Co-Author adds **EXTRACT** (copy), **PRINT**, and **OBJMODEL** (Office + object-model access — needed for macros and simultaneous editing). + Granting OBJMODEL is **necessary but not sufficient** for Office + multi-user co-authoring on encrypted files: the tenant-wide switch + `Set-PolicyConfig -EnableLabelCoauth:$true` (toolkit param + `-EnableLabelCoAuthoring`, opt-in) must also be on. + +The global default **Reviewer** bundle (no Copy, no Print, no OBJMODEL) +applies to any OTHER Template-encrypted label that does NOT carry a +per-label override: + + ``` + VIEW, VIEWRIGHTSDATA, DOCEDIT, EDIT, REPLY, REPLYALL, FORWARD + ``` + +To change the default, edit `EncryptionRightsDefinitions` in +`PurviewConfig.psd1`. To override for a specific label, add an +`EncryptionRightsDefinitions = '...'` field on that label hashtable +(same token semantics as the global default). + +The rights are scoped to **all users in your tenant only** via the +`{TenantDomain}` token at the start of `EncryptionRightsDefinitions`. At +run time the toolkit resolves the token against your auto-discovered +tenant identity (default verified domain, falling back to your initial +`*.onmicrosoft.com`). Per Entra rights-management semantics, ANY verified +domain expands to ALL verified domains in the tenant — so specifying one +is enough to cover the whole tenant, and it explicitly EXCLUDES external +`AuthenticatedUsers` from other Microsoft 365 tenants, B2B guests +attached to other tenants, social/MSA accounts, and OTP users. + +If you intentionally need the broader cross-tenant scope (e.g. you +collaborate via the label with partner organizations that do NOT yet have +a B2B trust into your tenant), edit `EncryptionRightsDefinitions` to use +`{AuthenticatedUsers}` (or the literal `AuthenticatedUsers`) and document +the decision. The toolkit refuses to silently fall back to that scope — +if the `{TenantDomain}` token cannot be resolved (no Graph/EXO identity), +the labels module aborts with an actionable error. + +> **Retroactive scope changes only apply to NEW labelling.** Existing +> protected files carry the use-license they had at the moment of +> labelling. Tightening the rights bundle does NOT retroactively revoke +> external access to files that were already labelled and shared. To +> tighten retroactively, re-label / re-protect the affected files. See +> [`docs/Change-Management-Playbook.md`](docs/Change-Management-Playbook.md#known-sharp-edges). + +--- + +## Known sharp edges (read before promoting to production) + +These are **not bugs**. They are correct defaults that require a customer +conversation. Full detail and mitigations are in +[`docs/Change-Management-Playbook.md`](docs/Change-Management-Playbook.md#known-sharp-edges). + +| Sharp edge | Default behaviour | Why it matters | +|---|---|---| +| **Encrypted-label scope = your tenant only (not retroactive)** | The Template-encrypted `Highly Confidential\All Employees` sub-label grants rights to all users in your tenant via the `{TenantDomain}` token. | Files labelled BEFORE this toolkit ran (or before you tightened the scope) keep their old rights — re-label them if you need the new scope applied. B2B guests from OTHER tenants are excluded by default; if you need cross-tenant collaboration, switch to `{AuthenticatedUsers}` deliberately. | +| **`Highly Confidential\Specific People` prompts users** | Word/Excel/PowerPoint asks the user to pick who can open the file. Outlook auto-applies Do Not Forward. | Most users do not know how to respond to this dialog. **Not** published to end users by default — keep it that way unless you ship user training. | +| **`Confidential\TrustedPeople` also prompts users** | UserDefined encryption — Outlook auto-applies Encrypt-Only (recipients can reshare); Office desktop apps prompt the recipient list. | Same usability caveat as the HC variant. **Not** published by default. Encryption is enforced once published. | +| **Container labels are one-way** | `Group.Unified` `EnableMIPLabels=True` is set by default on every run (BP is the licensing floor and BP includes Entra ID P1). Pass `-SkipContainerLabels` to opt out before the first run. | Microsoft does not officially support reverting once set. Treat the default-on behaviour as decision-grade — confirm with the customer up front. | +| **Endpoint DLP without device onboarding is theatre** | Endpoint DLP policy is created on E5 tenants when not `-BPOnly`. | Without Defender / Purview device onboarding, the policy enforces nothing. Looks deployed; protects nothing. | +| **7-year retention deletes mail** | Tenant-wide retention deletes Exchange mail older than 7 years. | Aligns with most SMB regulatory frameworks (ATO / IRS / SEC / ASIC), but still wrong for some verticals (e.g. paediatric healthcare) and for customers who want no automatic deletion. See [`docs/Retention-Default-Risk.md`](docs/Retention-Default-Risk.md). | +| **DLP starts in simulation** | `DlpStartInSimulation = $true`. Telemetry only — nothing is blocked. | Has to be **explicitly promoted** at day 30 via the [`docs/DLP-Simulation-Exit-Runbook.md`](docs/DLP-Simulation-Exit-Runbook.md), or it is permanent shelfware. | + +--- + +## Troubleshooting + +| Symptom | Cause / fix | +|-------------------------------------------------------------|----------------------------------------------------------------------------------------------| +| `Required PowerShell module 'X' is not installed` | Re-run with `-AutoInstallModules` to install missing modules automatically, or run the matching `Install-Module` command from the prerequisites table. | +| `Connect-SPOService failed ... Fallback (UseWindowsPowerShell) also failed: ... no valid module file was found` | PS 7's `Install-Module` only installs to the PS 7 module path; the SPO module's Windows-PowerShell-proxy fallback can't see it. Open `powershell.exe` (Windows PowerShell 5.1) once and run `Install-Module Microsoft.Online.SharePoint.PowerShell -Scope CurrentUser -Force -AllowClobber`, then re-run this script from `pwsh`. | +| `Label '...' already exists but is not managed by toolkit` | An existing label with the same name was found. Audit it, then re-run with `-AdoptExisting`. | +| `Get-SPOTenant: ... not connected` | Wrong `-SharePointAdminUrl`, or SPO module failed to load under PS 7 — verify `Microsoft.Online.SharePoint.PowerShell` is installed and re-run from a fresh `pwsh` prompt. | +| Users don't see new labels in Office | Labels can take up to 24 h to propagate; sign out / back in to force refresh. | +| DLP not blocking external sends | Allow up to ~1 h enforcement window; verify rule is `Mode=Enable` in the Purview portal. | +| `Connect-IPPSSession` fails after `Connect-ExchangeOnline` | They are separate sessions — both must succeed; modern auth + MFA both required. | + +--- + +## Out of scope + +This toolkit deliberately implements **only** the Business Premium baseline +from the source deck. The following are out of scope and require separate, +more advanced tooling: + +* Insider Risk Management +* Communication Compliance +* eDiscovery +* Auto-labelling (service-side & client-side) +* Multi-geo configurations +* Customer-specific protection scopes (per-domain, per-group) + +For the extended Purview guide referenced in the deck, see +. + +--- + +## Contributing + +Issues and PRs welcome. Please: + +* Keep changes idempotent. +* Don't introduce display-name-based matching for labels in DLP rules — use + GUIDs. +* Validate every change in a pilot tenant before opening a PR. + +--- + +## License + +Provided as-is, without warranty of any kind. Review and test before applying +to production tenants. See the repository root for licence information. + +## Disclaimer + +This sample script is not supported under any Microsoft standard support +program or service. The sample script is provided AS IS without warranty of +any kind. Microsoft further disclaims all implied warranties including, +without limitation, any implied warranties of merchantability or of fitness +for a particular purpose. The entire risk arising out of the use or +performance of the sample scripts and documentation remains with you. In no +event shall Microsoft, its authors, or anyone else involved in the creation, +production, or delivery of the scripts be liable for any damages whatsoever +(including, without limitation, damages for loss of business profits, +business interruption, loss of business information, or other pecuniary loss) +arising out of the use of or inability to use the sample scripts or +documentation, even if Microsoft has been advised of the possibility of such +damages. + +Please do not contact Microsoft support with any issues or concerns regarding +this script. diff --git a/Products/Purview/References/DataSecuritySmbGuideMapping.psd1 b/Products/Purview/References/DataSecuritySmbGuideMapping.psd1 new file mode 100644 index 0000000..5550d23 --- /dev/null +++ b/Products/Purview/References/DataSecuritySmbGuideMapping.psd1 @@ -0,0 +1,372 @@ +@{ + SchemaVersion = '1.1' + Role = 'Primary' + Guide = @{ + Id = 'MS-DATA-SECURITY-SMB-FINALCLEAN' + Title = 'Data Security Deployment Guide for Small Business' + Edition = 'FinalClean' + RevisionDate = '2026-04-08' + SourceModifiedDate = '2026-06-11' + VerifiedDate = '2026-09-10' + Publisher = 'Microsoft Security' + SourceClassification = 'ProductOwnerSupplied' + SourceFileName = 'Data Security Deployment Guide for Small Business_FinalClean.pptx' + Owner = 'SMB Best Practice Tool maintainers' + Levels = @( + @{ + Id = 'Good' + Title = 'Good' + Summary = 'Enable audit logging, deploy the baseline label experience, and protect Exchange, SharePoint, and OneDrive.' + Inherits = @() + } + @{ + Id = 'Better' + Title = 'Better' + Summary = 'Add an Exchange mailbox retention policy, expanding to other workloads as needed.' + Inherits = @('Good') + } + @{ + Id = 'Best' + Title = 'Best' + Summary = 'Add Endpoint, Teams, and Copilot DLP, automated classification, encryption, custom SITs, and DSPM.' + Inherits = @('Good', 'Better') + } + ) + } + Controls = @( + @{ + Id = 'DS-SMB-G-01' + Level = 'Good' + Section = 'Audit logging' + SourceSection = 'Enable audit log' + Summary = 'Enable or confirm audit logging for user and administrator activity.' + Status = 'Aligned' + ActionIds = @('purview.tenant.audit-standard') + ConfigPaths = @('TenantSettings.EnableUnifiedAuditLog') + Rationale = '' + ReferenceUris = @() + } + @{ + Id = 'DS-SMB-G-02' + Level = 'Good' + Section = 'Sensitivity label hierarchy' + SourceSection = 'Recommended label taxonomy' + Summary = 'Define the Public, General, Confidential, and Highly Confidential label hierarchy.' + Status = 'Conditional' + ActionIds = @('purview.labels.taxonomy') + ConfigPaths = @('Labels', 'Labels[].SubLabels') + Rationale = 'The toolkit implements the four-level hierarchy but consolidates or omits some sublabels shown in the guide taxonomy.' + ReferenceUris = @() + } + @{ + Id = 'DS-SMB-G-03' + Level = 'Good' + Section = 'Sensitivity label hierarchy' + SourceSection = 'Order labels by priority' + Summary = 'Order the sensitivity label hierarchy from least to most restrictive.' + Status = 'Aligned' + ActionIds = @('purview.labels.priority') + ConfigPaths = @('Labels') + Rationale = '' + ReferenceUris = @() + } + @{ + Id = 'DS-SMB-G-04' + Level = 'Good' + Section = 'Sensitivity label policy settings' + SourceSection = 'Publish sensitivity labels' + Summary = 'Publish the configured sensitivity labels and defaults so the hierarchy is available to users.' + Status = 'Aligned' + ActionIds = @('purview.labels.publish') + ConfigPaths = @('LabelPolicy') + Rationale = '' + ReferenceUris = @() + } + @{ + Id = 'DS-SMB-G-05' + Level = 'Good' + Section = 'Sensitivity labels for containers' + SourceSection = 'Enable sensitivity labels for containers and synchronize labels' + Summary = 'Enable sensitivity labels for Microsoft 365 groups, Teams, and SharePoint sites and synchronize them with Microsoft Entra ID.' + Status = 'Aligned' + ActionIds = @( + 'purview.tenant.container-directory-setting' + 'purview.labels.container-scope' + ) + ConfigPaths = @( + 'TenantSettings' + 'Labels[].ContentType' + ) + Rationale = '' + ReferenceUris = @() + } + @{ + Id = 'DS-SMB-G-06' + Level = 'Good' + Section = 'Sensitivity labels for SharePoint and OneDrive' + SourceSection = 'Enable sensitive labels for Office files SharePoint and OneDrive' + Summary = 'Enable sensitivity-label processing for Office files stored in SharePoint and OneDrive.' + Status = 'Aligned' + ActionIds = @('purview.tenant.spo-labels') + ConfigPaths = @('TenantSettings.EnableAIPIntegrationInSPO') + Rationale = '' + ReferenceUris = @() + } + @{ + Id = 'DS-SMB-G-07' + Level = 'Good' + Section = 'Sensitivity label content marking' + SourceSection = 'Create sensitivity labels: Modern label scheme' + Summary = 'Optionally add content marking while configuring sensitivity labels.' + Status = 'Conditional' + ActionIds = @('purview.labels.content-marking') + ConfigPaths = @('EnableContentMarking', 'Labels[].ContentMark') + Rationale = 'The guide presents content marking as optional, and the shipped global content-marking gate is disabled.' + ReferenceUris = @() + } + @{ + Id = 'DS-SMB-G-08' + Level = 'Good' + Section = 'Exchange DLP' + SourceSection = 'Create and deploy DLP policies for Exchange' + Summary = 'Protect Confidential, All Employees content from external Exchange recipients.' + Status = 'Conditional' + ActionIds = @('purview.dlp.exchange') + ConfigPaths = @('DlpPolicies[Workload=Exchange]') + Rationale = 'The toolkit starts the Exchange DLP policy in simulation instead of immediate enforcement.' + ReferenceUris = @() + } + @{ + Id = 'DS-SMB-G-09' + Level = 'Good' + Section = 'SharePoint and OneDrive DLP' + SourceSection = 'Create and deploy DLP policies for SharePoint and OneDrive' + Summary = 'Protect Confidential, All Employees content from external sharing in SharePoint and OneDrive.' + Status = 'Conditional' + ActionIds = @('purview.dlp.sharepoint-onedrive') + ConfigPaths = @('DlpPolicies[Workload=SharePointOneDrive]') + Rationale = 'The toolkit starts the SharePoint and OneDrive DLP policy in simulation instead of immediate enforcement.' + ReferenceUris = @() + } + @{ + Id = 'DS-SMB-B-01' + Level = 'Better' + Section = 'Exchange retention' + SourceSection = 'Create a retention policy for Exchange Email box' + Summary = 'Create a retention policy for Exchange mailboxes and expand it to other workloads as needed.' + Status = 'Conditional' + ActionIds = @('purview.retention.exchange') + ConfigPaths = @('Retention') + Rationale = 'The toolkit requires explicit -ApplyRetention opt-in and uses the configured seven-year policy rather than assuming the guide example fits every organization.' + ReferenceUris = @() + } + @{ + Id = 'DS-SMB-X-01' + Level = 'Best' + Section = 'Endpoint DLP prerequisites' + SourceSection = 'Enable Endpoint DLP for the policy to apply to devices' + Summary = 'Enable Endpoint DLP settings and onboard supported Windows and macOS devices.' + Status = 'NotImplemented' + ActionIds = @() + ConfigPaths = @() + Rationale = 'Device onboarding and tenant-wide Endpoint DLP prerequisite configuration are outside the Purview policy module.' + ReferenceUris = @() + } + @{ + Id = 'DS-SMB-X-02' + Level = 'Best' + Section = 'Endpoint DLP policy' + SourceSection = 'Create DLP policy for Devices' + Summary = 'Restrict copy, print, browser upload, cloud sync, remote access, Bluetooth, and removable-media actions on sensitive files.' + Status = 'Conditional' + ActionIds = @('purview.dlp.endpoint') + ConfigPaths = @('DlpPolicies[Workload=Endpoint]') + Rationale = 'The toolkit uses audit-first endpoint restrictions and requires Purview Suite entitlement, device onboarding, and runtime readiness.' + ReferenceUris = @() + } + @{ + Id = 'DS-SMB-X-03' + Level = 'Best' + Section = 'Teams DLP' + SourceSection = 'Create DLP policy for Teams' + Summary = 'Create a DLP policy for sensitive data shared in Teams chats and channel messages.' + Status = 'NotImplemented' + ActionIds = @() + ConfigPaths = @() + Rationale = 'The shipped Purview configuration does not define a Teams chat and channel DLP workload.' + ReferenceUris = @() + } + @{ + Id = 'DS-SMB-X-04' + Level = 'Best' + Section = 'Copilot DLP' + SourceSection = 'Create DLP policy for Copilot' + Summary = 'Create separate Copilot DLP rules for sensitive prompt information and labeled content.' + Status = 'Conditional' + ActionIds = @('purview.ai.copilot-dlp') + ConfigPaths = @('AIGovernance.DlpPolicies') + Rationale = 'The toolkit blocks processing of configured highly confidential labels but does not create the guide''s separate sensitive-information-type prompt rule.' + ReferenceUris = @() + } + @{ + Id = 'DS-SMB-X-05' + Level = 'Best' + Section = 'Client-side auto-labeling' + SourceSection = 'Configure client-side auto-labeling' + Summary = 'Configure Office apps to recommend sensitivity labels when custom sensitive information types match.' + Status = 'NotImplemented' + ActionIds = @() + ConfigPaths = @() + Rationale = 'Client-side recommendation rules require organization-specific classifiers, tuning, and false-positive review.' + ReferenceUris = @() + } + @{ + Id = 'DS-SMB-X-06' + Level = 'Best' + Section = 'Service-side auto-labeling' + SourceSection = 'Extend auto-labeling to M365 services' + Summary = 'Configure service-side auto-labeling for existing files in SharePoint and OneDrive.' + Status = 'NotImplemented' + ActionIds = @() + ConfigPaths = @() + Rationale = 'Service-side auto-labeling requires separate classifier tuning, simulation, and rollout approval.' + ReferenceUris = @() + } + @{ + Id = 'DS-SMB-X-07' + Level = 'Best' + Section = 'Sensitivity label encryption' + SourceSection = 'Apply encryption to labeled content' + Summary = 'Apply encryption and access controls to high-sensitivity labeled content.' + Status = 'Aligned' + ActionIds = @('purview.labels.encryption') + ConfigPaths = @( + 'Labels[].Encrypt' + 'Labels[].ProtectionType' + 'EncryptionRightsDefinitions' + ) + Rationale = '' + ReferenceUris = @() + } + @{ + Id = 'DS-SMB-X-08' + Level = 'Best' + Section = 'Custom sensitive information types' + SourceSection = 'Create custom sensitive info types' + Summary = 'Create organization-specific sensitive information types from built-in types or new patterns.' + Status = 'NotImplemented' + ActionIds = @() + ConfigPaths = @() + Rationale = 'Custom pattern authoring requires organization-specific samples, testing, and false-positive review.' + ReferenceUris = @() + } + @{ + Id = 'DS-SMB-X-09' + Level = 'Best' + Section = 'Advanced classification' + SourceSection = 'Out-of-the-box Built-in SITs vs. Custom SITs' + Summary = 'Improve classification with custom keyword dictionaries.' + Status = 'NotImplemented' + ActionIds = @() + ConfigPaths = @() + Rationale = 'Custom keyword dictionaries require organization-specific source data and validation.' + ReferenceUris = @() + } + @{ + Id = 'DS-SMB-X-10' + Level = 'Best' + Section = 'Advanced classification' + SourceSection = 'Out-of-the-box Built-in SITs vs. Custom SITs' + Summary = 'Improve classification with exact data match.' + Status = 'NotImplemented' + ActionIds = @() + ConfigPaths = @() + Rationale = 'Exact data match requires organization-specific schemas, data preparation, and secure upload.' + ReferenceUris = @() + } + @{ + Id = 'DS-SMB-X-11' + Level = 'Best' + Section = 'Advanced classification' + SourceSection = 'Out-of-the-box Built-in SITs vs. Custom SITs' + Summary = 'Improve classification with document fingerprinting.' + Status = 'NotImplemented' + ActionIds = @() + ConfigPaths = @() + Rationale = 'Document fingerprinting requires organization-specific templates and validation.' + ReferenceUris = @() + } + @{ + Id = 'DS-SMB-X-12' + Level = 'Best' + Section = 'Advanced classification' + SourceSection = 'Out-of-the-box Built-in SITs vs. Custom SITs' + Summary = 'Improve classification with trainable classifiers.' + Status = 'NotImplemented' + ActionIds = @() + ConfigPaths = @() + Rationale = 'Trainable classifiers require curated examples, evaluation, and governance outside the shipped baseline.' + ReferenceUris = @() + } + @{ + Id = 'DS-SMB-X-13' + Level = 'Best' + Section = 'Data Security Posture Management' + SourceSection = 'Get started with DSPM' + Summary = 'Activate Data Security Posture Management and complete its initial setup.' + Status = 'NotImplemented' + ActionIds = @() + ConfigPaths = @() + Rationale = 'DSPM activation requires separate tenant permissions, product setup, and operational ownership.' + ReferenceUris = @() + } + @{ + Id = 'DS-SMB-X-14' + Level = 'Best' + Section = 'Data Security Posture Management' + SourceSection = 'Know your data estate' + Summary = 'Review data-security posture objectives, snapshots, and trends.' + Status = 'NotImplemented' + ActionIds = @() + ConfigPaths = @() + Rationale = 'Posture review is an operator assessment workflow rather than a deterministic tenant configuration action.' + ReferenceUris = @() + } + @{ + Id = 'DS-SMB-X-15' + Level = 'Best' + Section = 'Data Security Posture Management' + SourceSection = 'Run Data risk assessment' + Summary = 'Run and review data risk assessments for oversharing in SharePoint, OneDrive, and Fabric.' + Status = 'NotImplemented' + ActionIds = @() + ConfigPaths = @() + Rationale = 'Risk assessment review requires live tenant evidence and remains outside the offline deployment configuration.' + ReferenceUris = @() + } + @{ + Id = 'DS-SMB-X-16' + Level = 'Best' + Section = 'Data Security Posture Management' + SourceSection = 'Configure policies based on recommended actions' + Summary = 'Review DSPM recommendations and create the appropriate out-of-box policies.' + Status = 'NotImplemented' + ActionIds = @() + ConfigPaths = @() + Rationale = 'Recommendation-driven policy creation needs human review, explicit scope, and separate promotion controls.' + ReferenceUris = @() + } + @{ + Id = 'DS-SMB-X-17' + Level = 'Best' + Section = 'Data Security Posture Management' + SourceSection = 'Review policy Impact with Out-of-the-box Posture Report' + Summary = 'Review posture reports to measure how deployed policies affect data protection.' + Status = 'NotImplemented' + ActionIds = @() + ConfigPaths = @() + Rationale = 'Posture reporting depends on live tenant telemetry and is not produced by the offline deployment plan.' + ReferenceUris = @() + } + ) +} diff --git a/Products/Purview/References/MicrosoftLearnLightweightDlpMapping.psd1 b/Products/Purview/References/MicrosoftLearnLightweightDlpMapping.psd1 new file mode 100644 index 0000000..266e294 --- /dev/null +++ b/Products/Purview/References/MicrosoftLearnLightweightDlpMapping.psd1 @@ -0,0 +1,198 @@ +@{ + SchemaVersion = '1.1' + Role = 'Supporting' + Guide = @{ + Id = 'MS-PURVIEW-LIGHTWEIGHT-DLP' + Title = 'Lightweight guide to mitigate data leakage' + Edition = '2026-03-31' + RevisionDate = '2026-03-31' + VerifiedDate = '2026-08-11' + Publisher = 'Microsoft Learn' + SourceClassification = 'AuthoritativePublic' + SourceUri = 'https://learn.microsoft.com/purview/deploymentmodels/depmod-lightweight-dlp-intro' + SourceCommit = 'a580c1c2e34a67b10bce436a05a44376cf21f61d' + Owner = 'SMB Best Practice Tool maintainers' + } + Controls = @( + @{ + Id = 'DS-1.1' + Section = 'Step 1 - Create sensitivity labels' + Summary = 'Create parent sensitivity labels for files and other data assets.' + Status = 'Aligned' + ActionIds = @('purview.labels.taxonomy') + ConfigPaths = @('Labels') + Rationale = '' + ReferenceUris = @('https://learn.microsoft.com/purview/deploymentmodels/depmod-lightweight-dlp-step1') + } + @{ + Id = 'DS-1.2' + Section = 'Step 1 - Create sensitivity sub-labels' + Summary = 'Create granular sub-labels beneath the parent taxonomy.' + Status = 'Aligned' + ActionIds = @('purview.labels.taxonomy') + ConfigPaths = @('Labels[].SubLabels') + Rationale = '' + ReferenceUris = @('https://learn.microsoft.com/purview/deploymentmodels/depmod-lightweight-dlp-step1') + } + @{ + Id = 'DS-1.3' + Section = 'Step 1 - Order labels by priority' + Summary = 'Order labels so the most restrictive protection has the highest priority.' + Status = 'Aligned' + ActionIds = @('purview.labels.priority') + ConfigPaths = @('Labels') + Rationale = '' + ReferenceUris = @('https://learn.microsoft.com/purview/deploymentmodels/depmod-lightweight-dlp-step1') + } + @{ + Id = 'DS-1.4' + Section = 'Step 1 - Publish labels and set a default' + Summary = 'Publish sensitivity labels and configure default labels.' + Status = 'Aligned' + ActionIds = @('purview.labels.publish') + ConfigPaths = @('LabelPolicy') + Rationale = '' + ReferenceUris = @('https://learn.microsoft.com/purview/deploymentmodels/depmod-lightweight-dlp-step1') + } + @{ + Id = 'DS-1.5' + Section = 'Step 1 - Enable labels for SharePoint and OneDrive' + Summary = 'Enable sensitivity-label support for SharePoint and OneDrive files.' + Status = 'Aligned' + ActionIds = @('purview.tenant.spo-labels') + ConfigPaths = @('TenantSettings.EnableAIPIntegrationInSPO') + Rationale = '' + ReferenceUris = @('https://learn.microsoft.com/purview/deploymentmodels/depmod-lightweight-dlp-step1') + } + @{ + Id = 'DS-1.6' + Section = 'Step 1 - Exchange DLP' + Summary = 'Create a separate Exchange DLP policy that blocks external sharing of labeled content.' + Status = 'Conditional' + ActionIds = @('purview.dlp.exchange') + ConfigPaths = @('DlpPolicies[Workload=Exchange]') + Rationale = 'The toolkit uses simulation as the safe default instead of immediate enforcement.' + ReferenceUris = @('https://learn.microsoft.com/purview/deploymentmodels/depmod-lightweight-dlp-step1') + } + @{ + Id = 'DS-1.7' + Section = 'Step 1 - SharePoint and OneDrive DLP' + Summary = 'Create DLP coverage for SharePoint and OneDrive external sharing.' + Status = 'Conditional' + ActionIds = @('purview.dlp.sharepoint-onedrive') + ConfigPaths = @('DlpPolicies[Workload=SharePointOneDrive]') + Rationale = 'The toolkit uses simulation as the safe default instead of immediate enforcement.' + ReferenceUris = @('https://learn.microsoft.com/purview/deploymentmodels/depmod-lightweight-dlp-step1') + } + @{ + Id = 'DS-1.8' + Section = 'Step 1 - Enable audit logging' + Summary = 'Confirm or enable audit logging for user and administrator activity.' + Status = 'Aligned' + ActionIds = @('purview.tenant.audit-standard') + ConfigPaths = @('TenantSettings.EnableUnifiedAuditLog') + Rationale = '' + ReferenceUris = @('https://learn.microsoft.com/purview/deploymentmodels/depmod-lightweight-dlp-step1') + } + @{ + Id = 'DS-2.1' + Section = 'Step 2 - Custom sensitive information types' + Summary = 'Create organization-specific sensitive information types.' + Status = 'NotImplemented' + ActionIds = @() + ConfigPaths = @() + Rationale = 'Custom sensitive information type authoring requires organization-specific patterns and review.' + ReferenceUris = @('https://learn.microsoft.com/purview/deploymentmodels/depmod-lightweight-dlp-step2') + } + @{ + Id = 'DS-2.2' + Section = 'Step 2 - Client-side auto-labeling' + Summary = 'Recommend labels in Office apps by using custom sensitive information types.' + Status = 'NotImplemented' + ActionIds = @() + ConfigPaths = @() + Rationale = 'Auto-labeling is intentionally left for organization-specific false-positive review.' + ReferenceUris = @('https://learn.microsoft.com/purview/deploymentmodels/depmod-lightweight-dlp-step2') + } + @{ + Id = 'DS-2.3' + Section = 'Step 2 - Endpoint DLP' + Summary = 'Protect sensitive data from device copy, print, and upload actions.' + Status = 'Conditional' + ActionIds = @('purview.dlp.endpoint') + ConfigPaths = @('DlpPolicies[Workload=Endpoint]') + Rationale = 'Endpoint DLP is configured but requires E5 or Microsoft Purview Suite entitlement.' + ReferenceUris = @('https://learn.microsoft.com/purview/deploymentmodels/depmod-lightweight-dlp-step2') + } + @{ + Id = 'DS-2.4' + Section = 'Step 2 - Teams DLP' + Summary = 'Block external sharing of sensitive content in Teams chats and channels.' + Status = 'NotImplemented' + ActionIds = @() + ConfigPaths = @() + Rationale = 'The shipped Purview configuration does not define a Teams chat and channel DLP workload.' + ReferenceUris = @('https://learn.microsoft.com/purview/deploymentmodels/depmod-lightweight-dlp-step2') + } + @{ + Id = 'DS-2.5' + Section = 'Step 2 - Email DLP and label inheritance' + Summary = 'Use a separate Exchange DLP policy and inherit labels from email attachments.' + Status = 'Aligned' + ActionIds = @('purview.dlp.exchange', 'purview.labels.attachment-inheritance') + ConfigPaths = @('DlpPolicies[Workload=Exchange]', 'LabelPolicy.AttachmentAction') + Rationale = '' + ReferenceUris = @('https://learn.microsoft.com/purview/deploymentmodels/depmod-lightweight-dlp-step2') + } + @{ + Id = 'DS-3.1' + Section = 'Step 3 - Encrypt labeled content' + Summary = 'Apply encryption to high-sensitivity labeled content.' + Status = 'Aligned' + ActionIds = @('purview.labels.encryption') + ConfigPaths = @('Labels[].Encrypt', 'EncryptionRightsDefinitions') + Rationale = '' + ReferenceUris = @('https://learn.microsoft.com/purview/deploymentmodels/depmod-lightweight-dlp-step3') + } + @{ + Id = 'DS-3.2' + Section = 'Step 3 - Service-side auto-labeling' + Summary = 'Auto-label existing files and in-transit email by using service-side policies.' + Status = 'NotImplemented' + ActionIds = @() + ConfigPaths = @() + Rationale = 'Service-side auto-labeling is intentionally outside the shipped configuration.' + ReferenceUris = @('https://learn.microsoft.com/purview/deploymentmodels/depmod-lightweight-dlp-step3') + } + @{ + Id = 'DS-3.3' + Section = 'Step 3 - Insider Risk Management analytics' + Summary = 'Use Insider Risk Management analytics to identify risk patterns.' + Status = 'NotImplemented' + ActionIds = @() + ConfigPaths = @() + Rationale = 'Insider Risk Management analytics requires separate governance and role review.' + ReferenceUris = @('https://learn.microsoft.com/purview/deploymentmodels/depmod-lightweight-dlp-step3') + } + @{ + Id = 'DS-3.4' + Section = 'Step 3 - Adaptive Protection in DLP' + Summary = 'Adjust DLP actions by Insider Risk Management risk level.' + Status = 'NotImplemented' + ActionIds = @() + ConfigPaths = @() + Rationale = 'Risk-based enforcement is outside the toolkit safe default and needs separate approval design.' + ReferenceUris = @('https://learn.microsoft.com/purview/deploymentmodels/depmod-lightweight-dlp-step3') + } + @{ + Id = 'DS-3.5' + Section = 'Step 3 - Adaptive Protection with Conditional Access' + Summary = 'Restrict SharePoint and OneDrive access for elevated-risk users.' + Status = 'NotApplicable' + ActionIds = @() + ConfigPaths = @() + Rationale = 'Conditional Access enforcement belongs to the Entra product boundary, not the Purview deployment module.' + ReferenceUris = @('https://learn.microsoft.com/purview/deploymentmodels/depmod-lightweight-dlp-step3') + } + ) +} diff --git a/Products/Purview/References/ValidationAdapters.psd1 b/Products/Purview/References/ValidationAdapters.psd1 new file mode 100644 index 0000000..168d307 --- /dev/null +++ b/Products/Purview/References/ValidationAdapters.psd1 @@ -0,0 +1,195 @@ +@{ + # ========================================================================= + # Purview configuration-validation adapter allowlist. + # + # Maintainer-owned reference data used by the validator: + # + # * PurviewValidationModel.ps1 derives AdapterId, comparator, selector, + # prerequisite, and expected values from schema 1.2 IntendedState and + # refuses any value that is not listed here. + # * PurviewValidationCollectors.ps1 dispatches each derived AdapterId + # through a hardcoded switch and asserts that the switch covers exactly this + # list. + # + # A Deployment Plan is operator-supplied input. The validator therefore + # never treats any string in the plan as a command, a script, or a + # property path. The validator can only derive an adapter that already + # exists in this file, and the adapter decides which read commands to run. + # + # Adding an adapter requires: an entry here, a collector branch, an + # API/permission matrix row with a Microsoft Learn reference and a + # verification date, and fixture coverage. + # ========================================================================= + + SchemaVersion = '1.1' + + # Comparators the plan may name for a managed field. The validator rejects + # any other value before it connects to a tenant. + Comparators = @( + 'ExactBoolean' + 'ExactString' + 'CaseInsensitiveString' + 'ExactInt' + 'SetEquality' + 'OrderedSequence' + 'Presence' + ) + + # Prerequisite kinds a plan action may declare. 'License' and 'Capability' + # resolve at validation time; an unresolved prerequisite produces + # 'Not evaluated' rather than 'Drift'. + PrerequisiteKinds = @( + 'None' + 'License' + 'Capability' + ) + + # License prerequisite identifiers. These map to entitlement checks in the + # collector module, not to raw SKU strings in the plan. + LicensePrerequisites = @( + 'BusinessPremiumOrHigher' + 'E5OrPurviewSuite' + 'AuditPremium' + ) + + # Capability prerequisite identifiers. These map to command or service + # availability checks in the collector module. + CapabilityPrerequisites = @( + 'SharePointOnlineSession' + 'GraphDirectorySettings' + ) + + # Allowlisted adapters. 'Reads' is documentation for the API/permission + # matrix and for the report's read attribution. It is never executed from + # this file. + Adapters = @( + @{ + Id = 'purview.tenant.audit-standard' + Module = 'Setup-TenantSettings' + Service = 'ExchangeOnline' + Reads = @('Get-AdminAuditLogConfig') + ManagedFields = @('UnifiedAuditLogIngestionEnabled') + } + @{ + Id = 'purview.tenant.spo-labels' + Module = 'Setup-TenantSettings' + Service = 'SharePointOnline' + Reads = @('Get-SPOTenant') + ManagedFields = @('EnableAIPIntegration') + } + @{ + Id = 'purview.tenant.pdf-labels' + Module = 'Setup-TenantSettings' + Service = 'SharePointOnline' + Reads = @('Get-SPOTenant') + ManagedFields = @('EnableSensitivityLabelForPDF') + } + @{ + Id = 'purview.tenant.container-directory-setting' + Module = 'Setup-TenantSettings' + Service = 'MicrosoftGraph' + Reads = @('Get-MgBetaDirectorySetting') + ManagedFields = @('EnableMIPLabels') + } + @{ + Id = 'purview.tenant.label-coauthoring' + Module = 'Setup-TenantSettings' + Service = 'SecurityCompliance' + Reads = @('Get-PolicyConfig') + ManagedFields = @('EnableLabelCoauth') + } + @{ + Id = 'purview.tenant.audit-premium' + Module = 'Setup-TenantSettings' + Service = 'ExchangeOnline' + Reads = @('Get-Mailbox') + ManagedFields = @('AuditOwnerIncludesSearchQueryInitiated') + } + @{ + Id = 'purview.labels.taxonomy' + Module = 'Setup-SensitivityLabels' + Service = 'SecurityCompliance' + Reads = @('Get-Label') + ManagedFields = @('RootLabelCount', 'SubLabelCount', 'LabelSignatures') + } + @{ + Id = 'purview.labels.priority' + Module = 'Setup-SensitivityLabels' + Service = 'SecurityCompliance' + Reads = @('Get-Label') + ManagedFields = @('PriorityOrder') + } + @{ + Id = 'purview.labels.publish' + Module = 'Setup-SensitivityLabels' + Service = 'SecurityCompliance' + Reads = @('Get-LabelPolicy', 'Get-Label') + ManagedFields = @( + 'PolicyPresent', 'PublishedLabelSignatures', 'DefaultLabelSignature', + 'DefaultLabelForEmailSignature', 'MandatoryLabelling', 'DowngradeJustification' + ) + } + @{ + Id = 'purview.labels.attachment-inheritance' + Module = 'Setup-SensitivityLabels' + Service = 'SecurityCompliance' + Reads = @('Get-LabelPolicy') + ManagedFields = @('AttachmentAction') + } + @{ + Id = 'purview.labels.content-marking' + Module = 'Setup-SensitivityLabels' + Service = 'SecurityCompliance' + Reads = @('Get-Label') + ManagedFields = @('ContentMarkedLabelSignatures') + } + @{ + Id = 'purview.labels.encryption' + Module = 'Setup-SensitivityLabels' + Service = 'SecurityCompliance' + Reads = @('Get-Label') + ManagedFields = @( + 'EncryptedLabelSignatures', 'EncryptionOfflineAccessDays', + 'EncryptionContentExpiration' + ) + } + @{ + Id = 'purview.labels.container-scope' + Module = 'Setup-SensitivityLabels' + Service = 'SecurityCompliance' + Reads = @('Get-Label') + ManagedFields = @('ContainerScopedLabelSignatures') + } + @{ + Id = 'purview.dlp.workload' + Module = 'Setup-DLP' + Service = 'SecurityCompliance' + Reads = @('Get-DlpCompliancePolicy', 'Get-DlpComplianceRule') + ManagedFields = @( + 'PolicyPresent', 'PolicyMode', 'RulePresent', 'BlockAccess', + 'LabelPathCount', 'LabelSignatures', 'PolicySignatures' + ) + } + @{ + Id = 'purview.retention.exchange' + Module = 'Setup-Retention' + Service = 'SecurityCompliance' + Reads = @('Get-RetentionCompliancePolicy', 'Get-RetentionComplianceRule') + ManagedFields = @( + 'PolicyPresent', 'RetentionDurationDays', 'RetentionAction', + 'ExpirationDateOption', 'Locations' + ) + } + @{ + Id = 'purview.ai.copilot-dlp' + Module = 'Setup-AIGovernance' + Service = 'SecurityCompliance' + Reads = @('Get-DlpCompliancePolicy', 'Get-DlpComplianceRule') + ManagedFields = @( + 'PolicyCount', 'PolicyMode', 'EnforcementPlanes', + 'RestrictAccessSettings', 'ScopePrincipalSignatures', + 'LabelPathCount', 'PolicySignatures' + ) + } + ) +} diff --git a/Products/Purview/Test-PurviewTenantConfiguration.ps1 b/Products/Purview/Test-PurviewTenantConfiguration.ps1 new file mode 100644 index 0000000..cd5c9b2 --- /dev/null +++ b/Products/Purview/Test-PurviewTenantConfiguration.ps1 @@ -0,0 +1,315 @@ +#requires -Version 7.0 +# smb-quality-gate: read-only +<# +.SYNOPSIS + Standalone, read-only Purview configuration validation against a + Deployment Plan. + +.DESCRIPTION + Reads current Microsoft Purview tenant configuration. With -PlanPath, it + compares observable state with a schema 1.2 Deployment Plan intended-state + snapshot. Without -PlanPath, it assesses the pinned Good, Better, and Best + guide controls without assuming the repository's default configuration. + + This tool never changes tenant state. It has no apply path or repair switch. + It declares SupportsShouldProcess for repository contract parity, but GET + operations are not gated and local evidence is written with + -WhatIf:$false. Any future repair capability is a separate, explicitly + approved design. + + What the report is: + + * a comparison of observable tenant settings with one plan; + * scoped to fields the toolkit owns, so customer-managed settings are + reported as unscored context rather than as failures. + + What the report is not: + + * a compliance assessment; + * a measure of data-protection effectiveness or user adoption; + * a substitute for the deployment run report. + + Results use exactly five values: Matched, Drift, Not evaluated, + Informational, and Collection failed. There is no "pending propagation" + result: a mismatch that survives the shared transient-retry boundary is + reported as drift. + + Exit codes: + + 0 no drift and no collection failures + 2 drift present + 4 collection failure present + 6 both drift and collection failure + 1 fatal input, schema, tenant-identity, or connection failure + +.PARAMETER PlanPath + Optional path to a schema 1.2 Deployment Plan JSON sidecar. + +.PARAMETER TenantAdminUpn + UPN used to sign in to the tenant being validated. + +.PARAMETER OutputPath + HTML report path. The JSON sidecar uses the same basename. Defaults to a + timestamped file in the current directory. + +.PARAMETER DelegatedOrganization + Customer tenant primary domain when a partner signs in through GDAP. + +.PARAMETER SharePointAdminUrl + Explicit SharePoint admin URL for tenants where it cannot be derived. + +.PARAMETER ClientId + Optional approved tenant-local public-client application ID. The effective + Graph context must contain only the validator's two resource scopes plus + standard OpenID identity scopes. + +.PARAMETER UseDeviceAuthentication + Use Microsoft Graph device authentication when the host cannot display the + default WAM or browser flow. + +.PARAMETER AutoInstallModules + Install missing read-only modules for the current user without prompting. + +.PARAMETER NonInteractive + Fail instead of prompting. A tenant identity mismatch is always fatal. + +.EXAMPLE + .\Test-PurviewTenantConfiguration.ps1 ` + -PlanPath .\Deploy-PurviewBestPractice-Plan-20260814.json ` + -TenantAdminUpn admin@contoso.onmicrosoft.com + +.EXAMPLE + # Partner GDAP scenario with an explicit report location + .\Test-PurviewTenantConfiguration.ps1 ` + -PlanPath .\plan.json ` + -TenantAdminUpn partneradmin@fabrikam.onmicrosoft.com ` + -DelegatedOrganization contoso.onmicrosoft.com ` + -OutputPath .\evidence\contoso-validation.html +#> + +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Low')] +param( + [Parameter()] + [string] $PlanPath, + + [Parameter(Mandatory)] + [string] $TenantAdminUpn, + + [Parameter()] + [string] $OutputPath, + + [Parameter()] + [string] $DelegatedOrganization, + + [Parameter()] + [string] $SharePointAdminUrl, + + [Parameter()] + [ValidatePattern('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')] + [string] $ClientId, + + [Parameter()] + [switch] $UseDeviceAuthentication, + + [Parameter()] + [switch] $AutoInstallModules, + + [Parameter()] + [switch] $NonInteractive +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$script:ValidationVersion = '1.1.0' + +$modulesPath = Join-Path $PSScriptRoot 'Modules' +. (Join-Path $modulesPath 'PurviewRunLog.ps1') +. (Join-Path $modulesPath 'PurviewValidationModel.ps1') +. (Join-Path $modulesPath 'PurviewValidationCollectors.ps1') +. (Join-Path $modulesPath 'Write-PurviewValidationReport.ps1') +. (Join-Path $modulesPath 'PurviewTenantIdentity.ps1') + +Initialize-PurviewRunLog + +function Resolve-ValidationOutputPath { + [CmdletBinding()] + param([Parameter()] [string] $Requested) + + if ($Requested) { + if (Test-Path -LiteralPath $Requested -PathType Container) { + throw 'The -OutputPath value is a directory. Supply a file path ending in .html.' + } + return $Requested + } + + $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmss') + return Join-Path (Get-Location).Path "Test-PurviewTenantConfiguration-$stamp.html" +} + +$exitCode = 1 +$startedAt = [datetime]::UtcNow + +try { + Write-Host '' + Write-Host '=== Purview configuration validation (read-only) ===' -ForegroundColor White + Write-Host 'This tool reads tenant settings and makes no tenant changes.' -ForegroundColor DarkGray + + $adapterPath = Join-Path $PSScriptRoot 'References\ValidationAdapters.psd1' + if (-not (Test-Path -LiteralPath $adapterPath -PathType Leaf)) { + throw "Validation adapter allowlist not found: $adapterPath" + } + $allowlist = Import-PowerShellDataFile -LiteralPath $adapterPath + Assert-PurviewValidationAdapterCoverage -Allowlist $allowlist + + Write-Host '' + if ($PlanPath) { + Write-Host '--- Deployment Plan ---' -ForegroundColor White + $plan = Test-PurviewValidationPlan -Path $PlanPath -Allowlist $allowlist + Write-Host (" Plan {0}, schema {1}, {2} actions." -f ` + $plan.PlanReference, $plan.SchemaVersion, @($plan.Actions).Count) -ForegroundColor Green + Add-RunLogEntry -Module 'Test-PurviewTenantConfiguration' -Action 'Validate plan' ` + -Status 'Succeeded' -Detail ("schema={0}; actions={1}" -f $plan.SchemaVersion, @($plan.Actions).Count) + } else { + Write-Host '--- Guide-only assessment ---' -ForegroundColor White + $guidePath = Join-Path $PSScriptRoot 'References\DataSecuritySmbGuideMapping.psd1' + $guide = Import-PowerShellDataFile -LiteralPath $guidePath + $plan = New-PurviewGuideAssessmentPlan -GuideMapping $guide + Write-Host ' No plan supplied. Assessing pinned Good, Better, and Best controls.' -ForegroundColor Green + Add-RunLogEntry -Module 'Test-PurviewTenantConfiguration' -Action 'Select assessment mode' ` + -Status 'Succeeded' -Detail 'Guide-only assessment; no configuration intent loaded.' + } + + $reportPath = Resolve-ValidationOutputPath -Requested $OutputPath + + # Only connect the services the plan actually needs, and only for actions + # that will be scored. An excluded action must not cause an operator to + # authenticate a session, let alone trigger a tenant read. + $scoredAdapterIds = @( + $plan.Actions | + Where-Object { [bool]$_.Validation.Scored } | + ForEach-Object { [string]$_.Validation.AdapterId } | + Select-Object -Unique + ) + $needsSharePoint = @($scoredAdapterIds | Where-Object { + $_ -in @('purview.tenant.spo-labels', 'purview.tenant.pdf-labels') + }).Count -gt 0 + $needsGraph = $true + + Write-Host '' + Write-Host '--- Read-only service connections ---' -ForegroundColor White + $connectArgs = @{ + TenantAdminUpn = $TenantAdminUpn + NeedsGraph = $needsGraph + } + if ($needsSharePoint) { $connectArgs['NeedsSharePoint'] = $true } + if ($DelegatedOrganization) { $connectArgs['DelegatedOrganization'] = $DelegatedOrganization } + if ($SharePointAdminUrl) { $connectArgs['SharePointAdminUrl'] = $SharePointAdminUrl } + if ($ClientId) { $connectArgs['ClientId'] = $ClientId } + if ($UseDeviceAuthentication) { $connectArgs['UseDeviceAuthentication'] = $true } + if ($AutoInstallModules) { $connectArgs['AutoInstallModules'] = $true } + if ($NonInteractive) { $connectArgs['NonInteractive'] = $true } + + $connectScript = Join-Path $modulesPath 'Connect-PurviewValidationServices.ps1' + $connection = & $connectScript @connectArgs + + Write-Host '' + Write-Host '--- Tenant identity confirmation ---' -ForegroundColor White + $identity = Get-PurviewTenantIdentity + $identityMatch = Test-PurviewExpectedTenantMatch -Identity $identity ` + -TenantAdminUpn $TenantAdminUpn -DelegatedOrganization $DelegatedOrganization + + $tenantLabel = if ($identity.DisplayName) { $identity.DisplayName } else { '(not resolved)' } + Write-Host (" Connected tenant: {0} (source: {1})" -f $tenantLabel, $identity.Source) -ForegroundColor DarkGray + + if (-not $identityMatch.Matched) { + Add-RunLogEntry -Module 'Test-PurviewTenantConfiguration' -Action 'Tenant identity' ` + -Status 'Failed' -Detail $identityMatch.Reason + throw ( + "Tenant identity mismatch. $($identityMatch.Reason) " + + "Expected source: $($identityMatch.Source). " + + 'Validation stopped so the report cannot describe the wrong tenant.') + } + Write-Host ' Identity check passed.' -ForegroundColor Green + Add-RunLogEntry -Module 'Test-PurviewTenantConfiguration' -Action 'Tenant identity' ` + -Status 'Succeeded' -Detail 'Connected tenant matches the expected domain.' + + # Custom verified domains cannot be recognized by shape, so they are + # registered as literal redaction terms before any service text is + # captured into the model. + Set-PurviewValidationRedactionTerm -Term @( + @($identity.AllDomains) + $identity.DefaultDomain + $DelegatedOrganization + ) + + $entitlement = [pscustomobject]@{ + State = 'Unknown' + Tier = 'Unknown' + Available = $false + Reason = 'The validator does not request license-assignment scope. License-gated controls remain provisional unless the workload read proves support.' + } + + $managedTag = if ($plan.PSObject.Properties['ManagedByTag']) { [string]$plan.ManagedByTag } else { '' } + $capabilities = @{ + SharePointOnlineSession = ($needsSharePoint -and [bool]$connection.SharePointConnected) + GraphDirectorySettings = ([bool]$connection.GraphConnected -and + $null -ne (Get-Command -Name 'Get-MgBetaDirectorySetting' -ErrorAction SilentlyContinue)) + } + $context = New-PurviewValidationContext -ManagedByTag $managedTag ` + -Entitlement $entitlement -Capabilities $capabilities + + Write-Host '' + Write-Host '--- Collecting tenant configuration ---' -ForegroundColor White + $results = Invoke-PurviewValidationCollection -Plan $plan -Context $context -Progress { + param($action) + Write-Host (" {0}" -f $action.ActionId) -ForegroundColor DarkGray + } + + $model = New-PurviewValidationModel -Plan $plan -Results $results ` + -ToolkitVersion $script:ValidationVersion ` + -ObservedAt $startedAt ` + -DurationSeconds ([int]([datetime]::UtcNow - $startedAt).TotalSeconds) ` + -TenantDisplayName $identity.DisplayName ` + -TenantIdentityCheck 'Connected tenant verified' ` + -ServiceStatus $connection ` + -Diagnostics @(Get-PurviewRunLog | ForEach-Object { + [pscustomobject]@{ + Module = $_.Module + Action = $_.Action + Status = $_.Status + Detail = ConvertTo-PurviewValidationRedactedText -Text ([string]$_.Detail) + } + }) + + $exitCode = Get-PurviewValidationExitCode -Model $model + + # Print the outcome before writing, so an artifact-write failure does not + # leave the operator with no result at all. + Write-Host '' + Write-Host '--- Summary ---' -ForegroundColor White + Write-Host (" Matched: {0} Drift: {1} Not evaluated: {2} Informational: {3} Collection failed: {4}" -f ` + $model.Summary.Matched, $model.Summary.Drift, $model.Summary.NotEvaluated, ` + $model.Summary.Informational, $model.Summary.CollectionFailed) -ForegroundColor White + Write-Host (" Not evaluated breakdown: prerequisite unmet {0}, prerequisite unreadable {1}" -f ` + $model.Summary.PrerequisiteUnmet, $model.Summary.PrerequisiteUnknown) -ForegroundColor DarkGray + + $written = Write-PurviewValidationReport -Model $model -OutputPath $reportPath + + Write-Host (" Report: {0}" -f (Split-Path -Leaf $written.HtmlPath)) -ForegroundColor Green + Write-Host (" Sidecar: {0}" -f (Split-Path -Leaf $written.JsonPath)) -ForegroundColor Green + Write-Host (" Exit code: {0}" -f $exitCode) -ForegroundColor DarkGray + Write-Host (" Proven level: {0} Provisional level: {1}" -f ` + $model.Summary.ProvenLevel, $model.Summary.ProvisionalLevel) -ForegroundColor DarkGray +} +catch { + $exitCode = 1 + Write-Host '' + Write-Error -Message $_.Exception.Message -ErrorAction Continue + Write-Host ' Validation did not complete. No tenant state was read or changed after this point.' -ForegroundColor DarkGray +} +finally { + Clear-PurviewRunLog +} + +exit $exitCode diff --git a/Products/Purview/docs/Change-Management-Playbook.html b/Products/Purview/docs/Change-Management-Playbook.html index 83f2350..0c1378d 100644 --- a/Products/Purview/docs/Change-Management-Playbook.html +++ b/Products/Purview/docs/Change-Management-Playbook.html @@ -1,304 +1,306 @@ - - - - - -Change-Management Playbook — Purview deployment timeline - - - - -
- -
-
Microsoft Purview Best Practice Toolkit · Delivery Edition
-

Change-Management Playbook

-

The script handles the engineering. This playbook is what makes it safe in - production. Several defaults have customer-visible consequences the moment they take effect — - silent labelling on every new document, 7-year mail deletion, DLP blocking external sends. - Follow this timeline so the deploy lands as a planned change, not a help-desk ticket generator.

-
Audience: the deployment lead (a partner/MSP consultant or in-house IT). For the end-user-facing - companion (what employees see + ready-to-send comms), see the End-User Adoption Guide.
-
- -
- Deploy-team action - Needs business input - Decision gate -
- -
- - -
-
📋
-
-
Phase 0 · T-7 days
-

Pre-deploy — plan the change

-

Nothing touches the tenant yet. This phase exists to make the rest predictable.

-
    -
  • Confirm tenant SKU — Business Premium vs E5 / Purview Suite. Decides whether -BPOnly is needed; AI governance is default-on for E5 / Purview Suite.Deploy team
  • -
  • Confirm vertical & regulatory framework — decides whether to enable retention and at what duration (7-year delete is opt-in).Deploy + business
  • -
  • Inventory current Purview state — existing labels / DLP / retention. Decide -AdoptExisting vs rename/archive.Deploy team
  • -
  • Identify B2B guest exposure — routine external guests change the encryption scope picture.Deploy + business
  • -
  • Pick a pilot mailbox / site for retention & DLP simulation review.Deploy + business
  • -
  • Schedule the deploy window AND the day-30 review in the same calendar invite.Deploy team
  • -
  • Draft user comms — sent the day before deploy.Deploy team
  • -
-
-

Decisions to lock before you run the script

-
    -
  • -BPOnly · -SkipAIControls · -ApplyRetention (+ duration) · -SkipContainerLabels
  • -
  • -EnablePremiumAudit (+ mailboxes) · -EnableLabelCoAuthoring (one-way!)
  • -
  • EnableContentMarking in config — leave $false for initial deploy; flip after day-30
  • -
-
-
-
- - -
-
🚀
-
-
Phase 1 · T-0
-

Day-of deploy

-

Preview, apply during the agreed window, and verify simulation mode before anyone notices.

-
    -
  • Run with -WhatIf first; capture the full transcript.
  • -
  • Diff the preview against the Phase 0 inventory — nothing unexpected created/adopted.
  • -
  • Run in apply mode during the agreed window.
  • -
  • Save the deploy log + preflight banner to the customer record (audit trail).
  • -
  • In the Purview portal, confirm objects exist and are stamped [Managed by SMBTool Purview Toolkit].
  • -
  • Verify DLP simulation mode — one policy should read "Test with notifications off". Zero user impact day one.
  • -
  • Send the "deploy complete" comms.
  • -
-
-

Common day-of surprises

-
    -
  • Connect-SPOService clashes when EXO/Graph/Az/PnP are pre-loaded — pass -SharePointAdminUrl on vanity-UPN tenants.
  • -
  • Set-Label -Priority throws on pre-existing top-level labels — audit existing labels in Phase 0.
  • -
  • IPPS can report success before settings are live — wait 5–10 min and refresh; the script retries known races.
  • -
-
-
-
- - -
-
🔎
-
-
Phase 2 · Day 1–5
-

Soak & monitor

-

Labels are live; DLP only observes. Watch the signals before anything enforces.

-
    -
  • Day 1 AM: check the helpdesk queue for Purview-related tickets.
  • -
  • Day 1: confirm General \ Anyone (unrestricted) is the Outlook default and Confidential\AllEmployees the Office default.
  • -
  • Day 1–3: pilot owner sends a labelled doc externally — confirm the hit shows in Activity Explorer (no block — simulation).
  • -
  • Day 3: review Activity Explorer for unexpected match patterns (often a customer process, not a bug).
  • -
  • Day 5: spot-check audit-log entries to confirm UnifiedAuditLog captures label events.
  • -
-
-

What the customer sees this week

-
    -
  • Users will see new labels in Outlook / Word / Excel / PowerPoint on day 1.
  • -
  • They will not be blocked from anything yet (DLP is in simulation).
  • -
  • Footers / watermarks are not shown unless EnableContentMarking = $true (off by default until day-30).
  • -
-
-
-
- - -
-
🚦
-
-
Phase 3 · Day ~30 · THE GATE
-

Promote-from-simulation gate

-

The conversation most deploys skip — and the one that decides whether DLP becomes - real protection or shelfware.

-
-

Inputs to the conversation

-
    -
  • DLP Activity Explorer report — "what would have been blocked" (see the DLP Simulation Exit Runbook).
  • -
  • Helpdesk ticket count related to labels / DLP since day-of.
  • -
  • Pilot feedback — any complaints about labels, footers, or anything else?
  • -
-
- - - - - - - -
DecisionOptionsHow to action
Promote DLP out of simulation?Promote / extend / refine firstFlip DlpStartInSimulation = $false & re-run, or toggle per policy in portal
Turn on content marking?Yes / noFlip EnableContentMarking = $true & re-run
Tighten Endpoint DLP (E5)?Audit → Block / WarnEdit EndpointDlpRestrictions[].Value & re-run
Reconfirm retentionEnable / keep off / change duration-ApplyRetention on re-run, or edit Retention.DurationDays
Add SPO / OneDrive to retention?Yes / noAdd to Retention.Locations
-
-

Output (don't skip)

-
    -
  • A signed-off "Phase 3 outcome" note on the customer record listing the decisions + the timestamp of the re-run. Without it, nobody can later answer "is this tenant production-grade Purview, or still in simulation?"
  • -
-
-
-
- - -
-
✅
-
-
Ongoing
-

Operate & re-run

-

The deployment is idempotent — re-run any time to verify or extend the baseline.

-
    -
  • Send the Day +30 adoption comms (enforcement is now live for promoted policies).
  • -
  • Keep the deploy reports as the configuration baseline + audit trail.
  • -
  • Any future change re-enters this same simulate-then-promote method.
  • -
-
-
- -
- -
-

Known sharp edges — cover these in Phase 0

-

Not bugs — defaults that are correct technically but need a customer conversation.

-
-
-

Encrypted-label rights are tenant-scoped, not retroactive

-

The {TenantDomain} token scopes HC encryption rights to your tenant's users only (excludes external B2B guests). Tightening the template does not revoke access on files labelled earlier — owners must re-apply the label.

-
-
-

"Trusted People" / "Specific People" labels prompt the user

-

The two UserDefined sub-labels (Confidential\TrustedPeople, Highly Confidential\Specific People) apply UserDefined encryption — the author picks recipients each time (Trusted People: Encrypt-Only, recipients can reshare; Specific People: Do Not Forward). Expect a prompt; it's by design.

-
-
-

Co-authoring is a one-way switch

-

-EnableLabelCoAuthoring moves label metadata out of custom properties. Disabling later is PowerShell-only and loses labels on unencrypted Office files; older AIP scanner / OneDrive sync / MIP SDK / custom scanners break. Only enable after confirming none are in use.

-
-
-

Mandatory labelling prompts on save/send

-

Every Office app prompts for a label before saving a new doc or sending new mail. This closes the untagged-content hole DLP/Copilot rely on — but tell users first (training beats surprise).

-
-
-

Full detail, including the rights bundles and mitigations, is in the text version below this guide.

-
- -
- Change-Management Playbook (Delivery Edition) for the SMB Best Practice Tool · Microsoft Purview - toolkit. A delivery-method guide for whoever runs the toolkit (partners/MSPs or in-house IT) — authoritative behaviour lives in - Deploy-PurviewBestPractice.ps1 and PurviewConfig.psd1. Provided AS IS with no warranty. -
- -
- - + + + + + +Change-Management Playbook — Purview deployment timeline + + + + +
+ +
+
Microsoft Purview Best Practice Toolkit · Delivery Edition
+

Change-Management Playbook

+

The script handles the engineering. This playbook is what makes it safe in + production. Several defaults have customer-visible consequences the moment they take effect — + silent labelling on every new document, 7-year mail deletion, DLP blocking external sends. + Follow this timeline so the deploy lands as a planned change, not a help-desk ticket generator.

+
Audience: the deployment lead (a partner/MSP consultant or in-house IT). For the end-user-facing + companion (what employees see + ready-to-send comms), see the End-User Adoption Guide.
+
+ +
+ Deploy-team action + Needs business input + Decision gate +
+ +
+ + +
+
📋
+
+
Phase 0 · T-7 days
+

Pre-deploy — plan the change

+

Nothing touches the tenant yet. This phase exists to make the rest predictable.

+
    +
  • Confirm tenant SKU — Business Premium vs E5 / Purview Suite. Decides whether -BPOnly is needed; AI governance is default-on for E5 / Purview Suite.Deploy team
  • +
  • Confirm vertical & regulatory framework — decides whether to enable retention and at what duration (7-year delete is opt-in).Deploy + business
  • +
  • Inventory current Purview state — existing labels / DLP / retention. Decide -AdoptExisting vs rename/archive.Deploy team
  • +
  • Identify B2B guest exposure — routine external guests change the encryption scope picture.Deploy + business
  • +
  • Pick a pilot mailbox / site for retention & DLP simulation review.Deploy + business
  • +
  • Schedule the deploy window AND the day-30 review in the same calendar invite.Deploy team
  • +
  • Draft user comms — sent the day before deploy.Deploy team
  • +
  • Review the offline Deployment Plan generated before authentication. Confirm scope, opt-ins, conditional licensing, and Good / Better / Best guide coverage.Deploy team + reviewer
  • +
+
+

Decisions to lock before you run the script

+
    +
  • -BPOnly · -SkipAIControls · -ApplyRetention (+ duration) · -SkipContainerLabels
  • +
  • -EnablePremiumAudit (+ mailboxes) · -EnableLabelCoAuthoring (one-way!)
  • +
  • EnableContentMarking in config — leave $false for initial deploy; flip after day-30
  • +
+
+
+
+ + +
+
🚀
+
+
Phase 1 · T-0
+

Day-of deploy

+

Preview, apply during the agreed window, and verify simulation mode before anyone notices.

+
    +
  • Save the Deployment Plan HTML + JSON written before authentication. Keep the plan reference, sanitized intended-state snapshot, primary SMB tier, and supporting Microsoft Learn comparisons. It describes config intent, not tenant state.
  • +
  • Run with -WhatIf first; capture the full transcript.
  • +
  • Diff the preview against the Phase 0 inventory — nothing unexpected created/adopted.
  • +
  • Run in apply mode during the agreed window.
  • +
  • Save the deploy log + preflight banner to the customer record (audit trail).
  • +
  • In the Purview portal, confirm objects exist and are stamped [Managed by SMBTool Purview Toolkit].
  • +
  • Verify DLP simulation mode — one policy should read "Test with notifications off". Zero user impact day one.
  • +
  • Send the "deploy complete" comms.
  • +
+
+

Common day-of surprises

+
    +
  • Connect-SPOService clashes when EXO/Graph/Az/PnP are pre-loaded — pass -SharePointAdminUrl on vanity-UPN tenants.
  • +
  • Set-Label -Priority throws on pre-existing top-level labels — audit existing labels in Phase 0.
  • +
  • IPPS can report success before settings are live — wait 5–10 min and refresh; the script retries known races.
  • +
+
+
+
+ + +
+
🔎
+
+
Phase 2 · Day 1–5
+

Soak & monitor

+

Labels are live; DLP only observes. Watch the signals before anything enforces.

+
    +
  • Day 1 AM: check the helpdesk queue for Purview-related tickets.
  • +
  • Day 1: confirm General \ Anyone (unrestricted) is the Outlook default and Confidential\AllEmployees the Office default.
  • +
  • Day 1–3: pilot owner sends a labelled doc externally — confirm the hit shows in Activity Explorer (no block — simulation).
  • +
  • Day 3: review Activity Explorer for unexpected match patterns (often a customer process, not a bug).
  • +
  • Day 5: spot-check audit-log entries to confirm UnifiedAuditLog captures label events.
  • +
+
+

What the customer sees this week

+
    +
  • Users will see new labels in Outlook / Word / Excel / PowerPoint on day 1.
  • +
  • They will not be blocked from anything yet (DLP is in simulation).
  • +
  • Footers / watermarks are not shown unless EnableContentMarking = $true (off by default until day-30).
  • +
+
+
+
+ + +
+
🚦
+
+
Phase 3 · Day ~30 · THE GATE
+

Promote-from-simulation gate

+

The conversation most deploys skip — and the one that decides whether DLP becomes + real protection or shelfware.

+
+

Inputs to the conversation

+
    +
  • DLP Activity Explorer report — "what would have been blocked" (see the DLP Simulation Exit Runbook).
  • +
  • Helpdesk ticket count related to labels / DLP since day-of.
  • +
  • Pilot feedback — any complaints about labels, footers, or anything else?
  • +
+
+ + + + + + + +
DecisionOptionsHow to action
Promote DLP out of simulation?Promote / extend / refine firstFlip DlpStartInSimulation = $false & re-run, or toggle per policy in portal
Turn on content marking?Yes / noFlip EnableContentMarking = $true & re-run
Tighten Endpoint DLP (E5)?Audit → Block / WarnEdit EndpointDlpRestrictions[].Value & re-run
Reconfirm retentionEnable / keep off / change duration-ApplyRetention on re-run, or edit Retention.DurationDays
Add SPO / OneDrive to retention?Yes / noAdd to Retention.Locations
+
+

Output (don't skip)

+
    +
  • A signed-off "Phase 3 outcome" note on the customer record listing the decisions + the timestamp of the re-run. Without it, nobody can later answer "is this tenant production-grade Purview, or still in simulation?"
  • +
+
+
+
+ + +
+
✅
+
+
Ongoing
+

Operate & re-run

+

The deployment is idempotent — re-run any time to verify or extend the baseline.

+
    +
  • Send the Day +30 adoption comms (enforcement is now live for promoted policies).
  • +
  • Keep the deploy reports as the configuration baseline + audit trail.
  • +
  • Any future change re-enters this same simulate-then-promote method.
  • +
+
+
+ +
+ +
+

Known sharp edges — cover these in Phase 0

+

Not bugs — defaults that are correct technically but need a customer conversation.

+
+
+

Encrypted-label rights are tenant-scoped, not retroactive

+

The {TenantDomain} token scopes HC encryption rights to your tenant's users only (excludes external B2B guests). Tightening the template does not revoke access on files labelled earlier — owners must re-apply the label.

+
+
+

"Trusted People" / "Specific People" labels prompt the user

+

The two UserDefined sub-labels (Confidential\TrustedPeople, Highly Confidential\Specific People) apply UserDefined encryption — the author picks recipients each time (Trusted People: Encrypt-Only, recipients can reshare; Specific People: Do Not Forward). Expect a prompt; it's by design.

+
+
+

Co-authoring is a one-way switch

+

-EnableLabelCoAuthoring moves label metadata out of custom properties. Disabling later is PowerShell-only and loses labels on unencrypted Office files; older AIP scanner / OneDrive sync / MIP SDK / custom scanners break. Only enable after confirming none are in use.

+
+
+

Mandatory labelling prompts on save/send

+

Every Office app prompts for a label before saving a new doc or sending new mail. This closes the untagged-content hole DLP/Copilot rely on — but tell users first (training beats surprise).

+
+
+

Full detail, including the rights bundles and mitigations, is in the text version below this guide.

+
+ +
+ Change-Management Playbook (Delivery Edition) for the SMB Best Practice Tool · Microsoft Purview + toolkit. A delivery-method guide for whoever runs the toolkit (partners/MSPs or in-house IT) — authoritative behaviour lives in + Deploy-PurviewBestPractice.ps1 and PurviewConfig.psd1. Provided AS IS with no warranty. +
+ +
+ + diff --git a/Products/Purview/docs/Change-Management-Playbook.md b/Products/Purview/docs/Change-Management-Playbook.md index 308e9df..854df46 100644 --- a/Products/Purview/docs/Change-Management-Playbook.md +++ b/Products/Purview/docs/Change-Management-Playbook.md @@ -1,322 +1,324 @@ ---- -title: Change-Management Playbook -layout: default -parent: Purview -nav_order: 4 -permalink: /purview/change-management-playbook/ ---- - -# Change-Management Playbook (Delivery Edition) - -> **Audience.** The deployment lead — a partner/MSP consultant or in-house IT — responsible for -> rolling the SMBTool Purview baseline into a customer tenant. This is the -> *non-technical* counterpart to the script — it covers the customer -> conversation, comms, and the day-30 promote-from-simulation gate. -> -> **For the end-user-facing companion** (what employees see, plain-language -> FAQ, ready-to-send Day -7 / Day 0 / Day +30 comms templates), see -> [`End-User-Adoption-Guide.md`](End-User-Adoption-Guide.md). - -The script handles the engineering correctly. **This playbook is what makes -it safe in production.** Several defaults have customer-visible consequences -the moment they take effect (silent labelling on every new document, -7-year mail deletion, DLP blocking external sends). Without a conversation -and comms plan around them, the technical deploy is a help-desk ticket -generator. - -Use this as a checklist. Skip nothing on the first deployment for a -customer; you can shorten subsequent ones with experience. - -[Open the timeline full-screen ↗]({{ '/Products/Purview/docs/Change-Management-Playbook.html' | relative_url }}){: .btn .btn-purple target="_blank" } -[Download the standalone file ↗]({{ '/Products/Purview/docs/Change-Management-Playbook.html' | relative_url }}){: .btn target="_blank" download } - -> 💡 The visual timeline below is a **single self-contained HTML file** -> (`Products/Purview/docs/Change-Management-Playbook.html`). It needs no server, -> so you can share it with the delivery team on its own or open it offline. The -> **full text** of the playbook — including every checklist row and the "known -> sharp edges" detail — is kept below the timeline. - - - ---- - -## 📋 Full playbook (text version) -{: .no_toc } - -The complete checklist in Markdown — handy for copying rows into a delivery -tracker, and the authoritative detail for the "known sharp edges" summarised -visually above. - -## Phase 0 — Pre-deploy (T-7 days) - -| # | Action | Owner | Output | -|---|--------|-------|--------| -| 1 | **Confirm tenant SKU** (Business Premium vs E5 / Purview Suite) | Deploy team | Decides whether `-BPOnly` is required. AI governance is default-on for E5 / Purview Suite tenants (covers both paid Microsoft 365 Copilot and free Copilot Chat) and auto-skipped on Business Premium | -| 2 | **Confirm vertical and any regulatory framework** (law, accounting, healthcare, financial advisor, construction, real estate) | Deploy + business | Decides whether to enable retention at all (`-ApplyRetention` is opt-in) and, if enabled, what duration. The **default 7-year mail deletion** aligns with most SMB regulatory frameworks (ATO / IRS / SEC / ASIC) but may still be wrong for some verticals — see [Retention-Default-Risk.md](Retention-Default-Risk.md) | -| 3 | **Inventory current Purview state** in the tenant — existing labels, DLP policies, retention policies | Deploy team | If any exist, decide between `-AdoptExisting` (toolkit takes over) or rename / archive first | -| 4 | **Identify B2B guest exposure** — does the customer routinely invite accountants, lawyers, MSPs as guests? | Deploy + business | If yes, the `AuthenticatedUsers` encryption scope is **not** "internal-only" — see [Known sharp edges](#known-sharp-edges) | -| 5 | **Pick a pilot mailbox / pilot site** for retention and DLP simulation review | Deploy + business | Names and UPNs go in the pre-deploy doc | -| 6 | **Schedule the deploy window** and the day-30 review meeting in the same calendar invite | Deploy team | Both dates booked before the deploy | -| 7 | **Draft user comms** (see [Comms templates](#comms-templates)) | Deploy team | Sent the day before the deploy | - -### Decisions to lock before you run the script - -* `-BPOnly` — yes/no -* `-SkipAIControls` — yes/no (AI governance is default-on for E5 / Purview Suite; only set this if the customer has a specific reason to opt out) -* `-ApplyRetention` — yes/no (**opt-in** — only enable if the customer has consciously chosen a retention duration appropriate for their vertical) -* `-SkipContainerLabels` — yes/no (default behaviour is to enable; only set this if container labels are managed by another process or the tenant lacks Entra ID P1). Strips both the tenant-side `Group.Unified EnableMIPLabels = True` toggle AND the container-scope bits (`Site`, `UnifiedGroup`) on the 3 published labels' `ContentType`. The labels still ship with `File, Email` scope. -* `-EnablePremiumAudit` + which mailbox(es) — yes/no -* `-EnableLabelCoAuthoring` — yes/no (**opt-in, one-way change** — only enable after confirming the customer has no third-party apps, scanners, scripts or services that read sensitivity-label metadata from the old custom-properties location. AIP scanner < v3.0, OneDrive sync < 19.002, MIP SDK < 1.7, custom DLP scanners and custom Exchange mail-flow rules all break if they read from the old location. Disabling later is PowerShell-only and loses labels on unencrypted Office files. Ref: [`sensitivity-labels-coauthoring`](https://learn.microsoft.com/purview/sensitivity-labels-coauthoring)) -* Retention duration (only relevant if `-ApplyRetention` is enabled) — **stick with default 7 years only if the customer has explicitly agreed** (otherwise edit `Retention.DurationDays` in `PurviewConfig.psd1`) -* `EnableContentMarking` in `PurviewConfig.psd1` — leave `$false` for the initial deploy; flip to `$true` after the day-30 review - ---- - -## Phase 1 — Day-of deploy (T-0) - -| # | Action | Output | -|---|--------|--------| -| 1 | Run with **`-WhatIf` first**, capture full transcript | A complete preview of every label / DLP / retention object that *would* be created | -| 2 | Diff the `-WhatIf` output against the customer's existing Purview state from Phase 0 | Sanity-check that nothing unexpected is created or adopted | -| 3 | Run in **apply mode** during the agreed window | Live deploy | -| 4 | Save the deploy log + preflight banner to the customer record | Audit trail of what was changed, when, by whom | -| 5 | Open the **Purview portal** → Information protection → Labels and DLP → confirm objects exist and are stamped `[Managed by SMBTool Purview Toolkit]` | Visual confirmation | -| 6 | Verify **DLP simulation mode** by opening one DLP policy in the portal — Mode should read "Test with notifications off" | Confirms zero user impact on day one | -| 7 | Send the "deploy complete" comms (see [Comms templates](#comms-templates)) | Customer admins know what to expect tomorrow | - -### Common day-of surprises - -* `Connect-SPOService` fails on a developer machine because EXO/Graph/Az/PnP are already loaded — the toolkit now connects SPO first when the admin URL can be resolved upfront; if you hit this on a vanity-UPN tenant, pass `-SharePointAdminUrl` explicitly. -* `Set-Label -Priority` throws `InvalidSubLabelPriorityException` if the tenant has pre-existing top-level labels with sub-labels (Microsoft's `Personal` default + any demo labels). Audit existing labels in Phase 0. -* IPPS cmdlets sometimes return success before settings are live — the script retries the known propagation races automatically; if a policy looks wrong in the portal, wait 5–10 minutes and refresh. - ---- - -## Phase 2 — Day 1 to Day 5 - -| # | Action | Output | -|---|--------|--------| -| 1 | **Day 1 morning:** check helpdesk queue for any Purview-related tickets | Early-warning signal | -| 2 | **Day 1:** confirm `General` is showing as default in Outlook (new mail draft) and `Confidential\AllEmployees` is showing as default in Word/Excel/PowerPoint | Visual user-side confirmation | -| 3 | **Day 1–3:** ask the pilot mailbox owner to **try sending a labelled document externally** — confirm the DLP policy hit shows up in Activity Explorer (no user block — simulation mode) | Confirms DLP is wired correctly | -| 4 | **Day 3:** review **DLP Activity Explorer** for any unexpected match patterns (e.g. internal departments routinely sending labelled content out — likely a misconfigured customer process, not a script bug) | Telemetry-driven view of what *would* be blocked | -| 5 | **Day 5:** spot-check audit log entries via the Purview portal to confirm `UnifiedAuditLog` is capturing label apply/change events | Confirms Tenant Settings step worked | - -### Comms expectation for the customer - -Users **will** see new labels in Outlook/Word/Excel/PowerPoint on day 1. -They **will not** be blocked from anything yet (DLP is in simulation). -The footer / watermark are **not yet visible** unless `EnableContentMarking -= $true` was set in config — by default they aren't shown until the day-30 -review. - ---- - -## Phase 3 — Day 30 promote-from-simulation gate - -This is the conversation most deploys skip — and it's the one that decides -whether DLP becomes real protection or shelfware. - -### Inputs to the conversation - -1. **DLP Activity Explorer report** — see [DLP-Simulation-Exit-Runbook.md](DLP-Simulation-Exit-Runbook.md) for how to pull "what would have been blocked" and how to read it. -2. **Helpdesk ticket count** related to labels / DLP since day-of. -3. **Pilot mailbox feedback** — did anyone complain about labels appearing, footers appearing, or anything else? - -### Decisions to make and document - -| Decision | Options | How to action | -|---|---|---| -| Promote DLP out of simulation? | Promote / extend simulation / refine policy first | If promote: flip `DlpStartInSimulation = $false` in `PurviewConfig.psd1` and re-run; or toggle in Purview portal per policy | -| Turn on content marking (footers + HC watermark)? | Yes / no | Flip `EnableContentMarking = $true` in `PurviewConfig.psd1` and re-run | -| Tighten Endpoint DLP from `Audit` to `Block` / `Warn`? | (E5 only) | Edit `EndpointDlpRestrictions[].Value` in `PurviewConfig.psd1` and re-run | -| Reconfirm retention strategy | Enable (`-ApplyRetention`) / keep disabled / change duration | If enabling for the first time: pass `-ApplyRetention` on the re-run. If already enabled: edit `Retention.DurationDays` in `PurviewConfig.psd1` and re-run. **Critical for regulated verticals — see [Retention-Default-Risk.md](Retention-Default-Risk.md)** | -| Add SharePoint / OneDrive to retention scope? | Yes / no | Add `'SharePoint'` / `'OneDrive'` to `Retention.Locations` | - -### Output - -A signed-off (email is fine) "Phase 3 outcome" note on the customer record -listing the four decisions above and the timestamp of the re-run that -applied them. Without this, three months later nobody can answer *"is this -tenant in production-grade Purview, or still in simulation?"* - ---- - -## Known sharp edges - -These are NOT bugs. They are defaults the toolkit ships with that are -correct technically but require a conversation with the customer. Cover -them in Phase 0. - -### 1. Encrypted-label rights are tenant-scoped, but not retroactive - -The Template-encrypted `Highly Confidential\All Employees` sub-label -grants rights via the `{TenantDomain}` -token in `EncryptionRightsDefinitions`. At run time the toolkit resolves -this against your auto-discovered tenant identity (default verified -domain, falling back to your initial `*.onmicrosoft.com`). Per Entra -rights-management semantics, ANY verified domain expands to ALL verified -domains in your tenant — so the rights bundle is scoped to **all users -in your tenant only**, explicitly excluding B2B guests attached to OTHER -tenants, social/MSA accounts, OTP users, and `AuthenticatedUsers` from -other M365 tenants. - -The two HC sub-labels use **different rights bundles** by design: - -| Label | Bundle | Office co-authoring (rights side) | Copy / Print / Macros | -|---|---|---|---| -| `Highly Confidential\All Employees` | Co-Author (per-label override) | ✅ | ✅ | -| `Highly Confidential\Specific People` | UserDefined (Do Not Forward — author picks recipients) | ❌ | ❌ | - -Co-Author adds `EXTRACT`, `PRINT`, `OBJMODEL` on top of the global-default -Reviewer bundle. OBJMODEL grants Office object-model access — necessary -for macros and for Office simultaneous editing on encrypted files (but -**not sufficient** for the latter; the tenant-wide `-EnableLabelCoAuthoring` -switch must also be enabled — see §6 below). - -**This is a default change.** Older deployments used -`AuthenticatedUsers`, which DID include external authenticated users. -If your tenant has been running an older version of the toolkit, files -labelled BEFORE this change carry the older, broader rights — Microsoft -Rights Management embeds the use-license at the moment of protection. -Tightening the label template does NOT retroactively revoke external -access to previously-labelled files. - -**Mitigation when promoting this change:** -- Communicate to file owners that the new scope only applies to NEW - label applications and to files that get re-labelled. -- To tighten retroactively for sensitive existing files, ask owners to - re-apply the label (or remove and re-apply) — this re-issues the - use-license under the new scope. -- If you intentionally need cross-tenant collaboration via the label - (e.g. you do business with external partners who don't have a B2B - trust into your tenant), edit `EncryptionRightsDefinitions` in - `PurviewConfig.psd1` to use `{AuthenticatedUsers}` (or the literal - `AuthenticatedUsers`) and document the decision. The toolkit refuses - to silently fall back to that scope — if `{TenantDomain}` cannot be - resolved, the labels module aborts with an actionable error. -- The two UserDefined sub-labels (`Confidential\TrustedPeople`, - `Highly Confidential\Specific People`) are unaffected by this - scope — the message / file author picks the recipients each time. - -### 2. `Highly Confidential\Specific People` (and `Confidential\TrustedPeople`) prompt the user - -Both apply UserDefined encryption: Outlook auto-applies **Do Not Forward** -on Specific People and **Encrypt-Only** (recipients can reshare) on -Trusted People; Word/Excel/PowerPoint ask the user to pick who can open -the file for both. Most users have no idea what to do with that dialog -and either cancel or grant overly broad rights. - -**Mitigation.** Either don't publish these sub-labels to end users -(neither is in `LabelPolicy.PublishedLabels` by default — good), or -include a 30-second explainer in the user comms before publishing them. - -### 3. Container labels are one-way - -Once `Group.Unified` `EnableMIPLabels=True` is set (which the toolkit -does by default on every run, because Business Premium is the licensing -floor and BP includes Entra ID P1 — the AAD-side requirement), -Microsoft does not officially support flipping it back to `False`. You -can apply labels to Groups / Teams / SPO sites freely from then on, but -the *capability* is permanent. - -In addition, the 4 **published** labels (`General \ Anyone (unrestricted)`, -`Confidential\All Employees`, `Highly Confidential\All Employees`, `Public`) -ship with container scope (`Site`, `UnifiedGroup`) in their -`ContentType` so they are selectable when a user creates a new Team -or M365 Group. The toolkit's adoption path uses UNION-not-replace: -any container-scope bits a customer has previously added in the portal -are preserved, and re-running the deploy is a no-op when the live set -already covers the desired set. - -**Mitigation.** Treat the default-on behaviour as *decision-grade* — -confirm with the customer before the first run. Pass -`-SkipContainerLabels` to opt out (this strips both the tenant toggle -AND the per-label container bits). License auto-detect skips it -automatically on tenants where no recognised M365 BP/E5/Purview Suite -SKU is found (e.g. an E3-only tenant without Entra ID P1). - -### 4. Endpoint DLP without device onboarding is theatre - -The Endpoint DLP policy is created in simulation mode (or enforce mode if -`DlpStartInSimulation = $false`), but it can only fire on devices that -have been **onboarded into Microsoft Defender / Purview compliance**. On -a tenant with no onboarded devices, the policy looks deployed and -enforces nothing — silently. - -**Mitigation.** Confirm Defender device onboarding is in place (or -planned) before relying on Endpoint DLP for compliance reporting. The -Purview portal → Settings → Device onboarding page lists onboarded -devices. - -### 5. 7-year retention default deletes mail at the 7-year mark - -See [Retention-Default-Risk.md](Retention-Default-Risk.md) — this is the -biggest single bear-trap in the default config. - ---- - -## Comms templates - -Drop these into the customer's user comms tool (or an email blast). Edit -the bracketed bits. - -### Day-before template - -> Subject: Heads-up — small change to Office files and email tomorrow -> -> Tomorrow [DATE], our team will turn on Microsoft Purview's standard -> data-protection settings in our Microsoft 365 tenant. -> -> **What you'll see.** A new "Sensitivity" button at the top of Word, -> Excel, PowerPoint, and Outlook. New emails will default to a label -> called *General* — leave it as-is unless you know the content needs a -> stronger label. -> -> **What's changing.** Nothing about the way you work. You will not be -> blocked from any task during this rollout. We're collecting telemetry -> for 30 days, then deciding which restrictions to switch on. -> -> Questions: [helpdesk channel]. - -### Day-of "deploy complete" template - -> Subject: Microsoft Purview baseline is live -> -> The Purview baseline rollout completed at [TIMESTAMP] today. New labels -> are now available in Word / Excel / PowerPoint / Outlook. DLP policies -> are running in **monitoring mode for the next 30 days** — nobody is -> being blocked from sending or sharing yet. -> -> On [DATE + 30 DAYS] we'll review what those policies *would* have -> blocked and decide together which ones to enforce. -> -> Questions: [helpdesk channel]. - -### Day-30 "we're turning enforcement on" template (send only after Phase 3 decision) - -> Subject: Sharing / sending blocks start [DATE] -> -> Following the 30-day Purview monitoring period, on [DATE] the following -> DLP policies will start **blocking** [Exchange / SharePoint / OneDrive -> / Endpoint] activity for content labelled *Confidential* or *Highly -> Confidential*: -> -> * [List the specific policies in plain English — e.g. "Emails labelled -> Confidential cannot be sent to an external address."] -> -> If you have a legitimate business need to do one of the blocked -> actions, contact [helpdesk channel] and we'll review. - ---- - -## When NOT to run this playbook - -Skip the playbook if you are running into a **dedicated lab / sandbox -tenant** for engineering testing. Run it for **every customer-facing -deploy**, including renewals where you change a config flag. +--- +title: Change-Management Playbook +layout: default +parent: Purview +nav_order: 4 +permalink: /purview/change-management-playbook/ +--- + +# Change-Management Playbook (Delivery Edition) + +> **Audience.** The deployment lead — a partner/MSP consultant or in-house IT — responsible for +> rolling the SMBTool Purview baseline into a customer tenant. This is the +> *non-technical* counterpart to the script — it covers the customer +> conversation, comms, and the day-30 promote-from-simulation gate. +> +> **For the end-user-facing companion** (what employees see, plain-language +> FAQ, ready-to-send Day -7 / Day 0 / Day +30 comms templates), see +> [`End-User-Adoption-Guide.md`](End-User-Adoption-Guide.md). + +The script handles the engineering correctly. **This playbook is what makes +it safe in production.** Several defaults have customer-visible consequences +the moment they take effect (silent labelling on every new document, +7-year mail deletion, DLP blocking external sends). Without a conversation +and comms plan around them, the technical deploy is a help-desk ticket +generator. + +Use this as a checklist. Skip nothing on the first deployment for a +customer; you can shorten subsequent ones with experience. + +[Open the timeline full-screen ↗]({{ '/Products/Purview/docs/Change-Management-Playbook.html' | relative_url }}){: .btn .btn-purple target="_blank" } +[Download the standalone file ↗]({{ '/Products/Purview/docs/Change-Management-Playbook.html' | relative_url }}){: .btn target="_blank" download } + +> 💡 The visual timeline below is a **single self-contained HTML file** +> (`Products/Purview/docs/Change-Management-Playbook.html`). It needs no server, +> so you can share it with the delivery team on its own or open it offline. The +> **full text** of the playbook — including every checklist row and the "known +> sharp edges" detail — is kept below the timeline. + + + +--- + +## 📋 Full playbook (text version) +{: .no_toc } + +The complete checklist in Markdown — handy for copying rows into a delivery +tracker, and the authoritative detail for the "known sharp edges" summarised +visually above. + +## Phase 0 — Pre-deploy (T-7 days) + +| # | Action | Owner | Output | +|---|--------|-------|--------| +| 1 | **Confirm tenant SKU** (Business Premium vs E5 / Purview Suite) | Deploy team | Decides whether `-BPOnly` is required. AI governance is default-on for E5 / Purview Suite tenants (covers both paid Microsoft 365 Copilot and free Copilot Chat) and auto-skipped on Business Premium | +| 2 | **Confirm vertical and any regulatory framework** (law, accounting, healthcare, financial advisor, construction, real estate) | Deploy + business | Decides whether to enable retention at all (`-ApplyRetention` is opt-in) and, if enabled, what duration. The **default 7-year mail deletion** aligns with most SMB regulatory frameworks (ATO / IRS / SEC / ASIC) but may still be wrong for some verticals — see [Retention-Default-Risk.md](Retention-Default-Risk.md) | +| 3 | **Inventory current Purview state** in the tenant — existing labels, DLP policies, retention policies | Deploy team | If any exist, decide between `-AdoptExisting` (toolkit takes over) or rename / archive first | +| 4 | **Identify B2B guest exposure** — does the customer routinely invite accountants, lawyers, MSPs as guests? | Deploy + business | If yes, the `AuthenticatedUsers` encryption scope is **not** "internal-only" — see [Known sharp edges](#known-sharp-edges) | +| 5 | **Pick a pilot mailbox / pilot site** for retention and DLP simulation review | Deploy + business | Names and UPNs go in the pre-deploy doc | +| 6 | **Schedule the deploy window** and the day-30 review meeting in the same calendar invite | Deploy team | Both dates booked before the deploy | +| 7 | **Draft user comms** (see [Comms templates](#comms-templates)) | Deploy team | Sent the day before the deploy | +| 8 | **Review the offline Deployment Plan** generated before authentication | Deploy team + reviewer | Confirms configured scope, opt-ins, conditional licensing, and Good, Better, and Best guide coverage without claiming tenant state | + +### Decisions to lock before you run the script + +* `-BPOnly` — yes/no +* `-SkipAIControls` — yes/no (AI governance is default-on for E5 / Purview Suite; only set this if the customer has a specific reason to opt out) +* `-ApplyRetention` — yes/no (**opt-in** — only enable if the customer has consciously chosen a retention duration appropriate for their vertical) +* `-SkipContainerLabels` — yes/no (default behaviour is to enable; only set this if container labels are managed by another process or the tenant lacks Entra ID P1). Strips both the tenant-side `Group.Unified EnableMIPLabels = True` toggle AND the container-scope bits (`Site`, `UnifiedGroup`) on the 3 published labels' `ContentType`. The labels still ship with `File, Email` scope. +* `-EnablePremiumAudit` + which mailbox(es) — yes/no +* `-EnableLabelCoAuthoring` — yes/no (**opt-in, one-way change** — only enable after confirming the customer has no third-party apps, scanners, scripts or services that read sensitivity-label metadata from the old custom-properties location. AIP scanner < v3.0, OneDrive sync < 19.002, MIP SDK < 1.7, custom DLP scanners and custom Exchange mail-flow rules all break if they read from the old location. Disabling later is PowerShell-only and loses labels on unencrypted Office files. Ref: [`sensitivity-labels-coauthoring`](https://learn.microsoft.com/purview/sensitivity-labels-coauthoring)) +* Retention duration (only relevant if `-ApplyRetention` is enabled) — **stick with default 7 years only if the customer has explicitly agreed** (otherwise edit `Retention.DurationDays` in `PurviewConfig.psd1`) +* `EnableContentMarking` in `PurviewConfig.psd1` — leave `$false` for the initial deploy; flip to `$true` after the day-30 review + +--- + +## Phase 1 — Day-of deploy (T-0) + +| # | Action | Output | +|---|--------|--------| +| 1 | Save the generated **Deployment Plan HTML + JSON** before authentication | One plan reference, sanitized intended state, fingerprints, primary SMB tier comparison, and supporting Microsoft Learn comparison | +| 2 | Run with **`-WhatIf` first**, capture full transcript | A tenant-aware preview of state-changing cmdlets | +| 3 | Diff the `-WhatIf` output against the customer's existing Purview state from Phase 0 | Sanity-check that nothing unexpected is created or adopted | +| 4 | Run in **apply mode** during the agreed window | Live deploy | +| 5 | Save the deploy log + preflight banner to the customer record | Audit trail of what was changed, when, by whom | +| 6 | Open the **Purview portal** → Information protection → Labels and DLP → confirm objects exist and are stamped `[Managed by SMBTool Purview Toolkit]` | Visual confirmation | +| 7 | Verify **DLP simulation mode** by opening one DLP policy in the portal. Mode should read "Test with notifications off" | Confirms zero user impact on day one | +| 8 | Send the "deploy complete" comms (see [Comms templates](#comms-templates)) | Customer admins know what to expect tomorrow | + +### Common day-of surprises + +* `Connect-SPOService` fails on a developer machine because EXO/Graph/Az/PnP are already loaded — the toolkit now connects SPO first when the admin URL can be resolved upfront; if you hit this on a vanity-UPN tenant, pass `-SharePointAdminUrl` explicitly. +* `Set-Label -Priority` throws `InvalidSubLabelPriorityException` if the tenant has pre-existing top-level labels with sub-labels (Microsoft's `Personal` default + any demo labels). Audit existing labels in Phase 0. +* IPPS cmdlets sometimes return success before settings are live — the script retries the known propagation races automatically; if a policy looks wrong in the portal, wait 5–10 minutes and refresh. + +--- + +## Phase 2 — Day 1 to Day 5 + +| # | Action | Output | +|---|--------|--------| +| 1 | **Day 1 morning:** check helpdesk queue for any Purview-related tickets | Early-warning signal | +| 2 | **Day 1:** confirm `General` is showing as default in Outlook (new mail draft) and `Confidential\AllEmployees` is showing as default in Word/Excel/PowerPoint | Visual user-side confirmation | +| 3 | **Day 1–3:** ask the pilot mailbox owner to **try sending a labelled document externally** — confirm the DLP policy hit shows up in Activity Explorer (no user block — simulation mode) | Confirms DLP is wired correctly | +| 4 | **Day 3:** review **DLP Activity Explorer** for any unexpected match patterns (e.g. internal departments routinely sending labelled content out — likely a misconfigured customer process, not a script bug) | Telemetry-driven view of what *would* be blocked | +| 5 | **Day 5:** spot-check audit log entries via the Purview portal to confirm `UnifiedAuditLog` is capturing label apply/change events | Confirms Tenant Settings step worked | + +### Comms expectation for the customer + +Users **will** see new labels in Outlook/Word/Excel/PowerPoint on day 1. +They **will not** be blocked from anything yet (DLP is in simulation). +The footer / watermark are **not yet visible** unless `EnableContentMarking += $true` was set in config — by default they aren't shown until the day-30 +review. + +--- + +## Phase 3 — Day 30 promote-from-simulation gate + +This is the conversation most deploys skip — and it's the one that decides +whether DLP becomes real protection or shelfware. + +### Inputs to the conversation + +1. **DLP Activity Explorer report** — see [DLP-Simulation-Exit-Runbook.md](DLP-Simulation-Exit-Runbook.md) for how to pull "what would have been blocked" and how to read it. +2. **Helpdesk ticket count** related to labels / DLP since day-of. +3. **Pilot mailbox feedback** — did anyone complain about labels appearing, footers appearing, or anything else? + +### Decisions to make and document + +| Decision | Options | How to action | +|---|---|---| +| Promote DLP out of simulation? | Promote / extend simulation / refine policy first | If promote: flip `DlpStartInSimulation = $false` in `PurviewConfig.psd1` and re-run; or toggle in Purview portal per policy | +| Turn on content marking (footers + HC watermark)? | Yes / no | Flip `EnableContentMarking = $true` in `PurviewConfig.psd1` and re-run | +| Tighten Endpoint DLP from `Audit` to `Block` / `Warn`? | (E5 only) | Edit `EndpointDlpRestrictions[].Value` in `PurviewConfig.psd1` and re-run | +| Reconfirm retention strategy | Enable (`-ApplyRetention`) / keep disabled / change duration | If enabling for the first time: pass `-ApplyRetention` on the re-run. If already enabled: edit `Retention.DurationDays` in `PurviewConfig.psd1` and re-run. **Critical for regulated verticals — see [Retention-Default-Risk.md](Retention-Default-Risk.md)** | +| Add SharePoint / OneDrive to retention scope? | Yes / no | Add `'SharePoint'` / `'OneDrive'` to `Retention.Locations` | + +### Output + +A signed-off (email is fine) "Phase 3 outcome" note on the customer record +listing the four decisions above and the timestamp of the re-run that +applied them. Without this, three months later nobody can answer *"is this +tenant in production-grade Purview, or still in simulation?"* + +--- + +## Known sharp edges + +These are NOT bugs. They are defaults the toolkit ships with that are +correct technically but require a conversation with the customer. Cover +them in Phase 0. + +### 1. Encrypted-label rights are tenant-scoped, but not retroactive + +The Template-encrypted `Highly Confidential\All Employees` sub-label +grants rights via the `{TenantDomain}` +token in `EncryptionRightsDefinitions`. At run time the toolkit resolves +this against your auto-discovered tenant identity (default verified +domain, falling back to your initial `*.onmicrosoft.com`). Per Entra +rights-management semantics, ANY verified domain expands to ALL verified +domains in your tenant — so the rights bundle is scoped to **all users +in your tenant only**, explicitly excluding B2B guests attached to OTHER +tenants, social/MSA accounts, OTP users, and `AuthenticatedUsers` from +other M365 tenants. + +The two HC sub-labels use **different rights bundles** by design: + +| Label | Bundle | Office co-authoring (rights side) | Copy / Print / Macros | +|---|---|---|---| +| `Highly Confidential\All Employees` | Co-Author (per-label override) | ✅ | ✅ | +| `Highly Confidential\Specific People` | UserDefined (Do Not Forward — author picks recipients) | ❌ | ❌ | + +Co-Author adds `EXTRACT`, `PRINT`, `OBJMODEL` on top of the global-default +Reviewer bundle. OBJMODEL grants Office object-model access — necessary +for macros and for Office simultaneous editing on encrypted files (but +**not sufficient** for the latter; the tenant-wide `-EnableLabelCoAuthoring` +switch must also be enabled — see §6 below). + +**This is a default change.** Older deployments used +`AuthenticatedUsers`, which DID include external authenticated users. +If your tenant has been running an older version of the toolkit, files +labelled BEFORE this change carry the older, broader rights — Microsoft +Rights Management embeds the use-license at the moment of protection. +Tightening the label template does NOT retroactively revoke external +access to previously-labelled files. + +**Mitigation when promoting this change:** +- Communicate to file owners that the new scope only applies to NEW + label applications and to files that get re-labelled. +- To tighten retroactively for sensitive existing files, ask owners to + re-apply the label (or remove and re-apply) — this re-issues the + use-license under the new scope. +- If you intentionally need cross-tenant collaboration via the label + (e.g. you do business with external partners who don't have a B2B + trust into your tenant), edit `EncryptionRightsDefinitions` in + `PurviewConfig.psd1` to use `{AuthenticatedUsers}` (or the literal + `AuthenticatedUsers`) and document the decision. The toolkit refuses + to silently fall back to that scope — if `{TenantDomain}` cannot be + resolved, the labels module aborts with an actionable error. +- The two UserDefined sub-labels (`Confidential\TrustedPeople`, + `Highly Confidential\Specific People`) are unaffected by this + scope — the message / file author picks the recipients each time. + +### 2. `Highly Confidential\Specific People` (and `Confidential\TrustedPeople`) prompt the user + +Both apply UserDefined encryption: Outlook auto-applies **Do Not Forward** +on Specific People and **Encrypt-Only** (recipients can reshare) on +Trusted People; Word/Excel/PowerPoint ask the user to pick who can open +the file for both. Most users have no idea what to do with that dialog +and either cancel or grant overly broad rights. + +**Mitigation.** Either don't publish these sub-labels to end users +(neither is in `LabelPolicy.PublishedLabels` by default — good), or +include a 30-second explainer in the user comms before publishing them. + +### 3. Container labels are one-way + +Once `Group.Unified` `EnableMIPLabels=True` is set (which the toolkit +does by default on every run, because Business Premium is the licensing +floor and BP includes Entra ID P1 — the AAD-side requirement), +Microsoft does not officially support flipping it back to `False`. You +can apply labels to Groups / Teams / SPO sites freely from then on, but +the *capability* is permanent. + +In addition, the 4 **published** labels (`General \ Anyone (unrestricted)`, +`Confidential\All Employees`, `Highly Confidential\All Employees`, `Public`) +ship with container scope (`Site`, `UnifiedGroup`) in their +`ContentType` so they are selectable when a user creates a new Team +or M365 Group. The toolkit's adoption path uses UNION-not-replace: +any container-scope bits a customer has previously added in the portal +are preserved, and re-running the deploy is a no-op when the live set +already covers the desired set. + +**Mitigation.** Treat the default-on behaviour as *decision-grade* — +confirm with the customer before the first run. Pass +`-SkipContainerLabels` to opt out (this strips both the tenant toggle +AND the per-label container bits). License auto-detect skips it +automatically on tenants where no recognised M365 BP/E5/Purview Suite +SKU is found (e.g. an E3-only tenant without Entra ID P1). + +### 4. Endpoint DLP without device onboarding is theatre + +The Endpoint DLP policy is created in simulation mode (or enforce mode if +`DlpStartInSimulation = $false`), but it can only fire on devices that +have been **onboarded into Microsoft Defender / Purview compliance**. On +a tenant with no onboarded devices, the policy looks deployed and +enforces nothing — silently. + +**Mitigation.** Confirm Defender device onboarding is in place (or +planned) before relying on Endpoint DLP for compliance reporting. The +Purview portal → Settings → Device onboarding page lists onboarded +devices. + +### 5. 7-year retention default deletes mail at the 7-year mark + +See [Retention-Default-Risk.md](Retention-Default-Risk.md) — this is the +biggest single bear-trap in the default config. + +--- + +## Comms templates + +Drop these into the customer's user comms tool (or an email blast). Edit +the bracketed bits. + +### Day-before template + +> Subject: Heads-up — small change to Office files and email tomorrow +> +> Tomorrow [DATE], our team will turn on Microsoft Purview's standard +> data-protection settings in our Microsoft 365 tenant. +> +> **What you'll see.** A new "Sensitivity" button at the top of Word, +> Excel, PowerPoint, and Outlook. New emails will default to a label +> called *General* — leave it as-is unless you know the content needs a +> stronger label. +> +> **What's changing.** Nothing about the way you work. You will not be +> blocked from any task during this rollout. We're collecting telemetry +> for 30 days, then deciding which restrictions to switch on. +> +> Questions: [helpdesk channel]. + +### Day-of "deploy complete" template + +> Subject: Microsoft Purview baseline is live +> +> The Purview baseline rollout completed at [TIMESTAMP] today. New labels +> are now available in Word / Excel / PowerPoint / Outlook. DLP policies +> are running in **monitoring mode for the next 30 days** — nobody is +> being blocked from sending or sharing yet. +> +> On [DATE + 30 DAYS] we'll review what those policies *would* have +> blocked and decide together which ones to enforce. +> +> Questions: [helpdesk channel]. + +### Day-30 "we're turning enforcement on" template (send only after Phase 3 decision) + +> Subject: Sharing / sending blocks start [DATE] +> +> Following the 30-day Purview monitoring period, on [DATE] the following +> DLP policies will start **blocking** [Exchange / SharePoint / OneDrive +> / Endpoint] activity for content labelled *Confidential* or *Highly +> Confidential*: +> +> * [List the specific policies in plain English — e.g. "Emails labelled +> Confidential cannot be sent to an external address."] +> +> If you have a legitimate business need to do one of the blocked +> actions, contact [helpdesk channel] and we'll review. + +--- + +## When NOT to run this playbook + +Skip the playbook if you are running into a **dedicated lab / sandbox +tenant** for engineering testing. Run it for **every customer-facing +deploy**, including renewals where you change a config flag. diff --git a/Products/Purview/docs/Configuration-Reference.md b/Products/Purview/docs/Configuration-Reference.md index 5e271fe..c29f6a6 100644 --- a/Products/Purview/docs/Configuration-Reference.md +++ b/Products/Purview/docs/Configuration-Reference.md @@ -1,171 +1,234 @@ ---- -title: Configuration Reference -layout: default -parent: Purview -nav_order: 3 -permalink: /purview/configuration-reference/ ---- - -# Configuration Reference — customizing `PurviewConfig.psd1` -{: .no_toc } - -Every tunable the toolkit applies lives in **one file**: -[`Products/Purview/Config/PurviewConfig.psd1`](https://github.com/microsoft/BestPractice_Deploy-Scripts/blob/main/Products/Purview/Config/PurviewConfig.psd1). -There is no hidden state — what's in this file is exactly what lands in the -tenant. To customize a deployment you **fork the repo, edit this file, and -re-run**. Because every step is idempotent (read-modify-write), re-running only -changes what you changed. - -> 🧪 **Always preview first.** Run with `-WhatIf` after any edit to see exactly -> what would change before you apply it. DLP also ships in **simulation** by -> default, so policy edits don't enforce until you promote them (see the -> [DLP Simulation Exit Runbook](dlp-simulation-exit-runbook/)). - -1. TOC -{:toc} - ---- - -## How to customize (the 3-step loop) - -```powershell -# 1. Fork/clone the repo, then edit the config in your editor of choice: -code Products\Purview\Config\PurviewConfig.psd1 - -# 2. Preview the effect against the customer tenant (no writes): -.\Deploy-PurviewBestPractice.ps1 -TenantAdminUpn admin@contoso.onmicrosoft.com -WhatIf - -# 3. Apply when the preview looks right: -.\Deploy-PurviewBestPractice.ps1 -TenantAdminUpn admin@contoso.onmicrosoft.com -``` - -> ⚠️ **Don't change `ManagedByTag`** after a deployment. It's the stamp the -> toolkit writes into every object's comment/description so it recognises what -> it owns on the next run. Change it and the toolkit will no longer see prior -> objects as its own (they'll look like third-party objects and need -> `-AdoptExisting`). - ---- - -## Most-changed settings - -All keys below are top-level in `PurviewConfig.psd1` unless a dotted path is -shown (e.g. `LabelPolicy.DefaultLabel` means the `DefaultLabel` key inside the -`LabelPolicy` block). - -### Global behaviour - -| Key | Default | What it controls | -|---|---|---| -| `DlpStartInSimulation` | `$true` | New DLP policies are created in **simulation** (`TestWithoutNotifications`). Set `$false` to create them directly in **Enable** (enforce) mode. Leave `$true` for the safe simulate-then-promote method. | -| `EnableContentMarking` | `$false` | Master switch for header / footer / watermark marking on labels. While `$false`, per-label `ContentMark = $true` entries are skipped. Flip to `$true` once you're ready to roll visual markings out to users. | -| `ManagedByTag` | `[Managed by SMBTool Purview Toolkit]` | Ownership stamp for idempotent re-runs. **Do not change after deploying.** | - -### Sensitivity labels & publishing (`LabelPolicy`) - -| Key | Default | What it controls | -|---|---|---| -| `LabelPolicy.DefaultLabel` | `AllEmployees` (Confidential\All Employees) | Default label applied to **documents** (Word/Excel/PowerPoint). | -| `LabelPolicy.DefaultLabelForEmail` | `GeneralAnyone` (General \ Anyone (unrestricted)) | Default label for **Outlook email**. `General` itself is a non-applicable label group, so the default points at its assignable child. Set `$null` to fall back to `DefaultLabel`. | -| `LabelPolicy.PublishedLabels` | `Public, GeneralAnyone, AllEmployees, HCAllEmps` | Subset of labels **published** to end users. All labels in `Labels` are still *created*; only these are *visible*. Set `$null`/empty to publish everything. | -| `LabelPolicy.AttachmentAction` | `'Automatic'` | "Inherit label from attachments": when a user sends an email with labelled attachments, silently applies the highest-priority attachment label to the email. `'Recommended'` prompts the user instead; `$null` turns the feature off. | -| `LabelPolicy.MandatoryLabelling` | `$true` | Forces users to pick a label before saving/sending. Set `$false` only if the customer can't tolerate the prompt during rollout (training is the better answer). | -| `LabelPolicy.DowngradeJustification` | `$true` | Require a justification when a user lowers a label's sensitivity. | - -To change a label's display name, tooltip, colour, priority, encryption, or -scope, edit the relevant hashtable in the `Labels = @( ... )` array. - -> ℹ️ **`BuiltInName` — don't remove it.** Each built-in label carries a -> `BuiltInName` field holding Microsoft's stable internal signature -> (`defa4170-0d19-0005-NNNN-…`). The toolkit uses it to **adopt** the label -> if the tenant already has it (any locale, any label scheme) and to -> **create** it byte-identical to Microsoft's default on a blank tenant. -> You can freely change `DisplayName`/`Tooltip`/colour/encryption — just -> don't remove or repoint `BuiltInName` on a built-in label, or adoption -> will stop matching it and the toolkit may create a duplicate. - -### Encryption rights - -| Key | Default | What it controls | -|---|---|---| -| `EncryptionRightsDefinitions` (global) | `{TenantDomain}:VIEW,VIEWRIGHTSDATA,EDIT,DOCEDIT,REPLY,REPLYALL,FORWARD` | The **global default** usage-rights bundle for Template-encrypted labels. The `{TenantDomain}` token scopes rights to **your tenant only** (resolved at runtime). | -| `EncryptionRightsDefinitions` (per-label) | — | Add this field to a single label's hashtable to override the global default for just that label (same token semantics). | -| `EncryptionContentExpiredOnDateInDaysOrNever` | `Never` | When encrypted content access expires. | -| `EncryptionOfflineAccessDays` | `30` | How many days encrypted content is usable offline. | - -### DLP policies (`DlpPolicies` array) - -Each entry is one policy. The shipped config has **Exchange** and -**SharePoint + OneDrive** (Business Premium and up) and an **Endpoint** policy -(E5 / Purview Suite — auto-created in simulation when licensed, soft-skipped on -BP). - -| Field | Example | What it controls | -|---|---|---| -| `Workload` | `Exchange`, `SharePointOneDrive`, `Endpoint` | Which surface the policy targets. E5-only workloads: `Endpoint`/`Devices`, `OnPremisesScanner`, `DefenderForCloudApps`, `PowerBI` (rejected under `-BPOnly`). | -| `LabelPaths` | `Confidential/AllEmployees`, … | The sensitivity labels the rule matches (OR-matched, resolved to **GUIDs** at runtime). | -| `BlockAccess` | `$true` | Whether matched content is blocked. | -| `EndpointDlpRestrictions[].Value` | `Audit` → `Block`/`Warn`/`BlockOverride` | (Endpoint only) Per-device-action enforcement. Start at `Audit`, tighten after reviewing telemetry in DLP Activity Explorer. | - -> To **add a department exception** or fix a false positive, edit the rule's -> conditions/exceptions in the relevant `DlpPolicies` entry, re-run, then start -> a fresh 30-day simulation window. See the -> [DLP Simulation Exit Runbook](dlp-simulation-exit-runbook/). - -### Retention (`Retention`) — ⚠️ destructive - -Retention is **opt-in** — it only runs when you pass `-ApplyRetention`. The -shipped default **retains Exchange mail for 7 years, then deletes it**. - -| Key | Default | What it controls | -|---|---|---| -| `Retention.DurationDays` | `2555` (= **7 years**) | Retention period **in days**. Change to your required duration (e.g. `3650` for 10 years). The `DurationDisplayHint` is cosmetic only. | -| `Retention.Action` | `KeepAndDelete` | `KeepAndDelete` retains then deletes; other Purview actions retain-only / delete-only. | -| `Retention.ExpirationDateOption` | `CreationAgeInDays` | Whether the clock starts at item creation vs last modification. | -| `Retention.Locations` | `@('Exchange')` | Add `'SharePoint'`, `'OneDrive'` to widen scope beyond mailboxes. | - -> 🚨 **Read the [Retention Default — Risk Note](retention-default-risk/) before -> enabling retention.** The 7-year auto-delete is right for many SMB regulatory -> frameworks but wrong for some verticals — confirm with the customer. - -### AI governance / Copilot DLP (`AIGovernance`) - -Default-on for **E5 / Purview Suite** (auto-skipped on BP; opt out with -`-SkipAIControls`). Blocks Microsoft 365 Copilot from processing -`HighlyConfidential` content. - -| Key | Default | What it controls | -|---|---|---| -| `AIGovernance.DlpPolicies[].Mode` | `Enable` | `Enable` / `TestWithNotifications` / `TestWithoutNotifications` / `Disable`. | -| `AIGovernance.DlpPolicies[].LabelPaths` | `HighlyConfidential` | Which labels Copilot is blocked from grounding on. | -| `AIGovernance.DlpPolicies[].EnforcementPlanes` | `CopilotExperiences` | Add `Agent` to extend coverage to Agent 365 (preview). | -| `Locations[].Inclusions` | tenant-wide (`Tenant=All`) | Swap to a `Group` inclusion (see the commented example in the file) to pilot on a security group first. | - -### Tenant settings (`TenantSettings`) - -| Key | Default | What it controls | -|---|---|---| -| `EnableUnifiedAuditLog` | `$true` | Standard unified audit log (always recommended on). | -| `EnableSensitivityLabelForPDF` | `$true` | Allow labels on PDFs. | -| `EnableAIPIntegrationInSPO` | `$true` | SharePoint AIP / label integration. | -| `EnableLabelCoAuth` | `$false` | **One-way switch** — co-authoring on encrypted Office files. Operator opt-in via `-EnableLabelCoAuthoring`. **Disabling later loses labels on unencrypted Office files and breaks older AIP scanners / OneDrive sync / MIP SDK / custom scanners.** Leave `$false` unless you understand the [one-way semantics](https://learn.microsoft.com/purview/sensitivity-labels-coauthoring). | - ---- - -## Safety rails when editing - -- **`-WhatIf` first, every time.** It previews the full run with zero writes. -- **DLP stays in simulation** (`DlpStartInSimulation = $true`) until you - consciously promote — policy edits don't enforce on day 0. -- **Irreversible switches default to safe.** Retention (`-ApplyRetention`) and - co-authoring (`EnableLabelCoAuth` / `-EnableLabelCoAuthoring`) are opt-in. -- **`-BPOnly`** hard-blocks every E5-only feature so a fork that adds, say, an - Endpoint policy can't accidentally be pushed to a Business Premium customer. -- **`-AdoptExisting`** is required to update objects that exist but aren't - stamped with `ManagedByTag` — prevents silent overwrites. - -## Related - -- [Scenarios & Capabilities](scenarios/) — what each setting does and the per-licence behaviour. -- [Change-Management Playbook](change-management-playbook/) — when to flip which config key across the deployment timeline. -- [Deployment Framework (Visual)](deployment-framework/) — where configuration sits in the overall method. +--- +title: Configuration Reference +layout: default +parent: Purview +nav_order: 3 +permalink: /purview/configuration-reference/ +--- + +# Configuration Reference — customizing `PurviewConfig.psd1` +{: .no_toc } + +Every tunable the toolkit applies lives in **one file**: +[`Products/Purview/Config/PurviewConfig.psd1`](https://github.com/microsoft/BestPractice_Deploy-Scripts/blob/main/Products/Purview/Config/PurviewConfig.psd1). +There is no hidden state — what's in this file is exactly what lands in the +tenant. To customize a deployment you **fork the repo, edit this file, and +re-run**. Because every step is idempotent (read-modify-write), re-running only +changes what you changed. + +> 🧪 **Always preview first.** Run with `-WhatIf` after any edit to see exactly +> what would change before you apply it. DLP also ships in **simulation** by +> default, so policy edits don't enforce until you promote them (see the +> [DLP Simulation Exit Runbook](dlp-simulation-exit-runbook/)). + +1. TOC +{:toc} + +--- + +## How to customize (the 3-step loop) + +```powershell +# 1. Fork/clone the repo, then edit the config in your editor of choice: +code Products\Purview\Config\PurviewConfig.psd1 + +# 2. Preview the effect against the customer tenant (no writes): +.\Deploy-PurviewBestPractice.ps1 -TenantAdminUpn admin@contoso.onmicrosoft.com -WhatIf + +# 3. Apply when the preview looks right: +.\Deploy-PurviewBestPractice.ps1 -TenantAdminUpn admin@contoso.onmicrosoft.com +``` + +> ⚠️ **Don't change `ManagedByTag`** after a deployment. It's the stamp the +> toolkit writes into every object's comment/description so it recognises what +> it owns on the next run. Change it and the toolkit will no longer see prior +> objects as its own (they'll look like third-party objects and need +> `-AdoptExisting`). + +--- + +## Most-changed settings + +All keys below are top-level in `PurviewConfig.psd1` unless a dotted path is +shown (e.g. `LabelPolicy.DefaultLabel` means the `DefaultLabel` key inside the +`LabelPolicy` block). + +### Global behaviour + +| Key | Default | What it controls | +|---|---|---| +| `DlpStartInSimulation` | `$true` | New DLP policies are created in **simulation** (`TestWithoutNotifications`). Set `$false` to create them directly in **Enable** (enforce) mode. Leave `$true` for the safe simulate-then-promote method. | +| `EnableContentMarking` | `$false` | Master switch for header / footer / watermark marking on labels. While `$false`, per-label `ContentMark = $true` entries are skipped. Flip to `$true` once you're ready to roll visual markings out to users. | +| `ManagedByTag` | `[Managed by SMBTool Purview Toolkit]` | Ownership stamp for idempotent re-runs. **Do not change after deploying.** | + +### Sensitivity labels & publishing (`LabelPolicy`) + +| Key | Default | What it controls | +|---|---|---| +| `LabelPolicy.DefaultLabel` | `AllEmployees` (Confidential\All Employees) | Default label applied to **documents** (Word/Excel/PowerPoint). | +| `LabelPolicy.DefaultLabelForEmail` | `GeneralAnyone` (General \ Anyone (unrestricted)) | Default label for **Outlook email**. `General` itself is a non-applicable label group, so the default points at its assignable child. Set `$null` to fall back to `DefaultLabel`. | +| `LabelPolicy.PublishedLabels` | `Public, GeneralAnyone, AllEmployees, HCAllEmps` | Subset of labels **published** to end users. All labels in `Labels` are still *created*; only these are *visible*. Set `$null`/empty to publish everything. | +| `LabelPolicy.AttachmentAction` | `'Automatic'` | "Inherit label from attachments": when a user sends an email with labelled attachments, silently applies the highest-priority attachment label to the email. `'Recommended'` prompts the user instead; `$null` turns the feature off. | +| `LabelPolicy.MandatoryLabelling` | `$true` | Forces users to pick a label before saving/sending. Set `$false` only if the customer can't tolerate the prompt during rollout (training is the better answer). | +| `LabelPolicy.DowngradeJustification` | `$true` | Require a justification when a user lowers a label's sensitivity. | + +To change a label's display name, tooltip, colour, priority, encryption, or +scope, edit the relevant hashtable in the `Labels = @( ... )` array. + +> ℹ️ **`BuiltInName` — don't remove it.** Each built-in label carries a +> `BuiltInName` field holding Microsoft's stable internal signature +> (`defa4170-0d19-0005-NNNN-…`). The toolkit uses it to **adopt** the label +> if the tenant already has it (any locale, any label scheme) and to +> **create** it byte-identical to Microsoft's default on a blank tenant. +> You can freely change `DisplayName`/`Tooltip`/colour/encryption — just +> don't remove or repoint `BuiltInName` on a built-in label, or adoption +> will stop matching it and the toolkit may create a duplicate. A non-empty +> value that does not match the documented signature now fails local preflight +> before Deployment Plan generation or authentication. Leave `BuiltInName` +> empty for a genuinely custom label. + +### Encryption rights + +| Key | Default | What it controls | +|---|---|---| +| `EncryptionRightsDefinitions` (global) | `{TenantDomain}:VIEW,VIEWRIGHTSDATA,EDIT,DOCEDIT,REPLY,REPLYALL,FORWARD` | The **global default** usage-rights bundle for Template-encrypted labels. The `{TenantDomain}` token scopes rights to **your tenant only** (resolved at runtime). | +| `EncryptionRightsDefinitions` (per-label) | — | Add this field to a single label's hashtable to override the global default for just that label (same token semantics). | +| `EncryptionContentExpiredOnDateInDaysOrNever` | `Never` | When encrypted content access expires. | +| `EncryptionOfflineAccessDays` | `30` | How many days encrypted content is usable offline. | + +### DLP policies (`DlpPolicies` array) + +Each entry is one policy. The shipped config has **Exchange** and +**SharePoint + OneDrive** (Business Premium and up) and an **Endpoint** policy +(E5 / Purview Suite — auto-created in simulation when licensed, soft-skipped on +BP). + +| Field | Example | What it controls | +|---|---|---| +| `Workload` | `Exchange`, `SharePointOneDrive`, `Endpoint` | Which supported surface the policy targets. Defender for Cloud Apps, on-premises, Power BI, and workload aliases are not implemented and are skipped with evidence. | +| `LabelPaths` | `Confidential/AllEmployees`, … | Preferred array of sensitivity labels the rule matches (OR-matched, resolved to **GUIDs** at runtime). Legacy singular `LabelPath` remains supported when `LabelPaths` is absent. | +| `BlockAccess` | `$true` | Whether matched content is blocked. | +| `EndpointDlpRestrictions[].Value` | `Audit` → `Block`/`Warn`/`BlockOverride` | (Endpoint only) Per-device-action enforcement. Start at `Audit`, tighten after reviewing telemetry in DLP Activity Explorer. | + +> To **add a department exception** or fix a false positive, edit the rule's +> conditions/exceptions in the relevant `DlpPolicies` entry, re-run, then start +> a fresh 30-day simulation window. See the +> [DLP Simulation Exit Runbook](dlp-simulation-exit-runbook/). + +### Retention (`Retention`) — ⚠️ destructive + +Retention is **opt-in** — it only runs when you pass `-ApplyRetention`. The +shipped default **retains Exchange mail for 7 years, then deletes it**. + +| Key | Default | What it controls | +|---|---|---| +| `Retention.DurationDays` | `2555` (= **7 years**) | Retention period **in days**. Change to your required duration (e.g. `3650` for 10 years). The `DurationDisplayHint` is cosmetic only. | +| `Retention.Action` | `KeepAndDelete` | `KeepAndDelete` retains then deletes; other Purview actions retain-only / delete-only. | +| `Retention.ExpirationDateOption` | `CreationAgeInDays` | Whether the clock starts at item creation vs last modification. | +| `Retention.Locations` | `@('Exchange')` | Supported values are `Exchange`, `SharePoint`, and `OneDrive`. Mixed lists deploy the supported subset and report unsupported entries. If none are supported, the Deployment Plan excludes retention and the runtime module fails before tenant reads or writes. | + +> 🚨 **Read the [Retention Default — Risk Note](retention-default-risk/) before +> enabling retention.** The 7-year auto-delete is right for many SMB regulatory +> frameworks but wrong for some verticals — confirm with the customer. + +### AI governance / Copilot DLP (`AIGovernance`) + +Default-on for **E5 / Purview Suite** (auto-skipped on BP; opt out with +`-SkipAIControls`). Blocks Microsoft 365 Copilot from processing +`HighlyConfidential` content. + +| Key | Default | What it controls | +|---|---|---| +| `AIGovernance.DlpPolicies[].Mode` | `Enable` | `Enable` / `TestWithNotifications` / `TestWithoutNotifications` / `Disable`. | +| `AIGovernance.DlpPolicies[].LabelPaths` | `HighlyConfidential` | Which labels Copilot is blocked from grounding on. | +| `AIGovernance.DlpPolicies[].EnforcementPlanes` | `CopilotExperiences` | Add `Agent` to extend coverage to Agent 365 (preview). | +| `AIGovernance.DlpPolicies[].Locations[].Location` | Microsoft 365 Copilot public location GUID | The runtime API receives the documented GUID. The comparison-safe Deployment Plan records the canonical `Microsoft365Copilot` token. Custom locations retain only a redacted value, class, and deterministic one-way digest so different targets do not collapse. | +| `Locations[].Inclusions` | tenant-wide (`Tenant=All`) | Swap to a `Group` inclusion (see the commented example in the file) to pilot on a security group first. | + +### Tenant settings (`TenantSettings`) + +| Key | Default | What it controls | +|---|---|---| +| `EnableUnifiedAuditLog` | `$true` | Standard unified audit log (always recommended on). | +| `EnableSensitivityLabelForPDF` | `$true` | Allow labels on PDFs. | +| `EnableAIPIntegrationInSPO` | `$true` | SharePoint AIP / label integration. | +| `EnableLabelCoAuth` | `$false` | **One-way switch** — co-authoring on encrypted Office files. Operator opt-in via `-EnableLabelCoAuthoring`. **Disabling later loses labels on unencrypted Office files and breaks older AIP scanners / OneDrive sync / MIP SDK / custom scanners.** Leave `$false` unless you understand the [one-way semantics](https://learn.microsoft.com/purview/sensitivity-labels-coauthoring). | + +--- + +## Offline Deployment Plan + +Each invocation writes an offline Deployment Plan after local config and +parameter validation, before any service connection. The HTML and JSON files +come from one model and include: + +- a non-identifying config source classification and SHA-256; +- a deterministic plan-input SHA-256; +- effective non-sensitive switches; +- module and action intent; +- the supplied Data Security Deployment Guide for Small Business, grouped by + cumulative Deployment Priority Levels: Priority 1 (Good), Priority 2 + (Better), and Priority 3 (Best); +- the pinned Microsoft Learn Lightweight guide as a separate supporting + reference; +- a UUID Plan ID and short `PUR-yyyyMMdd-HHmmss-XXXXXXXX` plan reference. +- a sanitized intended-state snapshot and independent SHA-256 for later + read-only tenant comparison. + +The primary mapping follows the supplied deck: Good includes audit logging, +labels, container and SharePoint/OneDrive label enablement, and core DLP; +Better includes Exchange retention; Best includes Endpoint, Teams, and Copilot +DLP, auto-labeling, encryption, custom sensitive information types, and DSPM. +Actions not assigned by the deck are reported as toolkit extensions. +The HTML Action Preview includes a closed **How to read the Action Preview** +panel that defines every intent, priority-level, and recommendation-comparison +status without changing the canonical JSON values. + +The plan uses `Included`, `Excluded`, `Conditional`, and `Not configured`. A +conditional item still needs runtime license or prerequisite confirmation. The +plan does not read the tenant and cannot tell whether an object will be created, +updated, adopted, or left unchanged. + +| Parameter | Behavior | +|---|---| +| `-DeploymentPlanPath ` | Sets the `.html` or `.htm` path. Other extensions are rejected. The JSON sidecar uses the same basename. | +| `-NoDeploymentPlan` | Suppresses both pre-connection plan artifacts. | +| `-ReportPath ` | Sets the separate end-of-run HTML report path. | +| `-NoReport` | Suppresses only the end-of-run HTML and JSON evidence. | + +Plan generation is best effort. A local rendering or file-write failure is +recorded as a warning and does not block the deployment. + +Default filenames include the short plan reference. The Plan ID and reference +identify one generated HTML and JSON pair. The deterministic plan-input +SHA-256 identifies equivalent effective configuration and mapping inputs across +generations. The intended-state SHA-256 identifies the normalized comparison +snapshot. It also records publication mode, encryption lifetime, ownership, +and deterministic opaque digests for custom principals. Ownership is carried +as the top-level `ManagedByTag` field and included in the plan-input +fingerprint, not in `IntendedStateSha256`. The snapshot is +private customer-sensitive evidence because configured object names needed for +matching can appear. Tenant identity, live GUIDs in free text, mailbox UPNs, +resolved tenant domains, credentials, secrets, and local paths are excluded. +The dependent tenant-validation PR must be reconciled with this finalized +schema before `-PlanPath` is supported as an operator workflow. + +--- + +## Safety rails when editing + +- **`-WhatIf` first, every time.** It previews the full run with zero writes. +- **DLP stays in simulation** (`DlpStartInSimulation = $true`) until you + consciously promote — policy edits don't enforce on day 0. +- **Irreversible switches default to safe.** Retention (`-ApplyRetention`) and + co-authoring (`EnableLabelCoAuth` / `-EnableLabelCoAuthoring`) are opt-in. +- **`-BPOnly`** hard-blocks every E5-only feature so a fork that adds, say, an + Endpoint policy can't accidentally be pushed to a Business Premium customer. +- **`-AdoptExisting`** is required to update objects that exist but aren't + stamped with `ManagedByTag` — prevents silent overwrites. + +## Related + +- [Scenarios & Capabilities](scenarios/) — what each setting does and the per-licence behaviour. +- [Change-Management Playbook](change-management-playbook/) — when to flip which config key across the deployment timeline. +- [Deployment Framework (Visual)](deployment-framework/) — where configuration sits in the overall method. diff --git a/Products/Purview/docs/Configuration-Validation.md b/Products/Purview/docs/Configuration-Validation.md new file mode 100644 index 0000000..77f2afb --- /dev/null +++ b/Products/Purview/docs/Configuration-Validation.md @@ -0,0 +1,241 @@ +--- +title: Configuration Validation +layout: default +parent: Purview +nav_order: 10 +permalink: /purview/configuration-validation/ +--- + +# Configuration Validation +{: .no_toc } + +`Test-PurviewTenantConfiguration.ps1` reads current Purview tenant +configuration. With a plan, it compares live state with the plan's sanitized +`IntendedState`. Without a plan, it performs a guide-only Good, Better, and Best +assessment and does not load default configuration intent. + +It never changes anything. There is no apply path and no repair switch. + +For installation, module requirements, run commands, report locations, and +module-version troubleshooting, start with +[Running the Tenant Validation Report](Tenant-Validation-Report.md). + +## What it is and is not + +| It is | It is not | +|---|---| +| A comparison with one approved Deployment Plan | A compliance assessment | +| Scoped to fields the toolkit owns | A judgement on your own settings | +| A read-only readback you can run any time | The end-of-run deployment report | +| Evidence you can attach to a change record | A measure of how well the controls work | + +The report says so on its front page. Nobody should be able to forward it to a +customer and have it read as a compliance statement. + +## Prerequisites + +A plan is optional. Plan comparison accepts a Deployment Plan JSON sidecar at +**schema 1.2 or later**. Artifact type, plan reference, schema, plan-input +fingerprint, intended-state fingerprint, and intended-state content are checked +before authentication. + +Regenerating is offline and deterministic. Run the orchestrator with the same +configuration and switches, and the plan fingerprint is reproduced: + +```powershell +.\Deploy-PurviewBestPractice.ps1 -TenantAdminUpn admin@contoso.onmicrosoft.com -WhatIf +``` + +## Permissions + +Validation uses its own read-only connection profile rather than the deployment +one. It requests two delegated Microsoft Graph scopes: + +| Scope | Why | +|---|---| +| `Organization.Read.All` | tenant identity check | +| `GroupSettings.Read.All` | the container-label directory setting | + +The deployment profile's `Directory.ReadWrite.All` is deliberately not +requested. Exchange Online, Security & Compliance, and SharePoint Online do not +offer per-operation scopes, so those sessions can read whatever the operator's +directory role allows. `Get-SPOTenant` has no documented least-privileged read +role, which is why the SharePoint session is optional: without it, the two +SharePoint-backed actions report `Not evaluated` and the rest of the run +continues. + +Requesting two scopes is not enough if the effective token contains older, +broader consent. The validator checks the connected Graph context and stops +before tenant collection when any other Graph resource permission is present. +Standard OpenID identity scopes (`openid`, `profile`, `email`, and +`offline_access`) are allowed. + +When the existing context is broader, the validator disconnects it and directs +the operator to close the process. Start a fresh `pwsh -NoProfile` session and +rerun so a new delegated token is requested. This clears the in-process +context, but the shared Microsoft Graph client or Windows broker can still +return an earlier broad grant across processes. The new token is therefore +checked again. + +If the fresh sign-in is still broader, use an approved tenant-local public +client whose delegated grant contains only `Organization.Read.All` and +`GroupSettings.Read.All`. Pass its application ID with `-ClientId`; use +`-UseDeviceAuthentication` when the host cannot display the default WAM or +browser flow. The validator never creates an app registration, grants consent, +or stores a credential. It does not automatically clear the shared Windows +broker because that could sign the user out of other broker-enabled tools. + +For module requirements and the public sign-in instructions, see +[Permissions and sign-in](Tenant-Validation-Report.md#permissions-and-sign-in). +Verify the service roles in an approved pilot before relying on validation results. + +## When to run it + +Not immediately after a deployment. Purview label, DLP, and tenant settings can +take up to 24 hours to become visible to a read, and the tool has no +"propagating" result: a setting that is still rolling out is reported as drift. +Run it after the propagation window, or on a schedule, or before a change +review. The report repeats this warning at the top so nobody acts on a run made +ten minutes after a deployment. + +## Running it + +```powershell +cd Products/Purview + +.\Test-PurviewTenantConfiguration.ps1 ` + -PlanPath .\Deploy-PurviewBestPractice-Plan-PUR-20260814-091500-1234ABCD.json ` + -TenantAdminUpn admin@contoso.onmicrosoft.com +``` + +Partner GDAP, with an explicit report location: + +```powershell +.\Test-PurviewTenantConfiguration.ps1 ` + -PlanPath .\plan.json ` + -TenantAdminUpn partneradmin@fabrikam.onmicrosoft.com ` + -DelegatedOrganization contoso.onmicrosoft.com ` + -OutputPath .\evidence\contoso-validation.html +``` + +| Parameter | Purpose | +|---|---| +| `-PlanPath` | optional schema 1.2 plan JSON to compare | +| `-TenantAdminUpn` | required; sign-in identity | +| `-OutputPath` | HTML path. The JSON sidecar uses the same basename | +| `-DelegatedOrganization` | customer tenant domain for GDAP sign-in | +| `-SharePointAdminUrl` | for multi-geo, renamed, or vanity-domain tenants | +| `-ClientId` | approved tenant-local public client restricted to the two validation Graph permissions | +| `-UseDeviceAuthentication` | use the Graph device-code flow when WAM or browser authentication cannot open | +| `-AutoInstallModules` | install missing read-only modules without prompting | +| `-NonInteractive` | fail instead of prompting | + +## Reading the results + +Each action records three independent axes. + +| Axis | Values | +|---|---| +| Observation | `Present`, `Absent`, `Unreadable`, `Unsupported` | +| Intended state | `Match`, `Mismatch`, `NotInPlan`, `NotEvaluated` | +| Guide baseline | `Meets`, `DoesNotMeet`, `Indeterminate`, `NotApplicable` | + +The report also retains the legacy operational status used for exit codes: + +| Result | Meaning | What to do | +|---|---|---| +| **Matched** | every managed field matches the plan | nothing | +| **Drift** | a managed field differs, after transient retries | find out whether the deployment action failed or the setting was changed afterward | +| **Not evaluated** | entitlement is explicitly known to be absent, or a required service session, property, or capability was unavailable | confirm entitlement, session availability, or property support before treating it as drift | +| **Informational** | the plan excluded this action or did not configure it | nothing, unless you now want the feature in scope | +| **Collection failed** | the read did not succeed after retries | check permissions, modules, and service health, then rerun | + +Two of these deserve emphasis. + +**Informational is not a pass and not a failure.** If your plan excluded +retention and the tenant has a retention policy, that policy is reported as +informational context. The plan did not ask for it, so the tool has no opinion +about whether it should be there. + +**There is no "pending propagation" result.** Transient read failures are +retried with backoff. Anything that still does not match is reported as drift. +A result meaning "it might be right later" is a result nobody acts on. This is +why the timing advice above matters. + +**Not evaluated covers unavailable prerequisites.** If entitlement is explicitly +known to be absent, the action is not evaluated because the tenant is not +expected to expose that feature. If a required session, property, or capability +is unavailable, investigate connectivity, consent, roles, or module support. +Unreadable or missing license inventory is provisional and does not block a +workload read that can prove support. + +### Scored and unscored + +Only fields the toolkit owns are scored. A DLP policy comment you edited by +hand, a notification setting you tuned, a label policy that is not the +toolkit's: those appear as redacted unscored differences so you can see them, +and they never count against the result. A report that fails your own settings +is a report you stop reading. + +## Exit codes + +The exit code is a bitmask, so one run can report both drift and a read gap. + +| Code | Meaning | +|---|---| +| 0 | no drift, no collection failures | +| 2 | drift present | +| 4 | collection failure present | +| 6 | both | +| 1 | fatal input, schema, tenant-identity, or connection failure | + +Exit code 1 means the run stopped before it read anything useful: a missing or +unusable plan, a schema the validator will not accept, a tenant that is not the +one you asked for, or a failed connection. No artifact is written, because +there is nothing to report. + +## Output + +A self-contained HTML report and a matching JSON sidecar, both rendered from one +model so they cannot disagree. The HTML has no external stylesheet, font, or +script, so it can be emailed, attached to a ticket, or opened offline. + +Both artifacts are written even when some collectors failed. A run where four +reads failed is exactly the run whose report you need. + +The report correlates with its plan through the plan ID, the plan input +SHA-256, and the configuration SHA-256. Check those before acting on a report +somebody else produced. + +The JSON artifact type is `PurviewTenantValidation`, schema `1.0`. Its short +reference starts with `PUR-VAL-`. HTML and JSON use the same canonical model. +The report includes service status, current configuration, optional plan +comparison, guide tiers, extensions, manual checks, and a technical appendix. + +Validation output contains tenant observations. Keep it in the approved private +evidence location. Do not commit or publish it. + +## Current limitations + +- Pilot validation against a live tenant has not been performed for this + release. The deterministic fixtures prove the toolkit's own logic; they do + not establish Microsoft supportability or real permission behaviour. +- The shared Microsoft Graph PowerShell client can return previously consented + permissions in the effective token. The validator fails closed in that case; + retry first from a fresh PowerShell process. If the new token is still broad, + an approved isolated public client is required rather than accepting the + broader context. +- Least-privileged read roles for the Exchange Online, Security & Compliance, + and SharePoint Online cmdlets are not documented by Microsoft per cmdlet and + still need pilot confirmation. +- There is no propagation grace window. Run validation after the propagation + window described above. +- Actions the plan excluded are reported without a tenant read, so the report + says nothing about a feature the plan did not ask for. If you need to know + whether an excluded feature is configured, check it directly. +- A label the toolkit adopted from a pre-existing label with a GUID-shaped + internal name will report drift, because the plan expects the configured + internal name. The direction of that error is a false alarm rather than a + missed problem, but it is a real gap. +- The tool reports drift. It does not fix it, and it will not gain a repair + switch without a separate design and approval. diff --git a/Products/Purview/docs/DLP-Simulation-Exit-Runbook.md b/Products/Purview/docs/DLP-Simulation-Exit-Runbook.md index f3859f7..4b3a83e 100644 --- a/Products/Purview/docs/DLP-Simulation-Exit-Runbook.md +++ b/Products/Purview/docs/DLP-Simulation-Exit-Runbook.md @@ -1,184 +1,184 @@ ---- -title: DLP Simulation Exit Runbook -layout: default -parent: Purview -nav_order: 6 -permalink: /purview/dlp-simulation-exit-runbook/ ---- - -# DLP Simulation Exit Runbook - -> **Audience.** Whoever runs the Phase 3 (day-30) -> promote-from-simulation conversation with the customer. Pairs with -> [Change-Management-Playbook.md](Change-Management-Playbook.md). - -By default the toolkit deploys every DLP policy in **simulation mode** -(`DlpStartInSimulation = $true` → `TestWithoutNotifications`). This is -the right default — nothing breaks on day one, telemetry accumulates, -and the customer sees what *would* have been blocked before it actually -blocks anything. - -The risk is what happens at day 30: most teams either - -1. **Sit there forever** because nobody knows how to read the telemetry - (security theatre — the policies never actually enforce), or -2. **Flip enforce on without looking** (and tank legitimate outbound - sharing on day 31, generating a helpdesk fire-drill). - -This runbook gives you the exit criteria and the pull-the-report -walkthrough so you can make the decision on evidence. - ---- - -## When to run this - -Day 30 after deploy, or whenever you're about to flip a DLP policy out -of simulation. Do it **per policy**, not as a global "turn everything -on" event. - ---- - -## Step 1 — Pull the "what would have leaked" report from Activity Explorer - -This is the single most useful artifact simulation produces, and the -single most under-used feature of Purview. Most teams never open it. - -### Where it lives - -1. Open the **Microsoft Purview portal** (`https://purview.microsoft.com`). -2. **Solutions** → **Data loss prevention** → **Activity explorer**. -3. Filter by: - * **Activity** = `DLP rule match` - * **Policy** = (the policy you're reviewing) - * **Date range** = since deploy - -You'll get a row per policy hit, with the user, the file/email, the -matched label, the workload (Exchange / SPO / OneDrive / Endpoint), and -what *would* have happened (block / warn / audit). - -### What to look for - -| Pattern | Interpretation | Action | -|---|---|---| -| **Zero hits in 30 days** | Either the label isn't being applied (check label policy publish), or the customer genuinely doesn't share Confidential content externally | Investigate before promoting — a policy with no telemetry is a policy you can't validate | -| **A handful of hits, all from the same 1–2 users** | Likely legitimate business communication (e.g. a sales rep emailing a quote to a known client) | Talk to those users *before* promoting; you may need an exception (allow-list domain) or business-process change | -| **Steady stream of hits across many users** | The label is being over-applied — likely `Confidential\AllEmployees` is the default for documents and people are emailing routine docs out | Tighten label defaults OR refine the DLP rule conditions OR accept that promoting will generate user friction | -| **Hits concentrated on one workload (e.g. all SPO, no EXO)** | The other workload's label adoption is lower than expected | Investigate user-side label availability (`Get-Label`, check published policy) before promoting either workload | - -### Export the evidence - -Click **Export** → CSV. Save to the customer record alongside the -deploy log. This is your "we made the promote-to-enforce decision based -on this telemetry" audit trail. - ---- - -## Step 2 — Cross-check with the audit log - -Activity Explorer is the DLP-specific view. For the wider picture, query -the unified audit log via PowerShell: - -```powershell -# Last 30 days of DLP-related audit entries -Search-UnifiedAuditLog ` - -StartDate (Get-Date).AddDays(-30) ` - -EndDate (Get-Date) ` - -RecordType ComplianceDLPExchange,ComplianceDLPSharePoint,DLPEndpoint ` - -ResultSize 5000 | - Group-Object -Property UserIds | - Sort-Object Count -Descending | - Select-Object Count, Name -First 20 -``` - -Top-N users by DLP hit count gives you the list of people to talk to -before flipping enforce on. Two or three conversations beforehand -saves twenty helpdesk tickets after. - ---- - -## Step 3 — Decide per policy (not globally) - -For each policy in your config: - -| Decision | Trigger | Action | -|---|---|---| -| **Promote to enforce** | Telemetry looks reasonable, exceptions are identified and either accepted or worked around | Flip `DlpStartInSimulation = $false` in `PurviewConfig.psd1` and re-run, OR change Mode in the Purview portal per policy | -| **Extend simulation by 30 more days** | Not enough data, or recent process change means the last 30 days isn't representative | Leave config as-is; rebook the review | -| **Refine the policy first** | Telemetry shows false positives or wrong scope (e.g. internal department gets blocked) | Edit the DLP rule conditions in `PurviewConfig.psd1` (exceptions, recipient overrides), re-run, then start a fresh 30-day window | -| **Drop the policy** | Telemetry shows the policy serves no purpose for this customer's workflow | Remove the entry from `DlpPolicies` and re-run; or `-SkipDLP` and delete manually | - ---- - -## Step 4 — Document the decision - -Add a "Phase 3 outcome" note to the customer record listing, per policy: - -* Policy name -* Decision (promote / extend / refine / drop) -* Evidence cited (Activity Explorer hit count over the 30-day window) -* Timestamp and operator -* Date of next review (90 days after promote is a reasonable cadence) - -Without this, three months later nobody can answer *"is this tenant in -production-grade DLP, or are the policies still in simulation?"* - ---- - -## Endpoint DLP — extra step - -For the Endpoint policy (E5 only): - -1. **Confirm device onboarding.** Purview portal → Settings → Device - onboarding. If no devices are listed, the Endpoint policy has been - firing on zero devices — your telemetry is empty for the wrong - reason. Onboard devices first, then start the 30-day window fresh. -2. **Promote action-by-action.** Endpoint DLP supports per-action - modes — `CopyPaste` can be `Block` while `Print` stays `Audit`. - The default config sets every action to `Audit`. Move to `Warn` or - `BlockOverride` (user can override with justification) before going - to `Block`. -3. **Test on a single device first.** Edit the policy scope in the - portal to a pilot device group before flipping the whole tenant. - ---- - -## How to promote out of simulation - -Two ways — both work, pick whichever fits your operating model. - -### Via the toolkit (preferred — consistent with re-runs) - -```powershell -# Edit PurviewConfig.psd1 -DlpStartInSimulation = $false - -# Re-run the DLP module only -.\Deploy-PurviewBestPractice.ps1 ` - -TenantAdminUpn admin@contoso.onmicrosoft.com ` - -SkipTenantSettings -SkipLabels -ApplyRetention -``` - -This flips **every** DLP policy in the config to enforce mode. If you -want per-policy control, use the portal route below. - -### Via the Purview portal (per-policy control) - -1. Purview portal → DLP → Policies → click the policy. -2. **Edit policy** → step through to **Policy mode** → choose **Turn it on right away** or **Test it out first → Turn the policy on if it's not edited within fifteen days of simulation**. -3. Save. - -> **Important.** On the next toolkit re-run, the toolkit reconciles -> policy mode against config. If you flip a policy to Enable in the -> portal but leave `DlpStartInSimulation = $true` in the config, the -> next run pulls the policy back to simulation. Either flip the config -> flag, use `-SkipDLP`, or commit to portal-only operation for that -> policy. - ---- - -## Related reading - -* [Change-Management-Playbook.md](Change-Management-Playbook.md) — Phase 3 step references this runbook. -* [Scenarios.md](Scenarios.md) — Scenario 3 documents the DLP module behaviour and simulation reconciliation on re-runs. -* Microsoft Learn — [Get started with Activity Explorer](https://learn.microsoft.com/en-us/purview/data-classification-activity-explorer). -* Microsoft Learn — [Test or simulate DLP policies](https://learn.microsoft.com/en-us/purview/dlp-test-dlp-policies). +--- +title: DLP Simulation Exit Runbook +layout: default +parent: Purview +nav_order: 6 +permalink: /purview/dlp-simulation-exit-runbook/ +--- + +# DLP Simulation Exit Runbook + +> **Audience.** Whoever runs the Phase 3 (day-30) +> promote-from-simulation conversation with the customer. Pairs with +> [Change-Management-Playbook.md](Change-Management-Playbook.md). + +By default the toolkit deploys every DLP policy in **simulation mode** +(`DlpStartInSimulation = $true` → `TestWithoutNotifications`). This is +the right default — nothing breaks on day one, telemetry accumulates, +and the customer sees what *would* have been blocked before it actually +blocks anything. + +The risk is what happens at day 30: most teams either + +1. **Sit there forever** because nobody knows how to read the telemetry + (security theatre — the policies never actually enforce), or +2. **Flip enforce on without looking** (and tank legitimate outbound + sharing on day 31, generating a helpdesk fire-drill). + +This runbook gives you the exit criteria and the pull-the-report +walkthrough so you can make the decision on evidence. + +--- + +## When to run this + +Day 30 after deploy, or whenever you're about to flip a DLP policy out +of simulation. Do it **per policy**, not as a global "turn everything +on" event. + +--- + +## Step 1 — Pull the "what would have leaked" report from Activity Explorer + +This is the single most useful artifact simulation produces, and the +single most under-used feature of Purview. Most teams never open it. + +### Where it lives + +1. Open the **Microsoft Purview portal** (`https://purview.microsoft.com`). +2. **Solutions** → **Data loss prevention** → **Activity explorer**. +3. Filter by: + * **Activity** = `DLP rule match` + * **Policy** = (the policy you're reviewing) + * **Date range** = since deploy + +You'll get a row per policy hit, with the user, the file/email, the +matched label, the workload (Exchange / SPO / OneDrive / Endpoint), and +what *would* have happened (block / warn / audit). + +### What to look for + +| Pattern | Interpretation | Action | +|---|---|---| +| **Zero hits in 30 days** | Either the label isn't being applied (check label policy publish), or the customer genuinely doesn't share Confidential content externally | Investigate before promoting — a policy with no telemetry is a policy you can't validate | +| **A handful of hits, all from the same 1–2 users** | Likely legitimate business communication (e.g. a sales rep emailing a quote to a known client) | Talk to those users *before* promoting; you may need an exception (allow-list domain) or business-process change | +| **Steady stream of hits across many users** | The label is being over-applied — likely `Confidential\AllEmployees` is the default for documents and people are emailing routine docs out | Tighten label defaults OR refine the DLP rule conditions OR accept that promoting will generate user friction | +| **Hits concentrated on one workload (e.g. all SPO, no EXO)** | The other workload's label adoption is lower than expected | Investigate user-side label availability (`Get-Label`, check published policy) before promoting either workload | + +### Export the evidence + +Click **Export** → CSV. Save to the customer record alongside the +deploy log. This is your "we made the promote-to-enforce decision based +on this telemetry" audit trail. + +--- + +## Step 2 — Cross-check with the audit log + +Activity Explorer is the DLP-specific view. For the wider picture, query +the unified audit log via PowerShell: + +```powershell +# Last 30 days of DLP-related audit entries +Search-UnifiedAuditLog ` + -StartDate (Get-Date).AddDays(-30) ` + -EndDate (Get-Date) ` + -RecordType ComplianceDLPExchange,ComplianceDLPSharePoint,DLPEndpoint ` + -ResultSize 5000 | + Group-Object -Property UserIds | + Sort-Object Count -Descending | + Select-Object Count, Name -First 20 +``` + +Top-N users by DLP hit count gives you the list of people to talk to +before flipping enforce on. Two or three conversations beforehand +saves twenty helpdesk tickets after. + +--- + +## Step 3 — Decide per policy (not globally) + +For each policy in your config: + +| Decision | Trigger | Action | +|---|---|---| +| **Promote to enforce** | Telemetry looks reasonable, exceptions are identified and either accepted or worked around | Flip `DlpStartInSimulation = $false` in `PurviewConfig.psd1` and re-run, OR change Mode in the Purview portal per policy | +| **Extend simulation by 30 more days** | Not enough data, or recent process change means the last 30 days isn't representative | Leave config as-is; rebook the review | +| **Refine the policy first** | Telemetry shows false positives or wrong scope (e.g. internal department gets blocked) | Edit the DLP rule conditions in `PurviewConfig.psd1` (exceptions, recipient overrides), re-run, then start a fresh 30-day window | +| **Drop the policy** | Telemetry shows the policy serves no purpose for this customer's workflow | Remove the entry from `DlpPolicies` and re-run; or `-SkipDLP` and delete manually | + +--- + +## Step 4 — Document the decision + +Add a "Phase 3 outcome" note to the customer record listing, per policy: + +* Policy name +* Decision (promote / extend / refine / drop) +* Evidence cited (Activity Explorer hit count over the 30-day window) +* Timestamp and operator +* Date of next review (90 days after promote is a reasonable cadence) + +Without this, three months later nobody can answer *"is this tenant in +production-grade DLP, or are the policies still in simulation?"* + +--- + +## Endpoint DLP — extra step + +For the Endpoint policy (E5 only): + +1. **Confirm device onboarding.** Purview portal → Settings → Device + onboarding. If no devices are listed, the Endpoint policy has been + firing on zero devices — your telemetry is empty for the wrong + reason. Onboard devices first, then start the 30-day window fresh. +2. **Promote action-by-action.** Endpoint DLP supports per-action + modes — `CopyPaste` can be `Block` while `Print` stays `Audit`. + The default config sets every action to `Audit`. Move to `Warn` or + `BlockOverride` (user can override with justification) before going + to `Block`. +3. **Test on a single device first.** Edit the policy scope in the + portal to a pilot device group before flipping the whole tenant. + +--- + +## How to promote out of simulation + +Two ways — both work, pick whichever fits your operating model. + +### Via the toolkit (preferred — consistent with re-runs) + +```powershell +# Edit PurviewConfig.psd1 +DlpStartInSimulation = $false + +# Re-run the DLP module only +.\Deploy-PurviewBestPractice.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com ` + -SkipTenantSettings -SkipLabels -ApplyRetention +``` + +This flips **every** DLP policy in the config to enforce mode. If you +want per-policy control, use the portal route below. + +### Via the Purview portal (per-policy control) + +1. Purview portal → DLP → Policies → click the policy. +2. **Edit policy** → step through to **Policy mode** → choose **Turn it on right away** or **Test it out first → Turn the policy on if it's not edited within fifteen days of simulation**. +3. Save. + +> **Important.** On the next toolkit re-run, the toolkit reconciles +> policy mode against config. If you flip a policy to Enable in the +> portal but leave `DlpStartInSimulation = $true` in the config, the +> next run pulls the policy back to simulation. Either flip the config +> flag, use `-SkipDLP`, or commit to portal-only operation for that +> policy. + +--- + +## Related reading + +* [Change-Management-Playbook.md](Change-Management-Playbook.md) — Phase 3 step references this runbook. +* [Scenarios.md](Scenarios.md) — Scenario 3 documents the DLP module behaviour and simulation reconciliation on re-runs. +* Microsoft Learn — [Get started with Activity Explorer](https://learn.microsoft.com/en-us/purview/data-classification-activity-explorer). +* Microsoft Learn — [Test or simulate DLP policies](https://learn.microsoft.com/en-us/purview/dlp-test-dlp-policies). diff --git a/Products/Purview/docs/Deployment-Framework.html b/Products/Purview/docs/Deployment-Framework.html index 1af5bda..f75717e 100644 --- a/Products/Purview/docs/Deployment-Framework.html +++ b/Products/Purview/docs/Deployment-Framework.html @@ -1,636 +1,639 @@ - - - - - -Purview Deployment Framework — Simulate-Then-Promote - - - - -
- -
-
- -
-

Microsoft Purview — Deployment Framework

-
SMB Best Practice Tool · Microsoft Purview Data Security baseline (license-aware: Business Premium and up)
-
-
-
- -
-
- -
-

Deploy safe. Simulate. Promote on a gate.

-

- The goal of this toolkit is to bring a Microsoft 365 tenant to Microsoft's recommended - Data Security baseline — sensitivity labels, DLP, retention and audit — - without surprising a single end user. Business Premium is the baseline floor, - but the toolkit is license-aware: it auto-detects the tenant's licensing and applies - the controls that licence actually supports. A BP tenant gets Exchange + SharePoint/OneDrive - DLP and the core baseline; on an E5 / Microsoft Purview Suite tenant extra controls - switch on automatically — Endpoint DLP (device copy / print / USB, in simulation) and - AI governance (Copilot DLP) — while higher-SKU extras like Premium Audit and retention - stay opt-in. It does that with one repeatable method: every tenant-wide control - is piloted, applied idempotently, run in simulation first, and only - switched to enforcement after a human passes a Day-30 promotion gate. Click any phase - below to see what happens, how the app does it, the safety gate, and the rollback path. -

-
- Pilot before production - Zero-Trust connect - License-aware gating - Idempotent read-modify-write - DLP in simulation first - Human gate before enforce - Every step audited in a report -
-
- - - - - -
- -
- 🚦 The signature control: DLP ships in  Simulation  mode and only enforces after the Day-30 gate -
- -
- - - - -
-
-
♻️
-
Idempotent by construction
-

Every change is read-modify-write: read current state, preserve other tools' settings, merge our intent. Re-running never clobbers and never double-applies.

-
-
-
🧪
-
Simulation before enforcement
-

DLP policies are created in test/simulation mode. They observe and report matches for ~30 days with zero user impact before anyone flips them to enforce.

-
-
-
🔒
-
Least-privilege, Zero-Trust auth
-

Graph-first connect order, WAM broker, and the smallest consent scopes that satisfy the run. Read-only scopes for detection; write scopes only when a write feature is on.

-
-
-
🧾
-
Every disposition is audited
-

Ran, skipped or failed — each step writes to a persistent HTML + JSON report. Host output disappears; the report is the audit trail and the regression guard.

-
-
-
⚠️
-
Irreversible = opt-in
-

One-way operations (retention delete, co-authoring) are never default-on. The safe value ships by default; the operator must consciously pass a named switch.

-
-
-
🛟
-
Pilot, then production
-

The method assumes a test-tenant pilot first. Tenant-wide changes can take up to 24h to propagate, so nothing reaches production users unrehearsed.

-
-
- -
- Generated framework for the SMB Best Practice Tool · Microsoft Purview toolkit. - This is a delivery-method overview for whoever runs the toolkit (partners/MSPs or in-house IT) — it is not the - script itself. Authoritative behaviour lives in - Deploy-PurviewBestPractice.ps1 and the product docs. - Provided AS IS with no warranty. -
- -
- - - - + + + + + +Purview Deployment Framework — Simulate-Then-Promote + + + + +
+ +
+
+ +
+

Microsoft Purview — Deployment Framework

+
SMB Best Practice Tool · Microsoft Purview Data Security baseline (license-aware: Business Premium and up)
+
+
+
+ +
+
+ +
+

Deploy safe. Simulate. Promote on a gate.

+

+ The goal of this toolkit is to bring a Microsoft 365 tenant to Microsoft's recommended + Data Security baseline — sensitivity labels, DLP, retention and audit — + without surprising a single end user. Business Premium is the baseline floor, + but the toolkit is license-aware: it auto-detects the tenant's licensing and applies + the controls that licence actually supports. A BP tenant gets Exchange + SharePoint/OneDrive + DLP and the core baseline; on an E5 / Microsoft Purview Suite tenant extra controls + switch on automatically — Endpoint DLP (device copy / print / USB, in simulation) and + AI governance (Copilot DLP) — while higher-SKU extras like Premium Audit and retention + stay opt-in. It does that with one repeatable method: every tenant-wide control + is piloted, applied idempotently, run in simulation first, and only + switched to enforcement after a human passes a Day-30 promotion gate. Click any phase + below to see what happens, how the app does it, the safety gate, and the rollback path. +

+
+ Pilot before production + Zero-Trust connect + License-aware gating + Idempotent read-modify-write + DLP in simulation first + Human gate before enforce + Every step audited in a report +
+
+ + + + + +
+ +
+ 🚦 The signature control: DLP ships in  Simulation  mode and only enforces after the Day-30 gate +
+ +
+ + + + +
+
+
♻️
+
Idempotent by construction
+

Every change is read-modify-write: read current state, preserve other tools' settings, merge our intent. Re-running never clobbers and never double-applies.

+
+
+
🧪
+
Simulation before enforcement
+

DLP policies are created in test/simulation mode. They observe and report matches for ~30 days with zero user impact before anyone flips them to enforce.

+
+
+
🔒
+
Least-privilege, Zero-Trust auth
+

Graph-first connect order, WAM broker, and the smallest consent scopes that satisfy the run. Read-only scopes for detection; write scopes only when a write feature is on.

+
+
+
🧾
+
Every disposition is audited
+

Ran, skipped or failed — each step writes to a persistent HTML + JSON report. Host output disappears; the report is the audit trail and the regression guard.

+
+
+
⚠️
+
Irreversible = opt-in
+

One-way operations (retention delete, co-authoring) are never default-on. The safe value ships by default; the operator must consciously pass a named switch.

+
+
+
🛟
+
Pilot, then production
+

The method assumes a test-tenant pilot first. Tenant-wide changes can take up to 24h to propagate, so nothing reaches production users unrehearsed.

+
+
+ +
+ Generated framework for the SMB Best Practice Tool · Microsoft Purview toolkit. + This is a delivery-method overview for whoever runs the toolkit (partners/MSPs or in-house IT) — it is not the + script itself. Authoritative behaviour lives in + Deploy-PurviewBestPractice.ps1 and the product docs. + Provided AS IS with no warranty. +
+ +
+ + + + diff --git a/Products/Purview/docs/Deployment-Framework.md b/Products/Purview/docs/Deployment-Framework.md index 721fe80..96d84db 100644 --- a/Products/Purview/docs/Deployment-Framework.md +++ b/Products/Purview/docs/Deployment-Framework.md @@ -1,87 +1,103 @@ ---- -title: Deployment Framework (Visual) -layout: default -parent: Purview -nav_order: 8 -permalink: /purview/deployment-framework/ ---- - -# Purview Deployment Framework — visual model -{: .no_toc } - -An interactive, at-a-glance model of **how** this toolkit deploys the Microsoft -Purview Data Security baseline — from **pre-deployment** preparation, through the -**safe + simulated** deployment run, to **post-deployment** promotion and -operation. - -It exists so that anyone — a deployment lead, a reviewer, or a customer -stakeholder — can understand the **method** and the **goal** in one screen, -without reading the scripts: *deploy safe, simulate, and only promote to -enforcement after a human passes a Day-30 gate.* - -> 🎫 **License-aware scope.** Business Premium is the **baseline floor**, not the -> ceiling. The toolkit auto-detects the tenant's licensing and applies the -> controls that licence supports. A **BP** tenant gets Exchange + -> SharePoint/OneDrive DLP and the core baseline; on an **E5 / Microsoft Purview -> Suite** tenant license detection automatically adds **Endpoint DLP** (device -> copy / print / USB, created in simulation) and **AI governance (Copilot DLP)**. -> Higher-SKU extras like **Premium Audit** (`-EnablePremiumAudit`) and -> **retention** (`-ApplyRetention`) stay **opt-in**. E5-only workloads are -> *soft-skipped* (not failed) on BP, and `-BPOnly` hard-skips them even when the -> SKU is present. - -[Open the framework full-screen ↗]({{ '/Products/Purview/docs/Deployment-Framework.html' | relative_url }}){: .btn .btn-purple target="_blank" } -[Download the standalone file ↗]({{ '/Products/Purview/docs/Deployment-Framework.html' | relative_url }}){: .btn target="_blank" download } - -> 💡 The framework below is a **single self-contained HTML file** -> (`Products/Purview/docs/Deployment-Framework.html`). It has its own theme and -> needs no server, so you can share it on its own — email it, drop it in a deck -> appendix, or open it offline — and it still works. - ---- - - - ---- - -## What the model shows - -The framework breaks the deployment into **seven phases** across three stages: - -| Stage | Phases | What it means | -|---|---|---| -| 🟡 **Pre-deployment** | 0 · Prepare | Scope, prerequisites, pilot plan, customer change-management — *no tenant changes yet*. | -| 🔵 **Deployment** | 1 · Connect → 2 · Assess → 3 · Configure → 4 · Soak & Simulate | The app run. Zero-Trust connect, license-aware gating, idempotent baseline, and **DLP created in simulation mode** with zero user impact. | -| 🟢 **Post-deployment** | 5 · Promote → 6 · Adopt & Operate | The Day-30 human gate that promotes DLP from simulation to enforcement, then end-user adoption and ongoing operation. | - -Click any phase in the framework to expand **what happens**, **how the app does -it** (the method), the **safety gate**, and the **rollback path**. - -## The signature method - -The whole model is built around one idea that makes tenant-wide change safe for -an SMB: - -> **DLP ships in *simulation* and only enforces after a Day-30 promotion gate.** - -Every phase also obeys five cross-cutting principles — idempotent -read-modify-write, simulation-before-enforcement, least-privilege Zero-Trust -auth, every-disposition-audited reporting, and irreversible-operations-are-opt-in. -These are the same conventions the codebase itself is held to. - -## Where the detail lives - -The framework is a map, not the territory. Each phase links to the authoritative -docs: - -- [Scenarios & Capabilities](scenarios/) — what every task does and what's out of scope. -- [Configuration Reference](configuration-reference/) — every `PurviewConfig.psd1` knob and how to change it. -- [Change-Management Playbook](change-management-playbook/) — the day-by-day delivery runbook. -- [DLP Simulation Exit Runbook](dlp-simulation-exit-runbook/) — the Day-30 promotion gate. -- [Retention Default — Risk Note](retention-default-risk/) — before you pass `-ApplyRetention`. -- [End-User Adoption Guide](end-user-adoption-guide/) — the T-7 / T-0 / T+30 comms. +--- +title: Deployment Framework (Visual) +layout: default +parent: Purview +nav_order: 8 +permalink: /purview/deployment-framework/ +--- + +# Purview Deployment Framework — visual model +{: .no_toc } + +An interactive, at-a-glance model of **how** this toolkit deploys the Microsoft +Purview Data Security baseline — from **pre-deployment** preparation, through the +**safe + simulated** deployment run, to **post-deployment** promotion and +operation. + +It exists so that anyone — a deployment lead, a reviewer, or a customer +stakeholder — can understand the **method** and the **goal** in one screen, +without reading the scripts: *deploy safe, simulate, and only promote to +enforcement after a human passes a Day-30 gate.* + +> 🎫 **License-aware scope.** Business Premium is the **baseline floor**, not the +> ceiling. The toolkit auto-detects the tenant's licensing and applies the +> controls that licence supports. A **BP** tenant gets Exchange + +> SharePoint/OneDrive DLP and the core baseline; on an **E5 / Microsoft Purview +> Suite** tenant license detection automatically adds **Endpoint DLP** (device +> copy / print / USB, created in simulation) and **AI governance (Copilot DLP)**. +> Higher-SKU extras like **Premium Audit** (`-EnablePremiumAudit`) and +> **retention** (`-ApplyRetention`) stay **opt-in**. E5-only workloads are +> *soft-skipped* (not failed) on BP, and `-BPOnly` hard-skips them even when the +> SKU is present. + +[Open the framework full-screen ↗]({{ '/Products/Purview/docs/Deployment-Framework.html' | relative_url }}){: .btn .btn-purple target="_blank" } +[Download the standalone file ↗]({{ '/Products/Purview/docs/Deployment-Framework.html' | relative_url }}){: .btn target="_blank" download } + +> 💡 The framework below is a **single self-contained HTML file** +> (`Products/Purview/docs/Deployment-Framework.html`). It has its own theme and +> needs no server, so you can share it on its own — email it, drop it in a deck +> appendix, or open it offline — and it still works. + +--- + + + +--- + +## What the model shows + +The framework breaks the deployment into **seven phases** across three stages: + +| Stage | Phases | What it means | +|---|---|---| +| 🟡 **Pre-deployment** | 0 · Prepare | Scope, prerequisites, pilot plan, customer change-management, and offline Deployment Plan review. No tenant changes yet. | +| 🔵 **Deployment** | 1 · Connect → 2 · Assess → 3 · Configure → 4 · Soak & Simulate | The app run. Zero-Trust connect, license-aware gating, idempotent baseline, and **DLP created in simulation mode** with zero user impact. | +| 🟢 **Post-deployment** | 5 · Promote → 6 · Adopt & Operate | The Day-30 human gate that promotes DLP from simulation to enforcement, then end-user adoption and ongoing operation. | + +Click any phase in the framework to expand **what happens**, **how the app does +it** (the method), the **safety gate**, and the **rollback path**. + +At run start, the toolkit writes an offline Deployment Plan before +authentication. Its HTML and JSON files summarize config intent, compare +actions with the supplied Good, Better, and Best SMB guide, and retain the +Microsoft Learn Lightweight guide as a separate supporting reference. The +short plan reference identifies one artifact pair. The deterministic +fingerprint identifies equivalent intent. The plan does not inspect the tenant +or replace the tenant-aware `-WhatIf` review. Its JSON also carries a sanitized +intended-state snapshot as a stable handoff artifact for the dependent +tenant-validation feature. That feature is not an available operator workflow +until its stacked PR is reconciled with the finalized schema. The snapshot uses +`Microsoft365Copilot` for the documented public Copilot destination, records +privacy-safe classes and digests for custom AI locations, records only supported +retention destinations as deployable scope, records unsupported destinations +separately, and rejects invalid built-in label signatures before authentication. + +## The signature method + +The whole model is built around one idea that makes tenant-wide change safe for +an SMB: + +> **DLP ships in *simulation* and only enforces after a Day-30 promotion gate.** + +Every phase also obeys five cross-cutting principles — idempotent +read-modify-write, simulation-before-enforcement, least-privilege Zero-Trust +auth, every-disposition-audited reporting, and irreversible-operations-are-opt-in. +These are the same conventions the codebase itself is held to. + +## Where the detail lives + +The framework is a map, not the territory. Each phase links to the authoritative +docs: + +- [Scenarios & Capabilities](scenarios/) — what every task does and what's out of scope. +- [Configuration Reference](configuration-reference/) — every `PurviewConfig.psd1` knob and how to change it. +- [Change-Management Playbook](change-management-playbook/) — the day-by-day delivery runbook. +- [DLP Simulation Exit Runbook](dlp-simulation-exit-runbook/) — the Day-30 promotion gate. +- [Retention Default — Risk Note](retention-default-risk/) — before you pass `-ApplyRetention`. +- [End-User Adoption Guide](end-user-adoption-guide/) — the T-7 / T-0 / T+30 comms. +- [Configuration Validation](configuration-validation/): read-only post-deployment readback against a Deployment Plan. diff --git a/Products/Purview/docs/End-User-Adoption-Guide.html b/Products/Purview/docs/End-User-Adoption-Guide.html index 6626fcd..3a65531 100644 --- a/Products/Purview/docs/End-User-Adoption-Guide.html +++ b/Products/Purview/docs/End-User-Adoption-Guide.html @@ -1,634 +1,634 @@ - - - - - -What's changing in Microsoft 365 — your guide - - - - - -
-
- Microsoft 365 · What's changing -

New data-protection features are coming to your Microsoft 365

-

A friendly guide to the new Sensitivity button in Word, Excel, PowerPoint and Outlook — and what happens when you label a file or email.

- -
-

TL;DR — Your IT team is turning on Microsoft's standard data-protection features. You'll see a new Sensitivity button in Office apps. For the first 30 days nothing is blocked — IT is just watching. After 30 days, files and emails labelled Confidential or Highly Confidential can no longer be shared with people outside the company. General and Public work as normal — no change to your day-to-day inbox.

-
-
-
- -
- - -
-

1 The rollout, in three phases

-

Most of the changes don't actually block anything until Day 30. Until then, everything still works exactly as it does today.

-
-
- Day 1 · Labels arrive -

You see a new Sensitivity button

-

Word, Excel, PowerPoint and Outlook get a Sensitivity button. New emails default to General. New documents default to Confidential — All Employees. Nothing is blocked.

-
-
- Day 1–30 · Quiet monitoring -

IT watches, you keep working

-

Microsoft Purview is in simulation mode. You won't be blocked from anything, no pop-ups, no policy tips. IT collects telemetry on what would have been blocked so they can tune the rules.

-
-
- Day 30+ · Enforcement -

Sharing rules switch on

-

If you try to send or share Confidential or Highly Confidential content externally, you'll see a banner explaining the block. Internal sharing keeps working normally.

-
-
-
- - -
-

2 The four labels you'll actually pick

-

Your IT may publish more labels later (e.g. sub-labels for specific people), but these four cover almost everything you do day-to-day. The colored dot beside each label matches the color you'll see in the Sensitivity button in Word, Excel, PowerPoint and Outlook.

- -
-
-
PublicNo protection
-

Use for marketing material, press releases — anything explicitly approved for the world to see.

-

Nothing is added to the file or email.

-
-
-
General — Anyone (unrestricted)No protection
-

Routine internal email and documents you might share with trusted external partners (vendors, customers under NDA).

-

Nothing is added to the file or email.

- Default for new emails -
-
-
Confidential — All EmployeesFooter marking
-

Internal-only content: contracts, plans, customer data, anything regulated.

-

A footer "Classified as Confidential" may appear; external sharing is blocked after Day 30.

- Default for new documents -
-
-
Highly Confidential — All EmployeesEncrypted
-

Source code, pre-announce financials, employee PII, payroll, M&A.

-

File is encrypted — only people in our organisation can open it. Watermark "HIGHLY CONFIDENTIAL" and footer added. External sharing blocked after Day 30. Microsoft 365 Copilot will not summarise or ground on these files.

-
-
-
- - -
-

3 What it looks like in your apps

-

Three things start showing up in Office, Outlook and SharePoint. Here's a preview so it isn't a surprise.

- -
- - -
-
Word · New document
-
-
Home  |  Insert  |  Layout  |  ...
-
🔒 Sensitivity: Confidential — All Employees ▼
-
Click the button to change the label. New documents start at Confidential — All Employees by default.
- -
-
- - -
-
Outlook · Composing a message
-
-
To: someone@partner.com
-
Subject: Q4 board pack
-
⚠️ Policy tip: This message contains content labelled Confidential. After the monitoring period it cannot be sent to external recipients.
-
The tip appears before you click Send so you can correct course.
-
-
- - -
-
Excel · Highly Confidential workbook
-
-
🔒 Sensitivity: Highly Confidential ▼
-
HIGHLY CONFIDENTIAL
-
Encrypted. Only people in our organisation can open this file. If it ends up on a USB or personal Gmail, it cannot be read.
- -
-
- - -
-
SharePoint · Share dialog
-
-
Share budget-2026.xlsx
-
To: guest@external.com
-
🚫 Blocked — This file contains Confidential content that cannot be shared outside our organisation. Contact the helpdesk if there is a business need.
-
Internal share — same file, same person inside the company — works as normal.
-
-
- -
-
- - -
-

4 How to pick (or change) a label

-

Labels live on a single Sensitivity button at the top of every Office app. Picking one takes about three seconds.

- -

To set or change the label

-
    -
  1. Open the file in Word / Excel / PowerPoint, or open a new email in Outlook.
  2. -
  3. Click the Sensitivity button on the ribbon (top-right area).
  4. -
  5. Pick the right label. The visible marking (footer, watermark) updates immediately. Save to persist.
  6. -
- -

If you need to lower the sensitivity (e.g. share externally)

-
    -
  1. Click Sensitivity, pick the lower label (e.g. General or Public).
  2. -
  3. Office will prompt you for a brief justification — type a short sentence (e.g. "Approved for external release.").
  4. -
  5. Click OK. The change is logged for audit but no one has to approve it — you continue working straight away.
  6. -
- -
- Good to know. Old documents and emails are not relabelled automatically — the default only applies to new content. Existing files keep whatever label (or no label) they have today. -
-
- - -
-

5 What gets blocked after Day 30 — and what doesn't

-

Only externally addressed shares of Confidential or Highly Confidential content are blocked. Everything else works as normal.

- -
-
- 🚫 Blocked -
Outlook · Email a Confidential file to external.com / a partner
-

A yellow banner explains the block. Click Send and the message is stopped. The file owner and site admin are notified.

-
-
- ✅ Allowed -
Outlook · Email the same file to a colleague inside the company
-

Internal email flows normally. The footer marking is preserved.

-
-
- 🚫 Blocked -
SharePoint / OneDrive · Share a Confidential file with an external guest or anonymous link
-

The Share dialog blocks the action with a message. Internal sharing remains available.

-
-
- ✅ Allowed -
Anything labelled General or Public
-

Routine work email and public material flow normally. No tip, no block, no change.

-
-
- 🚫 Blocked -
Anyone outside the company who somehow receives a Highly Confidential file
-

The file is encrypted — it can't be opened without a Microsoft 365 identity in our organisation.

-
-
- ✅ Allowed -
Inside the company — view, edit, save, reply, reply-all, forward
-

Every employee can collaborate on encrypted files inside the organisation. Co-authoring may be limited if IT chose the conservative profile — ask helpdesk if you hit a co-author issue.

-
-
- -
- Can I override a block myself? No — not in the default setup. If you have a legitimate business need (e.g. sharing a labelled contract with the customer it relates to), contact the helpdesk. They can adjust the rule or share the file through an approved channel. -
-
- - -
-

6 Microsoft 365 Copilot — what changes

-

If your tenant has Microsoft 365 Copilot, one extra rule applies.

- -
- Copilot will ignore Highly Confidential files. When you ask Copilot to summarise, find or ground on something, anything labelled Highly Confidential is excluded from the answer — even if you personally have read access to it. This is intentional, to keep the most sensitive content out of AI prompts and responses. -
- -

Workaround. If you legitimately need Copilot to read the content, either relabel the file to Confidential — All Employees (with downgrade justification, if the content allows it) or open and summarise the file yourself.

-
- - -
-

7 Email retention — what happens to old mail

-

If your IT team enabled the optional 7-year retention policy:

- -
    -
  • Mailbox content older than 7 years is automatically deleted.
  • -
  • This applies across your whole mailbox — Inbox, Sent Items, even items you "deleted" months ago are kept hidden for the full 7 years before hard-removal.
  • -
  • You don't have to do anything. There is no warning at deletion time and no "trash" to recover from after the 7-year mark.
  • -
- -
- Need to keep something longer? Move it out of your mailbox — save the email as a .msg or .pdf in a SharePoint library, move the file into a team site, or ask IT for a longer retention policy on your team's mailbox. -
-
- - -
-

8 Cheat sheet

-

Print this section or pin it on your desktop — it covers 95% of day-to-day questions.

- -
-
-

📝Picking a label

-
    -
  • Public material → Public
  • -
  • Internal & trusted partners → General
  • -
  • Contracts, customer data → Confidential
  • -
  • Payroll, source, M&A → Highly Confidential
  • -
-
-
-

↓️Lowering a label

-
    -
  • Click Sensitivity
  • -
  • Pick lower label
  • -
  • Type a one-line reason
  • -
  • Continue working
  • -
-
-
-

🚫If you're blocked

-
    -
  • Read the banner — it tells you why
  • -
  • If valid, relabel to a lower level
  • -
  • If you can't, contact helpdesk
  • -
  • Don't keep retrying — logs build up
  • -
-
-
-

📱On mobile / Mac / web

-
    -
  • Outlook mobile — full support
  • -
  • Office for Mac / iPad — full support
  • -
  • Office on the web — full support
  • -
  • 3rd-party apps — may struggle with encrypted files
  • -
-
-
-

🏠Working from home

-
    -
  • Labels & DLP work anywhere — they're tied to your account, not the office network
  • -
  • Open an encrypted file online at least once every 30 days
  • -
-
-
-

💬Need help?

-
    -
  • "Should this be labelled higher?" → ask your manager or helpdesk
  • -
  • Stuck on a block → helpdesk
  • -
  • Co-author issue on an encrypted file → helpdesk
  • -
-
-
-
- - -
-

9 Frequently asked questions

- -
- Will my old documents and emails suddenly be labelled? -

No. Existing content is not relabelled automatically. The default only applies to new content created after the rollout. You can choose to label old content manually.

-
- -
- Will I be unable to send any external email? -

No. Only emails you have explicitly labelled Confidential or Highly Confidential are blocked externally. Routine General email flows normally — this is the majority of what you send.

-
- -
- I attached a Confidential file to an email that was labelled General — why did the email's label change on its own? -

This is expected: "inherit label from attachments" is on by default. If an attachment carries a higher-priority label than the email, Outlook silently upgrades the email's label to match before it's sent — no prompt, no action needed from you. It's a safety net so a sensitive attachment can't leave under a lower-sensitivity email label.

-
- -
- My document auto-got Confidential — All Employees, but I need to share it externally. What do I do? -

Open the document, click Sensitivity → General (or Public), provide a brief justification when prompted, then share normally.

-
- -
- Will labels and DLP work outside the office, on my home laptop? -

Yes — the protections are tied to your Microsoft 365 identity, not the office network. They follow you wherever you sign in to Office.

-
- -
- Will labels work on my phone (Outlook mobile)? -

Yes. Outlook mobile (iOS and Android) supports applying and viewing sensitivity labels.

-
- -
- Will a labelled file open on a Mac, iPad, or the web? -

Yes for Office apps (Word/Excel/PowerPoint/Outlook on Mac, iOS, Android and the web). Third-party apps that don't understand Microsoft Information Protection may struggle with encrypted (Highly Confidential) files.

-
- -
- What happens if I email a labelled doc to a personal Gmail / Hotmail? -

After Day 30, blocked. During the first 30-day monitoring window, allowed but logged.

-
- -
- I deleted an email less than 7 years old — is it really gone? -

No — under a retention policy, items "deleted" by you are kept in a hidden recoverable-items area until the 7-year mark. After 7 years they're hard-deleted.

-
- -
- How do labels affect Teams chat? -

Files shared in Teams chat live in OneDrive and inherit the same DLP rules as direct OneDrive shares. So a Confidential file dropped into a Teams chat with an external guest will be blocked the same way as a direct external share.

-
- -
- When I create a new Team, Group or SharePoint site, I'm asked to pick a sensitivity. Which one? -

You'll see three choices — General, Confidential — All Employees, and Highly Confidential — All Employees. Pick the one that matches the most sensitive content you expect to keep in that workspace. Most everyday team sites are General. Use Confidential — All Employees for sites with employee or customer PII, financials, or contracts. Use Highly Confidential — All Employees for source code, M&A material, or anything regulated. You can change it later if you guess wrong.

-
- -
- Who can I ask if a file should be labelled higher or lower? -

Your manager or the helpdesk. As a rule of thumb: if it contains employee or customer PII, financials, source code, contracts, or anything regulated — use Confidential or higher.

-
- -
- I opened a Highly Confidential file at the weekend and now it won't open. Why? -

Encrypted files re-check your access every 30 days. If you're offline for longer than that, the file locks until you reconnect. Sign in once on a connected device and you're back in.

-
- -
- What's NOT covered in this guide? -

This guide describes only what the Microsoft Purview baseline deploys. Your IT team may layer additional controls (Conditional Access, device compliance, MFA, mailbox auto-forward blocks, external-link expiry, etc.). If you encounter behaviour that's not described here, it's likely one of those — start with the helpdesk.

-
-
- - -
-

10 Learn more (Microsoft Learn)

-

Official Microsoft documentation if you want to dig deeper.

- -
- -
- This guide reflects the Microsoft Purview baseline deployed by the SMBTool toolkit. · Your IT team may have customised some settings — if anything differs, the helpdesk is your source of truth. -
- -
- - + + + + + +What's changing in Microsoft 365 — your guide + + + + + +
+
+ Microsoft 365 · What's changing +

New data-protection features are coming to your Microsoft 365

+

A friendly guide to the new Sensitivity button in Word, Excel, PowerPoint and Outlook — and what happens when you label a file or email.

+ +
+

TL;DR — Your IT team is turning on Microsoft's standard data-protection features. You'll see a new Sensitivity button in Office apps. For the first 30 days nothing is blocked — IT is just watching. After 30 days, files and emails labelled Confidential or Highly Confidential can no longer be shared with people outside the company. General and Public work as normal — no change to your day-to-day inbox.

+
+
+
+ +
+ + +
+

1 The rollout, in three phases

+

Most of the changes don't actually block anything until Day 30. Until then, everything still works exactly as it does today.

+
+
+ Day 1 · Labels arrive +

You see a new Sensitivity button

+

Word, Excel, PowerPoint and Outlook get a Sensitivity button. New emails default to General. New documents default to Confidential — All Employees. Nothing is blocked.

+
+
+ Day 1–30 · Quiet monitoring +

IT watches, you keep working

+

Microsoft Purview is in simulation mode. You won't be blocked from anything, no pop-ups, no policy tips. IT collects telemetry on what would have been blocked so they can tune the rules.

+
+
+ Day 30+ · Enforcement +

Sharing rules switch on

+

If you try to send or share Confidential or Highly Confidential content externally, you'll see a banner explaining the block. Internal sharing keeps working normally.

+
+
+
+ + +
+

2 The four labels you'll actually pick

+

Your IT may publish more labels later (e.g. sub-labels for specific people), but these four cover almost everything you do day-to-day. The colored dot beside each label matches the color you'll see in the Sensitivity button in Word, Excel, PowerPoint and Outlook.

+ +
+
+
PublicNo protection
+

Use for marketing material, press releases — anything explicitly approved for the world to see.

+

Nothing is added to the file or email.

+
+
+
General — Anyone (unrestricted)No protection
+

Routine internal email and documents you might share with trusted external partners (vendors, customers under NDA).

+

Nothing is added to the file or email.

+ Default for new emails +
+
+
Confidential — All EmployeesFooter marking
+

Internal-only content: contracts, plans, customer data, anything regulated.

+

A footer "Classified as Confidential" may appear; external sharing is blocked after Day 30.

+ Default for new documents +
+
+
Highly Confidential — All EmployeesEncrypted
+

Source code, pre-announce financials, employee PII, payroll, M&A.

+

File is encrypted — only people in our organisation can open it. Watermark "HIGHLY CONFIDENTIAL" and footer added. External sharing blocked after Day 30. Microsoft 365 Copilot will not summarise or ground on these files.

+
+
+
+ + +
+

3 What it looks like in your apps

+

Three things start showing up in Office, Outlook and SharePoint. Here's a preview so it isn't a surprise.

+ +
+ + +
+
Word · New document
+
+
Home  |  Insert  |  Layout  |  ...
+
🔒 Sensitivity: Confidential — All Employees ▼
+
Click the button to change the label. New documents start at Confidential — All Employees by default.
+ +
+
+ + +
+
Outlook · Composing a message
+
+
To: someone@partner.com
+
Subject: Q4 board pack
+
⚠️ Policy tip: This message contains content labelled Confidential. After the monitoring period it cannot be sent to external recipients.
+
The tip appears before you click Send so you can correct course.
+
+
+ + +
+
Excel · Highly Confidential workbook
+
+
🔒 Sensitivity: Highly Confidential ▼
+
HIGHLY CONFIDENTIAL
+
Encrypted. Only people in our organisation can open this file. If it ends up on a USB or personal Gmail, it cannot be read.
+ +
+
+ + +
+
SharePoint · Share dialog
+
+
Share budget-2026.xlsx
+
To: guest@external.com
+
🚫 Blocked — This file contains Confidential content that cannot be shared outside our organisation. Contact the helpdesk if there is a business need.
+
Internal share — same file, same person inside the company — works as normal.
+
+
+ +
+
+ + +
+

4 How to pick (or change) a label

+

Labels live on a single Sensitivity button at the top of every Office app. Picking one takes about three seconds.

+ +

To set or change the label

+
    +
  1. Open the file in Word / Excel / PowerPoint, or open a new email in Outlook.
  2. +
  3. Click the Sensitivity button on the ribbon (top-right area).
  4. +
  5. Pick the right label. The visible marking (footer, watermark) updates immediately. Save to persist.
  6. +
+ +

If you need to lower the sensitivity (e.g. share externally)

+
    +
  1. Click Sensitivity, pick the lower label (e.g. General or Public).
  2. +
  3. Office will prompt you for a brief justification — type a short sentence (e.g. "Approved for external release.").
  4. +
  5. Click OK. The change is logged for audit but no one has to approve it — you continue working straight away.
  6. +
+ +
+ Good to know. Old documents and emails are not relabelled automatically — the default only applies to new content. Existing files keep whatever label (or no label) they have today. +
+
+ + +
+

5 What gets blocked after Day 30 — and what doesn't

+

Only externally addressed shares of Confidential or Highly Confidential content are blocked. Everything else works as normal.

+ +
+
+ 🚫 Blocked +
Outlook · Email a Confidential file to external.com / a partner
+

A yellow banner explains the block. Click Send and the message is stopped. The file owner and site admin are notified.

+
+
+ ✅ Allowed +
Outlook · Email the same file to a colleague inside the company
+

Internal email flows normally. The footer marking is preserved.

+
+
+ 🚫 Blocked +
SharePoint / OneDrive · Share a Confidential file with an external guest or anonymous link
+

The Share dialog blocks the action with a message. Internal sharing remains available.

+
+
+ ✅ Allowed +
Anything labelled General or Public
+

Routine work email and public material flow normally. No tip, no block, no change.

+
+
+ 🚫 Blocked +
Anyone outside the company who somehow receives a Highly Confidential file
+

The file is encrypted — it can't be opened without a Microsoft 365 identity in our organisation.

+
+
+ ✅ Allowed +
Inside the company — view, edit, save, reply, reply-all, forward
+

Every employee can collaborate on encrypted files inside the organisation. Co-authoring may be limited if IT chose the conservative profile — ask helpdesk if you hit a co-author issue.

+
+
+ +
+ Can I override a block myself? No — not in the default setup. If you have a legitimate business need (e.g. sharing a labelled contract with the customer it relates to), contact the helpdesk. They can adjust the rule or share the file through an approved channel. +
+
+ + +
+

6 Microsoft 365 Copilot — what changes

+

If your tenant has Microsoft 365 Copilot, one extra rule applies.

+ +
+ Copilot will ignore Highly Confidential files. When you ask Copilot to summarise, find or ground on something, anything labelled Highly Confidential is excluded from the answer — even if you personally have read access to it. This is intentional, to keep the most sensitive content out of AI prompts and responses. +
+ +

Workaround. If you legitimately need Copilot to read the content, either relabel the file to Confidential — All Employees (with downgrade justification, if the content allows it) or open and summarise the file yourself.

+
+ + +
+

7 Email retention — what happens to old mail

+

If your IT team enabled the optional 7-year retention policy:

+ +
    +
  • Mailbox content older than 7 years is automatically deleted.
  • +
  • This applies across your whole mailbox — Inbox, Sent Items, even items you "deleted" months ago are kept hidden for the full 7 years before hard-removal.
  • +
  • You don't have to do anything. There is no warning at deletion time and no "trash" to recover from after the 7-year mark.
  • +
+ +
+ Need to keep something longer? Move it out of your mailbox — save the email as a .msg or .pdf in a SharePoint library, move the file into a team site, or ask IT for a longer retention policy on your team's mailbox. +
+
+ + +
+

8 Cheat sheet

+

Print this section or pin it on your desktop — it covers 95% of day-to-day questions.

+ +
+
+

📝Picking a label

+
    +
  • Public material → Public
  • +
  • Internal & trusted partners → General
  • +
  • Contracts, customer data → Confidential
  • +
  • Payroll, source, M&A → Highly Confidential
  • +
+
+
+

↓️Lowering a label

+
    +
  • Click Sensitivity
  • +
  • Pick lower label
  • +
  • Type a one-line reason
  • +
  • Continue working
  • +
+
+
+

🚫If you're blocked

+
    +
  • Read the banner — it tells you why
  • +
  • If valid, relabel to a lower level
  • +
  • If you can't, contact helpdesk
  • +
  • Don't keep retrying — logs build up
  • +
+
+
+

📱On mobile / Mac / web

+
    +
  • Outlook mobile — full support
  • +
  • Office for Mac / iPad — full support
  • +
  • Office on the web — full support
  • +
  • 3rd-party apps — may struggle with encrypted files
  • +
+
+
+

🏠Working from home

+
    +
  • Labels & DLP work anywhere — they're tied to your account, not the office network
  • +
  • Open an encrypted file online at least once every 30 days
  • +
+
+
+

💬Need help?

+
    +
  • "Should this be labelled higher?" → ask your manager or helpdesk
  • +
  • Stuck on a block → helpdesk
  • +
  • Co-author issue on an encrypted file → helpdesk
  • +
+
+
+
+ + +
+

9 Frequently asked questions

+ +
+ Will my old documents and emails suddenly be labelled? +

No. Existing content is not relabelled automatically. The default only applies to new content created after the rollout. You can choose to label old content manually.

+
+ +
+ Will I be unable to send any external email? +

No. Only emails you have explicitly labelled Confidential or Highly Confidential are blocked externally. Routine General email flows normally — this is the majority of what you send.

+
+ +
+ I attached a Confidential file to an email that was labelled General — why did the email's label change on its own? +

This is expected: "inherit label from attachments" is on by default. If an attachment carries a higher-priority label than the email, Outlook silently upgrades the email's label to match before it's sent — no prompt, no action needed from you. It's a safety net so a sensitive attachment can't leave under a lower-sensitivity email label.

+
+ +
+ My document auto-got Confidential — All Employees, but I need to share it externally. What do I do? +

Open the document, click Sensitivity → General (or Public), provide a brief justification when prompted, then share normally.

+
+ +
+ Will labels and DLP work outside the office, on my home laptop? +

Yes — the protections are tied to your Microsoft 365 identity, not the office network. They follow you wherever you sign in to Office.

+
+ +
+ Will labels work on my phone (Outlook mobile)? +

Yes. Outlook mobile (iOS and Android) supports applying and viewing sensitivity labels.

+
+ +
+ Will a labelled file open on a Mac, iPad, or the web? +

Yes for Office apps (Word/Excel/PowerPoint/Outlook on Mac, iOS, Android and the web). Third-party apps that don't understand Microsoft Information Protection may struggle with encrypted (Highly Confidential) files.

+
+ +
+ What happens if I email a labelled doc to a personal Gmail / Hotmail? +

After Day 30, blocked. During the first 30-day monitoring window, allowed but logged.

+
+ +
+ I deleted an email less than 7 years old — is it really gone? +

No — under a retention policy, items "deleted" by you are kept in a hidden recoverable-items area until the 7-year mark. After 7 years they're hard-deleted.

+
+ +
+ How do labels affect Teams chat? +

Files shared in Teams chat live in OneDrive and inherit the same DLP rules as direct OneDrive shares. So a Confidential file dropped into a Teams chat with an external guest will be blocked the same way as a direct external share.

+
+ +
+ When I create a new Team, Group or SharePoint site, I'm asked to pick a sensitivity. Which one? +

You'll see three choices — General, Confidential — All Employees, and Highly Confidential — All Employees. Pick the one that matches the most sensitive content you expect to keep in that workspace. Most everyday team sites are General. Use Confidential — All Employees for sites with employee or customer PII, financials, or contracts. Use Highly Confidential — All Employees for source code, M&A material, or anything regulated. You can change it later if you guess wrong.

+
+ +
+ Who can I ask if a file should be labelled higher or lower? +

Your manager or the helpdesk. As a rule of thumb: if it contains employee or customer PII, financials, source code, contracts, or anything regulated — use Confidential or higher.

+
+ +
+ I opened a Highly Confidential file at the weekend and now it won't open. Why? +

Encrypted files re-check your access every 30 days. If you're offline for longer than that, the file locks until you reconnect. Sign in once on a connected device and you're back in.

+
+ +
+ What's NOT covered in this guide? +

This guide describes only what the Microsoft Purview baseline deploys. Your IT team may layer additional controls (Conditional Access, device compliance, MFA, mailbox auto-forward blocks, external-link expiry, etc.). If you encounter behaviour that's not described here, it's likely one of those — start with the helpdesk.

+
+
+ + +
+

10 Learn more (Microsoft Learn)

+

Official Microsoft documentation if you want to dig deeper.

+ +
+ +
+ This guide reflects the Microsoft Purview baseline deployed by the SMBTool toolkit. · Your IT team may have customised some settings — if anything differs, the helpdesk is your source of truth. +
+ +
+ + diff --git a/Products/Purview/docs/End-User-Adoption-Guide.md b/Products/Purview/docs/End-User-Adoption-Guide.md index fc49083..c0ab2bc 100644 --- a/Products/Purview/docs/End-User-Adoption-Guide.md +++ b/Products/Purview/docs/End-User-Adoption-Guide.md @@ -1,349 +1,349 @@ ---- -title: End-User Adoption Guide -layout: default -parent: Purview -nav_order: 7 -permalink: /purview/end-user-adoption-guide/ ---- - -# End-User Adoption Guide - -> **Audience.** Employees of an organisation where IT is rolling out the -> SMBTool Microsoft Purview baseline. **No technical background required.** -> -> **For IT / delivery teams:** the script handles the engineering. This document -> is what you give your users **before** the rollout so the helpdesk -> doesn't get flooded. A reusable comms kit lives at the bottom. The -> deployment-side playbook is in -> [`Change-Management-Playbook.md`](Change-Management-Playbook.md). - -[Open the guide full-screen ↗]({{ '/Products/Purview/docs/End-User-Adoption-Guide.html' | relative_url }}){: .btn .btn-purple target="_blank" } -[Download the standalone file ↗]({{ '/Products/Purview/docs/End-User-Adoption-Guide.html' | relative_url }}){: .btn target="_blank" download } - -> 💡 The styled guide below is a **single self-contained HTML file** -> (`Products/Purview/docs/End-User-Adoption-Guide.html`). It needs no server, -> so you can send it to staff on its own, host it on an intranet page, or open -> it offline. Edit the HTML (or the plain-text version below) directly — -> there is no separate `.docx` copy to keep in sync. - - - ---- - -## 📋 Plain-text version (easy to copy-paste) -{: .no_toc } - -The same guidance in Markdown — handy for lifting the email templates and -pasting them into your own comms. - -## 🟢 TL;DR — what's changing in one paragraph - -Your organisation is turning on Microsoft's standard data-protection -features in Microsoft 365 — **sensitivity labels** in Word / Excel / -PowerPoint / Outlook, plus **Data Loss Prevention (DLP)** rules that -will eventually stop confidential files from being shared with people -outside your organisation. **For the first 30 days nothing will block -you** — IT is in monitoring mode while everyone gets used to the new -labels. After 30 days, sharing rules switch on. Optional add-ons may -also turn on: 7-year **email retention** and **Microsoft 365 Copilot** -restrictions on Highly Confidential files. - ---- - -## 📅 The rollout in three phases - -| Phase | When | What you'll notice | -|---|---|---| -| **1. Labels arrive** | Day-of rollout | A new **Sensitivity** button appears in Word / Excel / PowerPoint / Outlook. New emails default to *General*. New documents default to *Confidential — All Employees*. **Nothing is blocked.** | -| **2. Quiet monitoring** | Day 1–30 | IT collects telemetry on what *would* be blocked. **You will still not be blocked from anything.** No pop-ups, no policy tips. | -| **3. Enforcement** | Day 30+ | Sharing rules switch on (see below). You'll see policy tips in Outlook / SharePoint / OneDrive when an action is going to be blocked. | - ---- - -## 1️⃣ Sensitivity labels — what they look like - -A **Sensitivity** button (or menu) appears at the top of Word, Excel, -PowerPoint, and Outlook. The labels you can pick are: - -| Label | Use for | What it does | -|---|---|---| -| **Public** | Marketing material, public press releases | Nothing — informational only | -| **General — Anyone (unrestricted)** *(default for new emails)* | Routine internal email and documents that can also go to external partners | Nothing — informational only | -| **Confidential — All Employees** *(default for new documents)* | Internal-only contracts, plans, customer data | Adds a footer *"Classified as Confidential"* once visual marking is turned on. **Blocks external sharing after Day 30.** | -| **Highly Confidential — All Employees** | Source code, pre-announce financials, customer PII, payroll | **Encrypts the file** so it can only be opened by people in your organisation. Adds a *"HIGHLY CONFIDENTIAL"* watermark when visual marking is turned on. Blocks external sharing after Day 30. | - -> ℹ️ In the Sensitivity picker, **General** and **Confidential** appear as -> a category you expand to reach the actual label (e.g. *General > -> Anyone (unrestricted)*). This guide refers to them by their full name -> the first time and by the short family name (*General*, *Confidential*) -> afterwards. - -### What if I don't pick a label? - -* **New emails** auto-get *General — Anyone (unrestricted)* — fine for routine internal traffic. -* **New documents** auto-get *Confidential — All Employees* — safe default; - it's only a problem if you intended to share externally. In that case, - change the label to *General* or *Public* **before** sending. -* **Existing files / emails** are not relabelled retroactively. The - default only applies to new content. -* **Emailing a labelled attachment?** If the attachment's label is more - sensitive than your email's current label, Outlook **silently upgrades - the email to match** ("inherit label from attachments") — you don't need - to manually raise the email's label yourself. - -### What if I pick a lower label by mistake? - -Office will ask you to type a brief reason (a *"downgrade justification"*). -Just describe why — e.g. *"This document is being approved for external -release"*. The justification is logged for audit but doesn't go to anyone -for approval — you proceed straight away. - -> 📖 Microsoft Learn: [Apply sensitivity labels to your files and email](https://support.microsoft.com/office/2f96e7cd-d5a4-403b-8bd7-4cc636bae0f9) · [Sensitivity labels in Office apps](https://learn.microsoft.com/purview/sensitivity-labels-office-apps) - ---- - -## 2️⃣ Encryption — only on *Highly Confidential* - -If you pick **Highly Confidential — All Employees** on a document: - -* The file is encrypted. Anyone outside your organisation who somehow - receives it (forwarded email, USB stick, file leak) **cannot open it**. -* Inside your organisation, every employee can open, view, edit, save, - reply, reply-all, and forward. -* **Copy, print, and macros are allowed by default** for this label - (a wider "Co-Author" rights bundle so Office co-authoring and search - work cleanly) — this may differ for other encrypted labels your IT - team adds later, so check with the helpdesk if something you expect - to work is blocked on a different label. -* If you open the file **offline**, you have **30 days** before it - re-checks with the server. Open it once online inside 30 days to - reset the clock. - -> 📖 Microsoft Learn: [Restrict access to content with encryption](https://learn.microsoft.com/purview/encryption-sensitivity-labels) - ---- - -## 3️⃣ Data Loss Prevention (DLP) — what gets blocked after Day 30 - -Once IT promotes DLP out of monitoring mode, the following sharing -attempts will be **blocked**: - -| Workload | Action | Blocked? | -|---|---|---| -| **Outlook** | Send an email labelled *Confidential* or *Highly Confidential* to an external address | ✅ Blocked | -| **Outlook** | Send the same email to an internal colleague | ❌ Not blocked | -| **SharePoint / OneDrive** | Share a *Confidential* or *Highly Confidential* file with an external person (anonymous link, guest invite) | ✅ Blocked | -| **SharePoint / OneDrive** | Share the same file with an internal colleague | ❌ Not blocked | - -### What blocking looks like - -* **In Outlook**, you'll see a yellow banner under the To/Cc line - *before* you click Send: *"This message contains content that - conflicts with a policy in your organisation."* If you click Send - anyway it's stopped, and you (and the file owner, last modifier, and - site admin) get notified. -* **In SharePoint / OneDrive**, when you try to share, the share - dialog displays *"This file contains content that conflicts with a - policy in your organisation"* and the Share button is greyed out for - external recipients. - -### Can I override? - -In the default configuration there is **no self-service override** — to -unblock, contact the helpdesk and explain the business need. They can -adjust the rule or share the file through an approved channel. - -> 📖 Microsoft Learn: [DLP policy tips reference](https://learn.microsoft.com/purview/dlp-policy-tips-reference) · [Use notifications and policy tips](https://learn.microsoft.com/purview/use-notifications-and-policy-tips) - ---- - -## 4️⃣ Email retention *(only if your IT enabled it)* - -If IT enabled the optional 7-year retention policy: - -* Mail older than **7 years** is automatically deleted from your mailbox. -* This applies to **everything in your mailbox** — Inbox, Sent Items, - Deleted Items, and even mail you "delete" stays for 7 years before - hard-removal. -* You don't need to do anything. There is no toggle for you, no - warning at deletion time, and no "trash" to recover from after - 7 years. -* If you have records you must keep longer (e.g. partnership - agreements, IP-related correspondence) — **move the file** to a - SharePoint document library, save the email as a `.msg` or `.pdf` - somewhere outside your mailbox, or talk to IT about a longer - retention policy for your team. - -> 📖 Microsoft Learn: [Retention policies for Exchange](https://learn.microsoft.com/purview/retention-policies-exchange) - ---- - -## 5️⃣ Microsoft 365 Copilot *(only if your IT enabled the AI controls)* - -If IT enabled the AI governance policy: - -* **Microsoft 365 Copilot will ignore *Highly Confidential* files** when - answering your questions, summarising, or grounding responses. -* If you ask Copilot *"summarise our Q4 board pack"* and the board pack - is labelled Highly Confidential, Copilot will respond as if it doesn't - have access. This is by design. -* **Workaround:** label the file *Confidential — All Employees* if - Copilot grounding is needed and the content allows it. Otherwise - open the file yourself. - -> 📖 Microsoft Learn: [Microsoft Purview data security and compliance protections for Microsoft 365 Copilot](https://learn.microsoft.com/purview/ai-microsoft-purview) - ---- - -## ❓ Frequently asked questions - -**Q. Will my old documents and emails suddenly be labelled?** -No. Existing content is not relabelled automatically. Only new content -created after the rollout receives the default labels. You can label -old content manually if you choose. - -**Q. Will I be unable to send any external email?** -No. Only emails you have **explicitly labelled** *Confidential* or -*Highly Confidential* are blocked externally. Routine *General* email -flows normally. - -**Q. My document auto-got** *Confidential — All Employees* **but I need to -share it externally. What do I do?** -Open the document, click **Sensitivity → General** (or **Public**), -provide a brief justification when prompted, then share normally. - -**Q. Will labels and DLP work outside the office / on my home laptop?** -Yes — they are tied to your Microsoft 365 identity, not the network. - -**Q. Will labels work on my phone (Outlook mobile)?** -Yes — Outlook mobile supports applying and viewing sensitivity labels. - -**Q. Will a labelled file open on a non-Microsoft device (Mac, iPad, web)?** -Yes for Office apps (Word/Excel/PowerPoint/Outlook on Mac, iOS, Android, -and the web). Third-party apps that don't understand Microsoft -Information Protection may struggle with encrypted (Highly Confidential) -files. - -**Q. What happens if I email a labelled doc to a personal Gmail / Hotmail?** -After Day 30, blocked. During the first 30 days, allowed but logged. - -**Q. I deleted an email less than 7 years old — is it really gone?** -No — under a retention policy, items "deleted" by you are kept in a -hidden recoverable-items area until the 7-year mark. After 7 years, -they're hard-deleted. - -**Q. How do labels affect Teams chat?** -Files shared in Teams chat live in OneDrive and inherit the same DLP -rules as direct OneDrive shares. - -**Q. Who can I ask if a file should be labelled higher / lower?** -Your manager or the helpdesk. As a rule of thumb: if it contains -employee PII, customer PII, financials, source code, contracts, or -anything regulated — use **Confidential** or higher. - ---- - -## 🗨️ IT comms kit (templates) - -> Edit the `[bracketed]` placeholders. Send via email, Teams Announcements, -> intranet, or whatever channel reaches your users. These are the -> short-form versions — the -> [Change-Management-Playbook](Change-Management-Playbook.md) has the -> full delivery-detail equivalents. - -### Template A — 7 days before rollout - -> **Subject:** Coming next week — new Sensitivity button in Office, no -> action needed -> -> Hi team, -> -> Next [DATE], IT will turn on Microsoft Purview's data-protection -> features in our Microsoft 365 tenant. **You don't need to do -> anything to prepare** — but here's what to expect. -> -> **What you'll see on Day 1:** -> * A new **Sensitivity** button at the top of Word, Excel, PowerPoint, -> and Outlook. -> * New emails default to *General* (no protection — same as today). -> * New documents default to *Confidential — All Employees* (no -> visible change yet). -> * **Nothing will be blocked** during the first 30 days. -> -> **What changes after 30 days:** -> Documents and emails labelled *Confidential* or *Highly -> Confidential* won't be shareable with people outside our -> organisation. You'll see a banner before you click Send. -> -> **Want detail?** [Link to this End-User Adoption Guide]. -> -> Questions: [helpdesk channel]. - -### Template B — Day of rollout - -> **Subject:** Sensitivity labels are now live in Office -> -> Hi team, -> -> The new sensitivity labels are now available in Word, Excel, -> PowerPoint, and Outlook on your machine and on mobile. Look for the -> **Sensitivity** button. -> -> **For the next 30 days, nothing is being blocked** — you can send and -> share as usual while IT monitors. On [DATE + 30 DAYS] sharing rules -> for *Confidential* and *Highly Confidential* content switch on. -> -> Detail: [Link to this guide]. -> Questions: [helpdesk channel]. - -### Template C — Day 30, enforcement turning on - -> **Subject:** Sharing rules now active — heads-up on labelled content -> -> Hi team, -> -> The 30-day monitoring window has ended. From today, content labelled -> *Confidential* or *Highly Confidential* **cannot be sent or shared -> outside [Organisation]**: -> -> * Outlook will block external recipients. -> * SharePoint / OneDrive will block external shares. -> -> You'll see a banner in the app explaining the block when it happens. -> If you have a legitimate business need to share, contact -> [helpdesk channel] and we'll review. -> -> *General* and *Public* content is unaffected — work as normal. - ---- - -## 📎 References - -| Topic | Microsoft Learn | -|---|---| -| Sensitivity labels overview | https://learn.microsoft.com/purview/sensitivity-labels | -| Sensitivity labels in Office apps | https://learn.microsoft.com/purview/sensitivity-labels-office-apps | -| Apply a label (end-user how-to) | https://support.microsoft.com/office/2f96e7cd-d5a4-403b-8bd7-4cc636bae0f9 | -| Default sensitivity labels (the spec this toolkit is based on) | https://learn.microsoft.com/purview/default-sensitivity-labels-policies | -| Encryption with sensitivity labels | https://learn.microsoft.com/purview/encryption-sensitivity-labels | -| DLP policy tips reference | https://learn.microsoft.com/purview/dlp-policy-tips-reference | -| DLP notifications and policy tips | https://learn.microsoft.com/purview/use-notifications-and-policy-tips | -| Retention policies for Exchange | https://learn.microsoft.com/purview/retention-policies-exchange | -| Copilot data protection (Purview) | https://learn.microsoft.com/purview/ai-microsoft-purview | -| Sensitivity labels on mobile (Outlook iOS / Android) | https://learn.microsoft.com/purview/sensitivity-labels-aip | - ---- - -## What this guide does **not** cover - -This guide describes only what the SMBTool Purview baseline deploys. -Your IT team may layer additional controls on top (Conditional Access, -device compliance, MFA enforcement, mailbox auto-forward block, -external-link expiry, etc.). If you encounter behaviour that's not -described above, it's likely one of those additional controls — start -with the helpdesk. +--- +title: End-User Adoption Guide +layout: default +parent: Purview +nav_order: 7 +permalink: /purview/end-user-adoption-guide/ +--- + +# End-User Adoption Guide + +> **Audience.** Employees of an organisation where IT is rolling out the +> SMBTool Microsoft Purview baseline. **No technical background required.** +> +> **For IT / delivery teams:** the script handles the engineering. This document +> is what you give your users **before** the rollout so the helpdesk +> doesn't get flooded. A reusable comms kit lives at the bottom. The +> deployment-side playbook is in +> [`Change-Management-Playbook.md`](Change-Management-Playbook.md). + +[Open the guide full-screen ↗]({{ '/Products/Purview/docs/End-User-Adoption-Guide.html' | relative_url }}){: .btn .btn-purple target="_blank" } +[Download the standalone file ↗]({{ '/Products/Purview/docs/End-User-Adoption-Guide.html' | relative_url }}){: .btn target="_blank" download } + +> 💡 The styled guide below is a **single self-contained HTML file** +> (`Products/Purview/docs/End-User-Adoption-Guide.html`). It needs no server, +> so you can send it to staff on its own, host it on an intranet page, or open +> it offline. Edit the HTML (or the plain-text version below) directly — +> there is no separate `.docx` copy to keep in sync. + + + +--- + +## 📋 Plain-text version (easy to copy-paste) +{: .no_toc } + +The same guidance in Markdown — handy for lifting the email templates and +pasting them into your own comms. + +## 🟢 TL;DR — what's changing in one paragraph + +Your organisation is turning on Microsoft's standard data-protection +features in Microsoft 365 — **sensitivity labels** in Word / Excel / +PowerPoint / Outlook, plus **Data Loss Prevention (DLP)** rules that +will eventually stop confidential files from being shared with people +outside your organisation. **For the first 30 days nothing will block +you** — IT is in monitoring mode while everyone gets used to the new +labels. After 30 days, sharing rules switch on. Optional add-ons may +also turn on: 7-year **email retention** and **Microsoft 365 Copilot** +restrictions on Highly Confidential files. + +--- + +## 📅 The rollout in three phases + +| Phase | When | What you'll notice | +|---|---|---| +| **1. Labels arrive** | Day-of rollout | A new **Sensitivity** button appears in Word / Excel / PowerPoint / Outlook. New emails default to *General*. New documents default to *Confidential — All Employees*. **Nothing is blocked.** | +| **2. Quiet monitoring** | Day 1–30 | IT collects telemetry on what *would* be blocked. **You will still not be blocked from anything.** No pop-ups, no policy tips. | +| **3. Enforcement** | Day 30+ | Sharing rules switch on (see below). You'll see policy tips in Outlook / SharePoint / OneDrive when an action is going to be blocked. | + +--- + +## 1️⃣ Sensitivity labels — what they look like + +A **Sensitivity** button (or menu) appears at the top of Word, Excel, +PowerPoint, and Outlook. The labels you can pick are: + +| Label | Use for | What it does | +|---|---|---| +| **Public** | Marketing material, public press releases | Nothing — informational only | +| **General — Anyone (unrestricted)** *(default for new emails)* | Routine internal email and documents that can also go to external partners | Nothing — informational only | +| **Confidential — All Employees** *(default for new documents)* | Internal-only contracts, plans, customer data | Adds a footer *"Classified as Confidential"* once visual marking is turned on. **Blocks external sharing after Day 30.** | +| **Highly Confidential — All Employees** | Source code, pre-announce financials, customer PII, payroll | **Encrypts the file** so it can only be opened by people in your organisation. Adds a *"HIGHLY CONFIDENTIAL"* watermark when visual marking is turned on. Blocks external sharing after Day 30. | + +> ℹ️ In the Sensitivity picker, **General** and **Confidential** appear as +> a category you expand to reach the actual label (e.g. *General > +> Anyone (unrestricted)*). This guide refers to them by their full name +> the first time and by the short family name (*General*, *Confidential*) +> afterwards. + +### What if I don't pick a label? + +* **New emails** auto-get *General — Anyone (unrestricted)* — fine for routine internal traffic. +* **New documents** auto-get *Confidential — All Employees* — safe default; + it's only a problem if you intended to share externally. In that case, + change the label to *General* or *Public* **before** sending. +* **Existing files / emails** are not relabelled retroactively. The + default only applies to new content. +* **Emailing a labelled attachment?** If the attachment's label is more + sensitive than your email's current label, Outlook **silently upgrades + the email to match** ("inherit label from attachments") — you don't need + to manually raise the email's label yourself. + +### What if I pick a lower label by mistake? + +Office will ask you to type a brief reason (a *"downgrade justification"*). +Just describe why — e.g. *"This document is being approved for external +release"*. The justification is logged for audit but doesn't go to anyone +for approval — you proceed straight away. + +> 📖 Microsoft Learn: [Apply sensitivity labels to your files and email](https://support.microsoft.com/office/2f96e7cd-d5a4-403b-8bd7-4cc636bae0f9) · [Sensitivity labels in Office apps](https://learn.microsoft.com/purview/sensitivity-labels-office-apps) + +--- + +## 2️⃣ Encryption — only on *Highly Confidential* + +If you pick **Highly Confidential — All Employees** on a document: + +* The file is encrypted. Anyone outside your organisation who somehow + receives it (forwarded email, USB stick, file leak) **cannot open it**. +* Inside your organisation, every employee can open, view, edit, save, + reply, reply-all, and forward. +* **Copy, print, and macros are allowed by default** for this label + (a wider "Co-Author" rights bundle so Office co-authoring and search + work cleanly) — this may differ for other encrypted labels your IT + team adds later, so check with the helpdesk if something you expect + to work is blocked on a different label. +* If you open the file **offline**, you have **30 days** before it + re-checks with the server. Open it once online inside 30 days to + reset the clock. + +> 📖 Microsoft Learn: [Restrict access to content with encryption](https://learn.microsoft.com/purview/encryption-sensitivity-labels) + +--- + +## 3️⃣ Data Loss Prevention (DLP) — what gets blocked after Day 30 + +Once IT promotes DLP out of monitoring mode, the following sharing +attempts will be **blocked**: + +| Workload | Action | Blocked? | +|---|---|---| +| **Outlook** | Send an email labelled *Confidential* or *Highly Confidential* to an external address | ✅ Blocked | +| **Outlook** | Send the same email to an internal colleague | ❌ Not blocked | +| **SharePoint / OneDrive** | Share a *Confidential* or *Highly Confidential* file with an external person (anonymous link, guest invite) | ✅ Blocked | +| **SharePoint / OneDrive** | Share the same file with an internal colleague | ❌ Not blocked | + +### What blocking looks like + +* **In Outlook**, you'll see a yellow banner under the To/Cc line + *before* you click Send: *"This message contains content that + conflicts with a policy in your organisation."* If you click Send + anyway it's stopped, and you (and the file owner, last modifier, and + site admin) get notified. +* **In SharePoint / OneDrive**, when you try to share, the share + dialog displays *"This file contains content that conflicts with a + policy in your organisation"* and the Share button is greyed out for + external recipients. + +### Can I override? + +In the default configuration there is **no self-service override** — to +unblock, contact the helpdesk and explain the business need. They can +adjust the rule or share the file through an approved channel. + +> 📖 Microsoft Learn: [DLP policy tips reference](https://learn.microsoft.com/purview/dlp-policy-tips-reference) · [Use notifications and policy tips](https://learn.microsoft.com/purview/use-notifications-and-policy-tips) + +--- + +## 4️⃣ Email retention *(only if your IT enabled it)* + +If IT enabled the optional 7-year retention policy: + +* Mail older than **7 years** is automatically deleted from your mailbox. +* This applies to **everything in your mailbox** — Inbox, Sent Items, + Deleted Items, and even mail you "delete" stays for 7 years before + hard-removal. +* You don't need to do anything. There is no toggle for you, no + warning at deletion time, and no "trash" to recover from after + 7 years. +* If you have records you must keep longer (e.g. partnership + agreements, IP-related correspondence) — **move the file** to a + SharePoint document library, save the email as a `.msg` or `.pdf` + somewhere outside your mailbox, or talk to IT about a longer + retention policy for your team. + +> 📖 Microsoft Learn: [Retention policies for Exchange](https://learn.microsoft.com/purview/retention-policies-exchange) + +--- + +## 5️⃣ Microsoft 365 Copilot *(only if your IT enabled the AI controls)* + +If IT enabled the AI governance policy: + +* **Microsoft 365 Copilot will ignore *Highly Confidential* files** when + answering your questions, summarising, or grounding responses. +* If you ask Copilot *"summarise our Q4 board pack"* and the board pack + is labelled Highly Confidential, Copilot will respond as if it doesn't + have access. This is by design. +* **Workaround:** label the file *Confidential — All Employees* if + Copilot grounding is needed and the content allows it. Otherwise + open the file yourself. + +> 📖 Microsoft Learn: [Microsoft Purview data security and compliance protections for Microsoft 365 Copilot](https://learn.microsoft.com/purview/ai-microsoft-purview) + +--- + +## ❓ Frequently asked questions + +**Q. Will my old documents and emails suddenly be labelled?** +No. Existing content is not relabelled automatically. Only new content +created after the rollout receives the default labels. You can label +old content manually if you choose. + +**Q. Will I be unable to send any external email?** +No. Only emails you have **explicitly labelled** *Confidential* or +*Highly Confidential* are blocked externally. Routine *General* email +flows normally. + +**Q. My document auto-got** *Confidential — All Employees* **but I need to +share it externally. What do I do?** +Open the document, click **Sensitivity → General** (or **Public**), +provide a brief justification when prompted, then share normally. + +**Q. Will labels and DLP work outside the office / on my home laptop?** +Yes — they are tied to your Microsoft 365 identity, not the network. + +**Q. Will labels work on my phone (Outlook mobile)?** +Yes — Outlook mobile supports applying and viewing sensitivity labels. + +**Q. Will a labelled file open on a non-Microsoft device (Mac, iPad, web)?** +Yes for Office apps (Word/Excel/PowerPoint/Outlook on Mac, iOS, Android, +and the web). Third-party apps that don't understand Microsoft +Information Protection may struggle with encrypted (Highly Confidential) +files. + +**Q. What happens if I email a labelled doc to a personal Gmail / Hotmail?** +After Day 30, blocked. During the first 30 days, allowed but logged. + +**Q. I deleted an email less than 7 years old — is it really gone?** +No — under a retention policy, items "deleted" by you are kept in a +hidden recoverable-items area until the 7-year mark. After 7 years, +they're hard-deleted. + +**Q. How do labels affect Teams chat?** +Files shared in Teams chat live in OneDrive and inherit the same DLP +rules as direct OneDrive shares. + +**Q. Who can I ask if a file should be labelled higher / lower?** +Your manager or the helpdesk. As a rule of thumb: if it contains +employee PII, customer PII, financials, source code, contracts, or +anything regulated — use **Confidential** or higher. + +--- + +## 🗨️ IT comms kit (templates) + +> Edit the `[bracketed]` placeholders. Send via email, Teams Announcements, +> intranet, or whatever channel reaches your users. These are the +> short-form versions — the +> [Change-Management-Playbook](Change-Management-Playbook.md) has the +> full delivery-detail equivalents. + +### Template A — 7 days before rollout + +> **Subject:** Coming next week — new Sensitivity button in Office, no +> action needed +> +> Hi team, +> +> Next [DATE], IT will turn on Microsoft Purview's data-protection +> features in our Microsoft 365 tenant. **You don't need to do +> anything to prepare** — but here's what to expect. +> +> **What you'll see on Day 1:** +> * A new **Sensitivity** button at the top of Word, Excel, PowerPoint, +> and Outlook. +> * New emails default to *General* (no protection — same as today). +> * New documents default to *Confidential — All Employees* (no +> visible change yet). +> * **Nothing will be blocked** during the first 30 days. +> +> **What changes after 30 days:** +> Documents and emails labelled *Confidential* or *Highly +> Confidential* won't be shareable with people outside our +> organisation. You'll see a banner before you click Send. +> +> **Want detail?** [Link to this End-User Adoption Guide]. +> +> Questions: [helpdesk channel]. + +### Template B — Day of rollout + +> **Subject:** Sensitivity labels are now live in Office +> +> Hi team, +> +> The new sensitivity labels are now available in Word, Excel, +> PowerPoint, and Outlook on your machine and on mobile. Look for the +> **Sensitivity** button. +> +> **For the next 30 days, nothing is being blocked** — you can send and +> share as usual while IT monitors. On [DATE + 30 DAYS] sharing rules +> for *Confidential* and *Highly Confidential* content switch on. +> +> Detail: [Link to this guide]. +> Questions: [helpdesk channel]. + +### Template C — Day 30, enforcement turning on + +> **Subject:** Sharing rules now active — heads-up on labelled content +> +> Hi team, +> +> The 30-day monitoring window has ended. From today, content labelled +> *Confidential* or *Highly Confidential* **cannot be sent or shared +> outside [Organisation]**: +> +> * Outlook will block external recipients. +> * SharePoint / OneDrive will block external shares. +> +> You'll see a banner in the app explaining the block when it happens. +> If you have a legitimate business need to share, contact +> [helpdesk channel] and we'll review. +> +> *General* and *Public* content is unaffected — work as normal. + +--- + +## 📎 References + +| Topic | Microsoft Learn | +|---|---| +| Sensitivity labels overview | https://learn.microsoft.com/purview/sensitivity-labels | +| Sensitivity labels in Office apps | https://learn.microsoft.com/purview/sensitivity-labels-office-apps | +| Apply a label (end-user how-to) | https://support.microsoft.com/office/2f96e7cd-d5a4-403b-8bd7-4cc636bae0f9 | +| Default sensitivity labels (the spec this toolkit is based on) | https://learn.microsoft.com/purview/default-sensitivity-labels-policies | +| Encryption with sensitivity labels | https://learn.microsoft.com/purview/encryption-sensitivity-labels | +| DLP policy tips reference | https://learn.microsoft.com/purview/dlp-policy-tips-reference | +| DLP notifications and policy tips | https://learn.microsoft.com/purview/use-notifications-and-policy-tips | +| Retention policies for Exchange | https://learn.microsoft.com/purview/retention-policies-exchange | +| Copilot data protection (Purview) | https://learn.microsoft.com/purview/ai-microsoft-purview | +| Sensitivity labels on mobile (Outlook iOS / Android) | https://learn.microsoft.com/purview/sensitivity-labels-aip | + +--- + +## What this guide does **not** cover + +This guide describes only what the SMBTool Purview baseline deploys. +Your IT team may layer additional controls on top (Conditional Access, +device compliance, MFA enforcement, mailbox auto-forward block, +external-link expiry, etc.). If you encounter behaviour that's not +described above, it's likely one of those additional controls — start +with the helpdesk. diff --git a/Products/Purview/docs/Retention-Default-Risk.md b/Products/Purview/docs/Retention-Default-Risk.md index 2f7826b..2548c23 100644 --- a/Products/Purview/docs/Retention-Default-Risk.md +++ b/Products/Purview/docs/Retention-Default-Risk.md @@ -1,217 +1,217 @@ ---- -title: Retention Default — Risk Note -layout: default -parent: Purview -nav_order: 5 -permalink: /purview/retention-default-risk/ ---- - -# Retention Default — Risk Note - -> **Read this before passing `-ApplyRetention`.** Retention is opt-in -> as of the current toolkit version. The shipped default retains -> Exchange mail for 7 years then deletes it. That duration aligns with -> most common SMB regulatory frameworks but is still a **tenant-wide -> destructive policy** — you should consciously confirm it's the right -> number for the customer before enabling. - ---- - -This page answers a simple question: **the default retention duration -shipped in `PurviewConfig.psd1` is 7 years — when is that right, when -is it wrong, and what should you do if it's wrong?** - -Retention is **opt-in** as of the current toolkit version (you must -pass `-ApplyRetention` to enable it). This page exists so that when you -decide to opt in, you also confirm the duration is right for the -customer's vertical. - ---- - -## What the toolkit does when you opt in - -`PurviewConfig.psd1` ships with: - -```powershell -Retention = @{ - Name = 'SMBTool - Exchange 7-year retention' - DurationDays = 2555 # 7 years - Action = 'KeepAndDelete' - Locations = @('Exchange') -} -``` - -This creates a tenant-wide retention policy that: - -* Applies to **every mailbox** in the tenant. -* **Deletes** any item once it is 7 years old (measured from item creation). -* Is **irreversible for non-litigation-hold mailboxes** — deleted mail - cannot be recovered after the retention deletion sweep runs. -* Does not affect SharePoint / OneDrive (those locations are not in the - default scope, but you can add them). - ---- - -## Why 7 years is a reasonable default - -Seven years aligns with the record-keeping floor for most common SMB -verticals the toolkit serves: - -| Vertical | Typical regulatory retention requirement | Citation / source of obligation | -|---|---|---| -| **Law firms** | 7 years (and the file's life span where longer) | State bar rules of professional conduct (e.g. ABA Model Rule 1.15 in the US — varies by jurisdiction) | -| **Accounting / tax** | 5–7 years (often + indefinite for audit working papers) | Local tax authority rules (e.g. ATO 5 years AU; IRS 3–7 years US) | -| **Financial advisors / brokers** | 5–7 years | SEC Rule 17a-4 (US); ASIC RG 78 (AU); Sarbanes-Oxley 7 years | -| **General SMB / professional services** | No formal requirement; 7 years matches the broader regulatory floor and is a safe ceiling | — | - -The default was raised from 2 years to 7 years (see issue #8) to align -with this regulatory floor. For customers in these verticals you can -keep the default unchanged. - ---- - -## When 7 years is still wrong - -The default is **not universally right**. Confirm it against the -customer before deploying: - -| Scenario | Why 7 years is wrong | What to do | -|---|---|---| -| **Paediatric healthcare records** | Many jurisdictions require records to be kept until the patient reaches age of majority + N years — often 18-25+ years total | Edit `DurationDays` to a much longer value, or use `Action = 'Keep'` (no automatic deletion) | -| **Construction / engineering with statute-of-repose exposure** | Statute-of-repose periods on workmanship can run 10–15 years post-completion | Edit `DurationDays` to match the longest applicable statute-of-repose | -| **Customer wants no automatic deletion at all** | Some customers want a retention guarantee without a deletion event — e.g. ongoing litigation hold expected, family-business archives, professional advisors who keep client records indefinitely | Set `Action = 'Keep'` (retains for the duration, never deletes) or skip retention entirely (omit `-ApplyRetention`) | -| **Tenant has mail older than 7 years that the customer wants to keep** | Deploying the policy starts deleting that mail immediately | Place affected mailboxes on Litigation Hold first, or skip retention until the customer has reviewed the old mail | -| **Jurisdiction-specific shorter requirement (e.g. some EU GDPR scenarios)** | GDPR data minimisation principles may require *shorter* retention for certain personal-data categories | Edit `DurationDays` lower; consider per-data-category retention labels instead of a tenant-wide policy | - ---- - -## What "lost mail" looks like in practice - -Six weeks after a default deploy onto a tenant that already had mail -older than 7 years, the helpdesk gets a ticket like: - -> "I'm trying to find the email from [client] from 2017 about [matter]. -> It's not in Outlook search, not in Deleted Items, not in Recoverable -> Items. Where is it?" - -The answer, if the customer hadn't been told, is "the retention policy -deleted it three weeks ago." There is no recovery short of a Microsoft -support ticket for a backup restore from before the deletion sweep — and -those windows are short (~14 days). - -For a regulated customer this can be a **professional liability incident** -even if the deletion was technically compliant — the customer's expectation -matters, not just the regulator's floor. - ---- - -## What to do instead - -Pick one of the following before deploy: - -### Option A — Match the vertical's longer requirement - -If 7 years is too short for the customer (paediatric healthcare, -long-life construction warranties, etc.), edit `PurviewConfig.psd1`: - -```powershell -Retention = @{ - Name = 'SMBTool - Exchange 15-year retention (construction)' - Comment = 'Retains Exchange mailbox content for 15 years from creation, then deletes. Aligned to statute-of-repose period.' - DurationDays = 5475 # 15 years - Action = 'KeepAndDelete' - Locations = @('Exchange') -} -``` - -For SharePoint / OneDrive, add them to `Locations`: - -```powershell - Locations = @('Exchange', 'SharePoint', 'OneDrive') -``` - -### Option B — `Keep` instead of `KeepAndDelete` - -Hold mail for the duration without deleting at the end. Useful when the -customer wants a litigation-hold-style policy without a fixed deletion -date: - -```powershell - Action = 'Keep' -``` - -This stops nothing being deleted by *this* policy. (Users can still -delete their own mail; the policy retains a copy in Recoverable Items -for the duration.) - -### Option C — Don't enable retention yet (default behaviour) - -Retention is **opt-in** — pass `-ApplyRetention` to enable. Run the -script without it, work the duration decision with the customer -separately, then re-run **only** the retention module once the duration -is agreed: - -```powershell -.\Deploy-PurviewBestPractice.ps1 ` - -TenantAdminUpn admin@contoso.onmicrosoft.com -# (retention is skipped — banner shows "[ ] Retention" and the summary reports -# "Skipped (opt-in — pass -ApplyRetention to enable; see docs/Retention-Default-Risk.md)") - -# ...have the retention conversation, edit PurviewConfig.psd1 if needed... - -# Then run the retention step on its own: -.\Modules\Setup-Retention.ps1 -Config (Import-PowerShellDataFile .\Config\PurviewConfig.psd1) - -# Or re-run the orchestrator with everything else skipped: -.\Deploy-PurviewBestPractice.ps1 ` - -TenantAdminUpn admin@contoso.onmicrosoft.com ` - -SkipTenantSettings -SkipLabels -SkipDLP ` - -ApplyRetention -``` - ---- - -## Why the toolkit doesn't ship per-vertical presets (yet) - -Per-vertical presets (law / accounting / healthcare / construction / -generic) are a planned future enhancement — see the repo issues list. -Until they ship, **whoever runs the deployment owns the duration decision** even though -the default now aligns with the most common SMB floor. Document the -chosen duration on the customer record before deploy, and revisit on -every renewal. - ---- - -## What about existing customer mail older than the retention duration? - -Deploying the 7-year retention policy onto a tenant whose oldest mail -is 10 years old will, over the following days, **start deleting all -mail between 7 and 10 years old**. - -The deletion is **not instantaneous** — the service-side retention -sweep runs continuously and works through old mail over hours / days. -The window between deploy and "mail starts disappearing" is short -enough that customers will not notice in time to stop it. - -Mitigations: - -1. **Don't enable retention yet** (the default — just omit `-ApplyRetention`) - when the tenant has mail older than the planned retention duration - that the customer wants to keep. -2. **Place affected mailboxes on Litigation Hold** before the policy - takes effect — Litigation Hold supersedes retention deletion. -3. **Roll out to a single pilot mailbox first** by editing the policy - scope (this requires a manual portal change post-deploy; the - toolkit's default scope is tenant-wide). - ---- - -## Related reading - -* [Change-Management-Playbook.md](Change-Management-Playbook.md) — Phase 0 - decisions include retention duration and Phase 3 includes a - "reconfirm retention" gate. -* [Scenarios.md](Scenarios.md) — Scenario 4 documents the retention - module's defaults and tunables. -* Microsoft Learn — [Retention policies and retention labels](https://learn.microsoft.com/en-us/purview/retention). -* Microsoft Learn — [Litigation Hold](https://learn.microsoft.com/en-us/purview/ediscovery-create-a-litigation-hold). +--- +title: Retention Default — Risk Note +layout: default +parent: Purview +nav_order: 5 +permalink: /purview/retention-default-risk/ +--- + +# Retention Default — Risk Note + +> **Read this before passing `-ApplyRetention`.** Retention is opt-in +> as of the current toolkit version. The shipped default retains +> Exchange mail for 7 years then deletes it. That duration aligns with +> most common SMB regulatory frameworks but is still a **tenant-wide +> destructive policy** — you should consciously confirm it's the right +> number for the customer before enabling. + +--- + +This page answers a simple question: **the default retention duration +shipped in `PurviewConfig.psd1` is 7 years — when is that right, when +is it wrong, and what should you do if it's wrong?** + +Retention is **opt-in** as of the current toolkit version (you must +pass `-ApplyRetention` to enable it). This page exists so that when you +decide to opt in, you also confirm the duration is right for the +customer's vertical. + +--- + +## What the toolkit does when you opt in + +`PurviewConfig.psd1` ships with: + +```powershell +Retention = @{ + Name = 'SMBTool - Exchange 7-year retention' + DurationDays = 2555 # 7 years + Action = 'KeepAndDelete' + Locations = @('Exchange') +} +``` + +This creates a tenant-wide retention policy that: + +* Applies to **every mailbox** in the tenant. +* **Deletes** any item once it is 7 years old (measured from item creation). +* Is **irreversible for non-litigation-hold mailboxes** — deleted mail + cannot be recovered after the retention deletion sweep runs. +* Does not affect SharePoint / OneDrive (those locations are not in the + default scope, but you can add them). + +--- + +## Why 7 years is a reasonable default + +Seven years aligns with the record-keeping floor for most common SMB +verticals the toolkit serves: + +| Vertical | Typical regulatory retention requirement | Citation / source of obligation | +|---|---|---| +| **Law firms** | 7 years (and the file's life span where longer) | State bar rules of professional conduct (e.g. ABA Model Rule 1.15 in the US — varies by jurisdiction) | +| **Accounting / tax** | 5–7 years (often + indefinite for audit working papers) | Local tax authority rules (e.g. ATO 5 years AU; IRS 3–7 years US) | +| **Financial advisors / brokers** | 5–7 years | SEC Rule 17a-4 (US); ASIC RG 78 (AU); Sarbanes-Oxley 7 years | +| **General SMB / professional services** | No formal requirement; 7 years matches the broader regulatory floor and is a safe ceiling | — | + +The default was raised from 2 years to 7 years to align +with this regulatory floor. For customers in these verticals you can +keep the default unchanged. + +--- + +## When 7 years is still wrong + +The default is **not universally right**. Confirm it against the +customer before deploying: + +| Scenario | Why 7 years is wrong | What to do | +|---|---|---| +| **Paediatric healthcare records** | Many jurisdictions require records to be kept until the patient reaches age of majority + N years — often 18-25+ years total | Edit `DurationDays` to a much longer value, or use `Action = 'Keep'` (no automatic deletion) | +| **Construction / engineering with statute-of-repose exposure** | Statute-of-repose periods on workmanship can run 10–15 years post-completion | Edit `DurationDays` to match the longest applicable statute-of-repose | +| **Customer wants no automatic deletion at all** | Some customers want a retention guarantee without a deletion event — e.g. ongoing litigation hold expected, family-business archives, professional advisors who keep client records indefinitely | Set `Action = 'Keep'` (retains for the duration, never deletes) or skip retention entirely (omit `-ApplyRetention`) | +| **Tenant has mail older than 7 years that the customer wants to keep** | Deploying the policy starts deleting that mail immediately | Place affected mailboxes on Litigation Hold first, or skip retention until the customer has reviewed the old mail | +| **Jurisdiction-specific shorter requirement (e.g. some EU GDPR scenarios)** | GDPR data minimisation principles may require *shorter* retention for certain personal-data categories | Edit `DurationDays` lower; consider per-data-category retention labels instead of a tenant-wide policy | + +--- + +## What "lost mail" looks like in practice + +Six weeks after a default deploy onto a tenant that already had mail +older than 7 years, the helpdesk gets a ticket like: + +> "I'm trying to find the email from [client] from 2017 about [matter]. +> It's not in Outlook search, not in Deleted Items, not in Recoverable +> Items. Where is it?" + +The answer, if the customer hadn't been told, is "the retention policy +deleted it three weeks ago." There is no recovery short of a Microsoft +support ticket for a backup restore from before the deletion sweep — and +those windows are short (~14 days). + +For a regulated customer this can be a **professional liability incident** +even if the deletion was technically compliant — the customer's expectation +matters, not just the regulator's floor. + +--- + +## What to do instead + +Pick one of the following before deploy: + +### Option A — Match the vertical's longer requirement + +If 7 years is too short for the customer (paediatric healthcare, +long-life construction warranties, etc.), edit `PurviewConfig.psd1`: + +```powershell +Retention = @{ + Name = 'SMBTool - Exchange 15-year retention (construction)' + Comment = 'Retains Exchange mailbox content for 15 years from creation, then deletes. Aligned to statute-of-repose period.' + DurationDays = 5475 # 15 years + Action = 'KeepAndDelete' + Locations = @('Exchange') +} +``` + +For SharePoint / OneDrive, add them to `Locations`: + +```powershell + Locations = @('Exchange', 'SharePoint', 'OneDrive') +``` + +### Option B — `Keep` instead of `KeepAndDelete` + +Hold mail for the duration without deleting at the end. Useful when the +customer wants a litigation-hold-style policy without a fixed deletion +date: + +```powershell + Action = 'Keep' +``` + +This stops nothing being deleted by *this* policy. (Users can still +delete their own mail; the policy retains a copy in Recoverable Items +for the duration.) + +### Option C — Don't enable retention yet (default behaviour) + +Retention is **opt-in** — pass `-ApplyRetention` to enable. Run the +script without it, work the duration decision with the customer +separately, then re-run **only** the retention module once the duration +is agreed: + +```powershell +.\Deploy-PurviewBestPractice.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com +# (retention is skipped — banner shows "[ ] Retention" and the summary reports +# "Skipped (opt-in — pass -ApplyRetention to enable; see docs/Retention-Default-Risk.md)") + +# ...have the retention conversation, edit PurviewConfig.psd1 if needed... + +# Then run the retention step on its own: +.\Modules\Setup-Retention.ps1 -Config (Import-PowerShellDataFile .\Config\PurviewConfig.psd1) + +# Or re-run the orchestrator with everything else skipped: +.\Deploy-PurviewBestPractice.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com ` + -SkipTenantSettings -SkipLabels -SkipDLP ` + -ApplyRetention +``` + +--- + +## Why the toolkit doesn't ship per-vertical presets (yet) + +Per-vertical presets (law / accounting / healthcare / construction / +generic) are a planned future enhancement — see the repo issues list. +Until they ship, **whoever runs the deployment owns the duration decision** even though +the default now aligns with the most common SMB floor. Document the +chosen duration on the customer record before deploy, and revisit on +every renewal. + +--- + +## What about existing customer mail older than the retention duration? + +Deploying the 7-year retention policy onto a tenant whose oldest mail +is 10 years old will, over the following days, **start deleting all +mail between 7 and 10 years old**. + +The deletion is **not instantaneous** — the service-side retention +sweep runs continuously and works through old mail over hours / days. +The window between deploy and "mail starts disappearing" is short +enough that customers will not notice in time to stop it. + +Mitigations: + +1. **Don't enable retention yet** (the default — just omit `-ApplyRetention`) + when the tenant has mail older than the planned retention duration + that the customer wants to keep. +2. **Place affected mailboxes on Litigation Hold** before the policy + takes effect — Litigation Hold supersedes retention deletion. +3. **Roll out to a single pilot mailbox first** by editing the policy + scope (this requires a manual portal change post-deploy; the + toolkit's default scope is tenant-wide). + +--- + +## Related reading + +* [Change-Management-Playbook.md](Change-Management-Playbook.md) — Phase 0 + decisions include retention duration and Phase 3 includes a + "reconfirm retention" gate. +* [Scenarios.md](Scenarios.md) — Scenario 4 documents the retention + module's defaults and tunables. +* Microsoft Learn — [Retention policies and retention labels](https://learn.microsoft.com/en-us/purview/retention). +* Microsoft Learn — [Litigation Hold](https://learn.microsoft.com/en-us/purview/ediscovery-create-a-litigation-hold). diff --git a/Products/Purview/docs/Scenarios.md b/Products/Purview/docs/Scenarios.md index 642aef5..3b991da 100644 --- a/Products/Purview/docs/Scenarios.md +++ b/Products/Purview/docs/Scenarios.md @@ -1,412 +1,423 @@ ---- -title: Scenarios & Capabilities -layout: default -parent: Purview -nav_order: 2 -permalink: /purview/scenarios/ ---- - -# What this toolkit does — Scenarios & Capabilities - -> 🧰 **On the support team / service desk?** For the fast, visual -> "what's changing & what's the impact" view, start with -> [**What's Changing — Support Team Guide**](whats-changing/). This page is -> the deeper technical reference. - -> **Audience.** Anyone evaluating, running, or reviewing the SMBTool Purview -> deployment script — IT admins, security leads, deployment consultants, and reviewers. -> No PowerShell expertise required to read this page; the technical -> details live behind expandable sections. - -This page answers three questions: - -1. **What scenarios does the script cover when I run it?** -2. **What changes if my customer's licensing changes?** -3. **What are the limits — what does it deliberately *not* do?** - -For step-by-step run instructions, see [`README.md`](../README.md). For the -list of every value the script applies, see -[`Config/PurviewConfig.psd1`](../Config/PurviewConfig.psd1). For the -non-technical companion docs, see: - -* [`Change-Management-Playbook.md`](Change-Management-Playbook.md) — pre-deploy / day-of / day 1–5 / day-30 promote-from-simulation gate -* [`End-User-Adoption-Guide.md`](End-User-Adoption-Guide.md) — plain-language guide to send to employees before rollout, with email templates (T-7 / T-0 / T+30) -* [`Retention-Default-Risk.md`](Retention-Default-Risk.md) — when the 7-year default is right, when it isn't, and how to override -* [`DLP-Simulation-Exit-Runbook.md`](DLP-Simulation-Exit-Runbook.md) — how to pull the "what would have leaked" report from Activity Explorer before promoting DLP out of simulation - ---- - -## TL;DR - -The script applies Microsoft's recommended **Data Security baseline** to a -Microsoft 365 tenant — designed for **Business Premium** and gracefully -expanding when **E5 / Purview Suite** licensing is present. It covers -**five scenarios**, all idempotent (safe to re-run) and DLP starts in -**simulation mode** by default so nothing breaks on day one. - -| # | Scenario | Default behaviour | Customer impact | -|---|----------|-------------------|-----------------| -| 1 | **Foundational tenant settings** | Enables audit log, SharePoint label integration, PDF labels | Invisible to end users | -| 2 | **Sensitivity labels** | Creates 3 parents + 6 sub-labels with defaults (`General` is a label **group**); publishes 4; sets `General \ Anyone (unrestricted)` as default for email and `Confidential\All Employees` as default for documents | Users see new labels in Outlook/Office; new documents auto-get a footer | -| 3 | **Data Loss Prevention (DLP)** | Blocks external sharing of Confidential and Highly Confidential content from Exchange and SharePoint/OneDrive (in **simulation** mode by default) | Telemetry only until the policy is promoted out of simulation | -| 4 | **Retention** | **Opt-in via `-ApplyRetention`.** Keeps Exchange mailbox content for 7 years, then deletes | Long-tail effect — mail older than 7 years starts to be removed | -| 5 | **AI governance (Copilot DLP)** | Opt-in — blocks Microsoft 365 Copilot from processing Highly Confidential content | Copilot ignores HC files when surfacing answers | - ---- - -## Scenario 1 — Foundational tenant settings - -**Module:** [`Setup-TenantSettings.ps1`](../Modules/Setup-TenantSettings.ps1) -**Always runs unless `-SkipTenantSettings` is passed.** - -These are the prerequisites every Purview feature depends on. Without them -labels can be applied but won't be enforced or visible everywhere. - -| Setting | What it enables | Default | -|---|---|---| -| **Unified Audit Log** | Every Purview / DLP / label action shows up in audit search and SIEM exports | ✅ on | -| **SharePoint AIP integration** | Sensitivity labels apply at the file level inside SharePoint and OneDrive (search, eDiscovery, DLP all see the label) | ✅ on | -| **Sensitivity labels for PDF** | New PDFs created or saved in SharePoint / OneDrive carry the parent doc's label | ✅ on | -| **Label co-authoring** | Tenant-wide switch that moves label metadata to the new embedded location so multiple users can edit a labelled / encrypted document at the same time in Office / Office Web. **One-way change** — disabling it later loses labels on unencrypted Office files; breaks third-party apps that read the old metadata location | Opt-in via `-EnableLabelCoAuthoring` | -| **Container labels** | Lets labels apply to Microsoft 365 Groups, Teams, and SharePoint sites (`Group.Unified` `EnableMIPLabels=True`) | Auto-on with Business Premium / E5 / Purview Suite (BP includes Entra ID P1+, which is the AAD-side requirement). Opt out with `-NoLicenseAutoDetect`. | -| **Premium audit** *(opt-in)* | Adds the rich `SearchQueryInitiated` event for the named mailbox(es); enables 1-year audit retention | Off — pass `-EnablePremiumAudit -PremiumAuditMailbox` | - -> **Licensing note.** All four are **Business Premium**. Container labels -> are also auto-enabled on Business Premium (BP includes Entra ID P1+, -> the AAD-side requirement) — premium audit still requires **E5 / Purview -> Suite**. Skip auto-enable with `-NoLicenseAutoDetect`. - ---- - -## Scenario 2 — Sensitivity labels - -**Module:** [`Setup-SensitivityLabels.ps1`](../Modules/Setup-SensitivityLabels.ps1) -**Always runs unless `-SkipLabels` is passed.** - -The toolkit creates an SMB-tuned subset of Microsoft's documented -[default sensitivity labels](https://learn.microsoft.com/en-us/purview/default-sensitivity-labels-policies), -identified and adopted by their stable Microsoft internal signature -(`defa4170-0d19-0005-NNNN-…`) so the same taxonomy lands correctly on any -tenant, region/locale, or label scheme (classic or modern): - -``` -Public -General ← label GROUP; default for EMAIL is its child below -├─ Anyone (unrestricted) ← default for EMAIL -└─ All Employees (unrestricted) -Confidential -├─ All Employees ← default for DOCUMENTS (footer only) -└─ Trusted People (footer + ENCRYPTED, UserDefined/Encrypt-Only — user picks who; recipients can reshare) -Highly Confidential (watermark "HIGHLY CONFIDENTIAL") -├─ All Employees (footer + ENCRYPTED, Co-Author rights, all users in your tenant only) -└─ Specific People (footer + ENCRYPTED, UserDefined/Do-Not-Forward — user picks who) -``` - -On **modern label scheme** tenants, `General`, `Confidential`, and `Highly -Confidential` are created as **label groups** (non-applicable containers); -their children carry the applicable content. On **classic scheme** tenants -they're created as regular parent labels. The toolkit detects the tenant's -scheme and branches automatically — no config change needed. - -### What gets published vs created - -| Created in tenant | Published to users by default | -|---|---| -| All 8 labels above | `Public`, `General \ Anyone (unrestricted)`, `Confidential\All Employees`, `Highly Confidential\All Employees` | - -The other sub-labels are created so DLP can match on them, but kept off the -client UI to reduce decision fatigue. Edit `LabelPolicy.PublishedLabels` -in [`PurviewConfig.psd1`](../Config/PurviewConfig.psd1) to surface more. -**Note:** `Confidential\TrustedPeople` and `Highly Confidential\Specific -People` apply UserDefined encryption — Word / Excel / PowerPoint prompt -the recipient list at apply time (Outlook auto-applies Encrypt-Only for -Trusted People, Do Not Forward for Specific People). Most end users do -not know how to respond to that dialog, so the default ships them -unpublished. Promote them only after user training. - -### Encryption — who can open the file? - -The **Template-encrypted Highly Confidential \ All Employees** sub-label -grants rights to **all users in your tenant only**. The rights bundle uses -the `{TenantDomain}` token, which the toolkit resolves at run time against -your auto-discovered tenant identity. Per Entra rights-management -semantics, ANY verified domain in your tenant expands to ALL verified -domains in your tenant, so this explicitly EXCLUDES external -`AuthenticatedUsers` (B2B guests attached to OTHER tenants, social/MSA -accounts, OTP users from other M365 tenants). - -The **two UserDefined sub-labels** (`Confidential\TrustedPeople`, -`Highly Confidential\Specific People`) let the message / file author pick -the exact recipients at apply time. Outlook auto-applies **Encrypt-Only** -for Trusted People (recipients can reshare) and **Do Not Forward** for -Specific People; Word / Excel / PowerPoint show a recipient-picker dialog -for both. - -| Label | Rights bundle | Office co-authoring (rights side) | Programmatic access | -|---|---|---|---| -| `Highly Confidential \ All Employees` (default) | Microsoft "Co-Author" — View, Edit, Save, Copy, Print, Allow Macros, Reply, Reply All, Forward | ✅ | ✅ | -| Any other Template-encrypted label (no per-label override) | Inherits global default (Reviewer) | ❌ | ❌ | - -The toolkit's global default is the conservative "Reviewer" bundle -because OBJMODEL access (granted by "Co-Author") is the right that most -often breaks third-party apps reading Office doc metadata. To override -the default for a specific label, add an -`EncryptionRightsDefinitions = '...'` field on that label's hashtable in -`PurviewConfig.psd1`. To change the global default for all -Template-encrypted labels at once, edit the top-level -`EncryptionRightsDefinitions` value. - -To intentionally OPT IN to broader cross-tenant scope (e.g. you -collaborate with partner organizations that don't have a B2B trust into -your tenant), replace the `{TenantDomain}` token in -`EncryptionRightsDefinitions` with `{AuthenticatedUsers}` (or the -literal `AuthenticatedUsers`). The toolkit refuses to silently fall -back to this scope — if `{TenantDomain}` cannot be resolved, the -labels module aborts with an actionable error rather than re-introduce -the broad scope. - -> **Retroactive scope changes only apply to NEW labelling.** Files -> already protected carry the use-license they had at the moment of -> labelling. To tighten retroactively, re-label / re-protect them. - -Note: granting the "Co-Author" *rights bundle* on a label is independent -of the tenant-wide *label co-authoring switch* (`-EnableLabelCoAuthoring`) -which controls where Office stores label metadata. Office auto-save + -simultaneous editing on encrypted documents requires both: the per-label -rights bundle AND the tenant-wide switch. - -### Visual marking (footers / watermarks) - -Footers on all sub-labels and the watermark on `Highly Confidential` are -**defined in config but disabled tenant-wide by default** -(`EnableContentMarking = $false`). Flip the master switch to `$true` once -you've validated rendering with the customer. No re-edit per label needed. - -### Default labels - -* **Email:** `General \ Anyone (unrestricted)` (Outlook auto-applies on new - messages; `General` itself is a non-applicable label group). If the - attachment inherits a higher-priority label, "Inherit label from - attachments" (`AttachmentAction = 'Automatic'`, on by default) silently - applies that label to the email instead. -* **Documents:** `Confidential\All Employees` (Word / Excel / PowerPoint / - service-side defaults). Every new document gets a footer. - -This pairing matches Microsoft's "secure by default" recommendation for SMB: -casual mail isn't over-classified, but every document leaves a trail. - ---- - -## Scenario 3 — Data Loss Prevention (DLP) - -**Module:** [`Setup-DLP.ps1`](../Modules/Setup-DLP.ps1) -**Always runs unless `-SkipDLP` is passed.** - -The toolkit creates **separate DLP policies per workload** (Microsoft's -recommended pattern). Each policy matches by **label GUID** — never by -display name — so renames don't break the rule. - -### Default policies created - -| Policy | Workload | Triggers when… | Action | -|---|---|---|---| -| **SMBTool - DLP - Confidential and HC external (EXO)** | Exchange Online | An email labelled with any Confidential or Highly Confidential sub-label is sent **outside the organisation** | Block + notify sender / site admin / last modifier | -| **SMBTool - DLP - Confidential and HC external (SPO+ODB)** | SharePoint + OneDrive | A labelled file is **shared externally** (anonymous or guest) | Block external recipients only — internal sharing untouched | -| **SMBTool - DLP - Endpoint Confidential and HC** *(E5 only)* | Endpoint (Windows / macOS devices) | A labelled file is **copy-pasted, printed, written to USB, or copied to a network share** on a managed device | Audit each event — telemetry visible in DLP Activity Explorer | - -### Simulation mode (key safety feature) - -By default (`DlpStartInSimulation = $true`) every DLP policy is created in -**`TestWithoutNotifications`** mode: - -* The rule **runs and logs hits** in DLP Activity Explorer. -* **Users are not blocked or notified**. -* The Purview portal exposes a "Turn the policy on if it's not edited - within fifteen days" toggle — flick it once you've reviewed the - telemetry. - -Set `DlpStartInSimulation = $false` to deploy directly into Enable mode -(only do this if you've already validated against a pilot tenant). - -> **Important behaviour.** On every re-run the toolkit reconciles policy -> mode against config. If you flip a policy to Enable in the portal but -> leave `DlpStartInSimulation = $true`, the next run pulls it back to -> simulation. Either flip the config flag or use `-SkipDLP`. - -### Endpoint DLP actions (E5 only) - -By default the Endpoint policy **audits** these device actions on -labelled content: - -* Copy & Paste -* Print -* Copy to Removable Media (USB drives, SD cards) -* Copy to Network Share - -Change `Audit` → `Block` / `Warn` / `BlockOverride` in -`DlpPolicies[Workload='Endpoint'].EndpointDlpRestrictions` once you've seen -the telemetry. Other supported actions (ScreenCapture, UnallowedApps, -CloudEgress, RemoteDesktopServices, browser print/copy/save, etc.) can be -added the same way. - ---- - -## Scenario 4 — Retention - -**Module:** [`Setup-Retention.ps1`](../Modules/Setup-Retention.ps1) -**Opt-in.** Only runs when `-ApplyRetention` is passed. - -> 🚨 **Retention is opt-in for a reason.** The shipped default deletes -> Exchange mail older than 7 years tenant-wide. That duration aligns with -> most common SMB regulatory frameworks (ATO / IRS / SEC / ASIC) but is -> still wrong for some verticals (paediatric healthcare, long-life -> construction warranties) and for customers who want no automatic -> deletion at all. See -> construction, real estate). Before passing `-ApplyRetention`, read -> [`docs/Retention-Default-Risk.md`](Retention-Default-Risk.md) and -> pick the right duration for the vertical. - -| Setting | Default | Tunable in config | -|---|---|---| -| Locations | Exchange mailboxes | Add `SharePoint`, `OneDrive` | -| Duration | 7 years | `Retention.DurationDays` | -| Action at end of period | Delete | `Retention.Action` (`KeepAndDelete` / `Keep` / `Delete`) | -| Trigger | Days since item creation | `Retention.ExpirationDateOption` | - -The policy is gentle by design — it only deletes mail older than 7 years -from when it was created, never anything newer. To pilot, narrow the scope -in config to a single mailbox before the wider rollout. - ---- - -## Scenario 5 — AI governance (Microsoft 365 Copilot DLP) - -**Module:** [`Setup-AIGovernance.ps1`](../Modules/Setup-AIGovernance.ps1) -**Default ON for E5 / Purview Suite tenants. Auto-skipped on Business Premium (`-BPOnly`). Opt out with `-SkipAIControls`.** - -| Policy | What it does | -|---|---| -| **SMBTool - AI - Block Copilot for Highly Confidential** | Stops Microsoft 365 Copilot from grounding, summarising, or surfacing in search any content labelled `Highly Confidential` (or its sub-labels). Aligned to Microsoft's [AI_054 Zero Trust Assessment control](https://microsoft.github.io/zerotrustassessment/docs/workshop-guidance/AI/AI_054). | - -This honours `DlpStartInSimulation` the same way as the other DLP policies -— safe to deploy in audit mode first. - -> **Licensing note.** Requires **Microsoft 365 E5 / Purview Suite** for the -> DLP-for-Copilot policy plane. Per -> [Microsoft Learn](https://learn.microsoft.com/purview/dlp-microsoft365-copilot-location-learn-about), -> the policy enforces against both paid **Microsoft 365 Copilot** and the -> free **Microsoft 365 Copilot Chat** experience that's included in -> eligible M365 licenses — so creation succeeds on any E5 / Purview Suite -> tenant regardless of whether paid Copilot per-user licenses are present. -> On Business Premium the policy plane is not available, so the toolkit -> auto-skips this step when `-BPOnly` is set (manually or via license -> auto-detect). - ---- - -## How licensing changes the picture - -The toolkit detects the customer's licensing and adjusts behaviour -automatically. Use `-BPOnly` to hard-block any E5-only feature, or -`-NoLicenseAutoDetect` to keep things explicit. - -| Capability | Business Premium | E5 / Purview Suite | Microsoft 365 Copilot | -|---|---|---|---| -| Sensitivity labels (Public → Highly Confidential) | ✅ created + published | ✅ | ✅ | -| Encryption + visual marking on labels | ✅ | ✅ | ✅ | -| DLP — Exchange | ✅ | ✅ | ✅ | -| DLP — SharePoint + OneDrive | ✅ | ✅ | ✅ | -| Retention (Exchange) | ✅ | ✅ | ✅ | -| Standard audit log (90-day) | ✅ | ✅ | ✅ | -| **Container labels** (Teams / M365 Groups / SPO sites) | ✅ auto-on (BP has AAD P1+); opt out via `-NoLicenseAutoDetect` | ✅ auto-on | ✅ | -| **Premium audit** (1-yr retention, `SearchQueryInitiated`) | ❌ | ✅ via `-EnablePremiumAudit` | ✅ | -| **Endpoint DLP** (devices) | ❌ blocked by `-BPOnly` | ✅ created in simulation | ✅ | -| **Copilot DLP** (block Copilot for HC) | ❌ blocked by `-BPOnly` | ✅ default ON (opt-out via `-SkipAIControls`) | ✅ | -| DLP for Defender for Cloud Apps / on-prem / Power BI | ❌ | ✅ if added to config | ✅ | - -### What changes when a customer upgrades from Business Premium to E5 - -Re-run the script with no `-BPOnly` flag. On the same config: - -1. **Container labels** auto-enable (no extra switch needed — license - detection handles it). -2. **Endpoint DLP** policy starts being created (the third entry in - `DlpPolicies` is no longer rejected). -3. **Copilot DLP / AI governance** starts being created (default-on for - E5 / Purview Suite). Lands in simulation mode while - `DlpStartInSimulation = $true`. Pass `-SkipAIControls` if the - customer has a specific reason to defer this. -4. **Premium audit** stays off until you opt in with - `-EnablePremiumAudit -PremiumAuditMailbox …` — you shouldn't - automatically expand audit scope without a conversation. - -Re-runs are **idempotent** — labels, DLP, retention objects already in -place are detected, left alone, or updated only where config has drifted. - -### What changes when a customer adds Microsoft 365 Copilot - -Nothing extra needs to be passed. AI governance is already default-on for -E5 / Purview Suite tenants and protects both paid Copilot and the free -Copilot Chat experience. The policy is created in simulation mode by -default (consistent with the rest of the toolkit) — once you're happy -with what it would block, flip `DlpStartInSimulation` to `$false` in -config and re-run. - ---- - -## What the script does NOT do - -This is deliberately **not** a full Purview implementation tool. The -following are out of scope: - -* **Insider Risk Management** — workflow + privacy controls require their - own consultation. -* **Communication Compliance** — same reason. -* **eDiscovery** (Premium) — case management is operator-driven. -* **Auto-labelling** (client-side and service-side) — high false-positive - risk; configure deliberately in the Purview portal once your team has - reviewed match patterns. -* **Customer-specific protection scopes** (per-domain, per-group rights) — - needs per-customer trust decisions. -* **Multi-geo configurations** — SharePoint URL handling is single-region. - -For the long-form Microsoft Purview guide, see -[aka.ms/Purview_LightweightGuide_PDF](https://aka.ms/Purview_LightweightGuide_PDF). - ---- - -## Safety mechanisms (good to know before you run it) - -| Mechanism | What it gives you | -|---|---| -| **`-WhatIf`** | Preview every state-changing call without executing it. | -| **Simulation mode** | DLP policies start in audit-only mode; user impact is zero until you promote them. | -| **`ManagedByTag`** | Every object the toolkit creates is stamped `[Managed by SMBTool Purview Toolkit]` — re-runs detect it and update in place. | -| **`-AdoptExisting`** | Required to update objects with the same name that the toolkit didn't create. Prevents accidental overwrite. | -| **Preflight summary + `y/N` prompt** | Master script prints what it's about to do and waits for confirmation. Skip with `-NonInteractive`. | -| **`-BPOnly`** | Hard-block E5-only features so you can't accidentally enable Endpoint DLP / container labels / premium audit on a Business Premium tenant. | -| **PowerShell 7 gate** | Refuses to run on PS 5.1 (silent EXOv3 / Graph SDK failures otherwise). | -| **Cleanup script** | [`Tests/Invoke-PurviewCleanup.ps1`](../../../Tests/Invoke-PurviewCleanup.ps1) removes every object stamped with `ManagedByTag`. | - ---- - -## Quick scenarios cheat-sheet - -| You want to… | Run | -|---|---| -| Preview everything, no changes | `-WhatIf` | -| Standard Business Premium customer | *(no extra flags — defaults are SMB-tuned)* | -| Block all E5 features explicitly | `-BPOnly` | -| Skip Copilot DLP guardrail (E5 / Purview Suite default-on) | `-SkipAIControls` | -| Customer has E5 + wants container labels | *(auto-enabled — no flag needed)* | -| Update labels / DLP that already exist (not toolkit-created) | `-AdoptExisting` | -| Re-deploy only DLP after fixing config | `-SkipTenantSettings -SkipLabels` (retention is opt-in already) | -| Office co-authoring on encrypted labels (tenant-wide, one-way) | `-EnableLabelCoAuthoring` | -| Roll back everything | Run [`Tests/Invoke-PurviewCleanup.ps1`](../../../Tests/Invoke-PurviewCleanup.ps1) | - ---- - -*Last updated to reflect Endpoint DLP, AI Copilot DLP, and license -auto-detect. All defaults are defined in -[`Config/PurviewConfig.psd1`](../Config/PurviewConfig.psd1) — fork and edit -to customise.* +--- +title: Scenarios & Capabilities +layout: default +parent: Purview +nav_order: 2 +permalink: /purview/scenarios/ +--- + +# What this toolkit does — Scenarios & Capabilities + +> 🧰 **On the support team / service desk?** For the fast, visual +> "what's changing & what's the impact" view, start with +> [**What's Changing — Support Team Guide**](whats-changing/). This page is +> the deeper technical reference. + +> **Audience.** Anyone evaluating, running, or reviewing the SMBTool Purview +> deployment script — IT admins, security leads, deployment consultants, and reviewers. +> No PowerShell expertise required to read this page; the technical +> details live behind expandable sections. + +This page answers three questions: + +1. **What scenarios does the script cover when I run it?** +2. **What changes if my customer's licensing changes?** +3. **What are the limits — what does it deliberately *not* do?** + +For step-by-step run instructions, see [`README.md`](../README.md). For the +list of every value the script applies, see +[`Config/PurviewConfig.psd1`](../Config/PurviewConfig.psd1). For the +non-technical companion docs, see: + +* [`Change-Management-Playbook.md`](Change-Management-Playbook.md) — pre-deploy / day-of / day 1–5 / day-30 promote-from-simulation gate +* [`End-User-Adoption-Guide.md`](End-User-Adoption-Guide.md) — plain-language guide to send to employees before rollout, with email templates (T-7 / T-0 / T+30) +* [`Retention-Default-Risk.md`](Retention-Default-Risk.md) — when the 7-year default is right, when it isn't, and how to override +* [`DLP-Simulation-Exit-Runbook.md`](DLP-Simulation-Exit-Runbook.md) — how to pull the "what would have leaked" report from Activity Explorer before promoting DLP out of simulation + +--- + +## TL;DR + +The script applies Microsoft's recommended **Data Security baseline** to a +Microsoft 365 tenant — designed for **Business Premium** and gracefully +expanding when **E5 / Purview Suite** licensing is present. It covers +**five scenarios**, all idempotent (safe to re-run) and DLP starts in +**simulation mode** by default so nothing breaks on day one. + +Before sign-in, the script writes a Deployment Plan that maps the selected +configuration to the supplied Good, Better, and Best SMB recommendations. The +plan keeps Microsoft Learn as a separate supporting reference and does not read +tenant state. Its JSON sidecar carries the Plan ID, short plan reference, and +sanitized intended-state snapshot prepared for the dependent tenant-validation +feature. That stacked feature must be reconciled with the finalized schema +before it is an available operator workflow. + +| # | Scenario | Default behaviour | Customer impact | +|---|----------|-------------------|-----------------| +| 1 | **Foundational tenant settings** | Enables audit log, SharePoint label integration, PDF labels | Invisible to end users | +| 2 | **Sensitivity labels** | Creates 3 parents + 6 sub-labels with defaults (`General` is a label **group**); publishes 4; sets `General \ Anyone (unrestricted)` as default for email and `Confidential\All Employees` as default for documents | Users see new labels in Outlook/Office; new documents auto-get a footer | +| 3 | **Data Loss Prevention (DLP)** | Blocks external sharing of Confidential and Highly Confidential content from Exchange and SharePoint/OneDrive (in **simulation** mode by default) | Telemetry only until the policy is promoted out of simulation | +| 4 | **Retention** | **Opt-in via `-ApplyRetention`.** Keeps Exchange mailbox content for 7 years, then deletes | Long-tail effect — mail older than 7 years starts to be removed | +| 5 | **AI governance (Copilot DLP)** | Opt-in — blocks Microsoft 365 Copilot from processing Highly Confidential content | Copilot ignores HC files when surfacing answers | + +--- + +## Scenario 1 — Foundational tenant settings + +**Module:** [`Setup-TenantSettings.ps1`](../Modules/Setup-TenantSettings.ps1) +**Always runs unless `-SkipTenantSettings` is passed.** + +These are the prerequisites every Purview feature depends on. Without them +labels can be applied but won't be enforced or visible everywhere. + +| Setting | What it enables | Default | +|---|---|---| +| **Unified Audit Log** | Every Purview / DLP / label action shows up in audit search and SIEM exports | ✅ on | +| **SharePoint AIP integration** | Sensitivity labels apply at the file level inside SharePoint and OneDrive (search, eDiscovery, DLP all see the label) | ✅ on | +| **Sensitivity labels for PDF** | New PDFs created or saved in SharePoint / OneDrive carry the parent doc's label | ✅ on | +| **Label co-authoring** | Tenant-wide switch that moves label metadata to the new embedded location so multiple users can edit a labelled / encrypted document at the same time in Office / Office Web. **One-way change** — disabling it later loses labels on unencrypted Office files; breaks third-party apps that read the old metadata location | Opt-in via `-EnableLabelCoAuthoring` | +| **Container labels** | Lets labels apply to Microsoft 365 Groups, Teams, and SharePoint sites (`Group.Unified` `EnableMIPLabels=True`) | Auto-on with Business Premium / E5 / Purview Suite (BP includes Entra ID P1+, which is the AAD-side requirement). Opt out with `-NoLicenseAutoDetect`. | +| **Premium audit** *(opt-in)* | Adds the rich `SearchQueryInitiated` event for the named mailbox(es); enables 1-year audit retention | Off — pass `-EnablePremiumAudit -PremiumAuditMailbox` | + +> **Licensing note.** All four are **Business Premium**. Container labels +> are also auto-enabled on Business Premium (BP includes Entra ID P1+, +> the AAD-side requirement) — premium audit still requires **E5 / Purview +> Suite**. Skip auto-enable with `-NoLicenseAutoDetect`. + +--- + +## Scenario 2 — Sensitivity labels + +**Module:** [`Setup-SensitivityLabels.ps1`](../Modules/Setup-SensitivityLabels.ps1) +**Always runs unless `-SkipLabels` is passed.** + +The toolkit creates an SMB-tuned subset of Microsoft's documented +[default sensitivity labels](https://learn.microsoft.com/en-us/purview/default-sensitivity-labels-policies), +identified and adopted by their stable Microsoft internal signature +(`defa4170-0d19-0005-NNNN-…`) so the same taxonomy lands correctly on any +tenant, region/locale, or label scheme (classic or modern): + +``` +Public +General ← label GROUP; default for EMAIL is its child below +├─ Anyone (unrestricted) ← default for EMAIL +└─ All Employees (unrestricted) +Confidential +├─ All Employees ← default for DOCUMENTS (footer only) +└─ Trusted People (footer + ENCRYPTED, UserDefined/Encrypt-Only — user picks who; recipients can reshare) +Highly Confidential (watermark "HIGHLY CONFIDENTIAL") +├─ All Employees (footer + ENCRYPTED, Co-Author rights, all users in your tenant only) +└─ Specific People (footer + ENCRYPTED, UserDefined/Do-Not-Forward — user picks who) +``` + +On **modern label scheme** tenants, `General`, `Confidential`, and `Highly +Confidential` are created as **label groups** (non-applicable containers); +their children carry the applicable content. On **classic scheme** tenants +they're created as regular parent labels. The toolkit detects the tenant's +scheme and branches automatically — no config change needed. + +### What gets published vs created + +| Created in tenant | Published to users by default | +|---|---| +| All 8 labels above | `Public`, `General \ Anyone (unrestricted)`, `Confidential\All Employees`, `Highly Confidential\All Employees` | + +The other sub-labels are created so DLP can match on them, but kept off the +client UI to reduce decision fatigue. Edit `LabelPolicy.PublishedLabels` +in [`PurviewConfig.psd1`](../Config/PurviewConfig.psd1) to surface more. +**Note:** `Confidential\TrustedPeople` and `Highly Confidential\Specific +People` apply UserDefined encryption — Word / Excel / PowerPoint prompt +the recipient list at apply time (Outlook auto-applies Encrypt-Only for +Trusted People, Do Not Forward for Specific People). Most end users do +not know how to respond to that dialog, so the default ships them +unpublished. Promote them only after user training. + +### Encryption — who can open the file? + +The **Template-encrypted Highly Confidential \ All Employees** sub-label +grants rights to **all users in your tenant only**. The rights bundle uses +the `{TenantDomain}` token, which the toolkit resolves at run time against +your auto-discovered tenant identity. Per Entra rights-management +semantics, ANY verified domain in your tenant expands to ALL verified +domains in your tenant, so this explicitly EXCLUDES external +`AuthenticatedUsers` (B2B guests attached to OTHER tenants, social/MSA +accounts, OTP users from other M365 tenants). + +The **two UserDefined sub-labels** (`Confidential\TrustedPeople`, +`Highly Confidential\Specific People`) let the message / file author pick +the exact recipients at apply time. Outlook auto-applies **Encrypt-Only** +for Trusted People (recipients can reshare) and **Do Not Forward** for +Specific People; Word / Excel / PowerPoint show a recipient-picker dialog +for both. + +| Label | Rights bundle | Office co-authoring (rights side) | Programmatic access | +|---|---|---|---| +| `Highly Confidential \ All Employees` (default) | Microsoft "Co-Author" — View, Edit, Save, Copy, Print, Allow Macros, Reply, Reply All, Forward | ✅ | ✅ | +| Any other Template-encrypted label (no per-label override) | Inherits global default (Reviewer) | ❌ | ❌ | + +The toolkit's global default is the conservative "Reviewer" bundle +because OBJMODEL access (granted by "Co-Author") is the right that most +often breaks third-party apps reading Office doc metadata. To override +the default for a specific label, add an +`EncryptionRightsDefinitions = '...'` field on that label's hashtable in +`PurviewConfig.psd1`. To change the global default for all +Template-encrypted labels at once, edit the top-level +`EncryptionRightsDefinitions` value. + +To intentionally OPT IN to broader cross-tenant scope (e.g. you +collaborate with partner organizations that don't have a B2B trust into +your tenant), replace the `{TenantDomain}` token in +`EncryptionRightsDefinitions` with `{AuthenticatedUsers}` (or the +literal `AuthenticatedUsers`). The toolkit refuses to silently fall +back to this scope — if `{TenantDomain}` cannot be resolved, the +labels module aborts with an actionable error rather than re-introduce +the broad scope. + +> **Retroactive scope changes only apply to NEW labelling.** Files +> already protected carry the use-license they had at the moment of +> labelling. To tighten retroactively, re-label / re-protect them. + +Note: granting the "Co-Author" *rights bundle* on a label is independent +of the tenant-wide *label co-authoring switch* (`-EnableLabelCoAuthoring`) +which controls where Office stores label metadata. Office auto-save + +simultaneous editing on encrypted documents requires both: the per-label +rights bundle AND the tenant-wide switch. + +### Visual marking (footers / watermarks) + +Footers on all sub-labels and the watermark on `Highly Confidential` are +**defined in config but disabled tenant-wide by default** +(`EnableContentMarking = $false`). Flip the master switch to `$true` once +you've validated rendering with the customer. No re-edit per label needed. + +### Default labels + +* **Email:** `General \ Anyone (unrestricted)` (Outlook auto-applies on new + messages; `General` itself is a non-applicable label group). If the + attachment inherits a higher-priority label, "Inherit label from + attachments" (`AttachmentAction = 'Automatic'`, on by default) silently + applies that label to the email instead. +* **Documents:** `Confidential\All Employees` (Word / Excel / PowerPoint / + service-side defaults). Every new document gets a footer. + +This pairing matches Microsoft's "secure by default" recommendation for SMB: +casual mail isn't over-classified, but every document leaves a trail. + +--- + +## Scenario 3 — Data Loss Prevention (DLP) + +**Module:** [`Setup-DLP.ps1`](../Modules/Setup-DLP.ps1) +**Always runs unless `-SkipDLP` is passed.** + +The toolkit creates **separate DLP policies per workload** (Microsoft's +recommended pattern). Each policy matches by **label GUID** — never by +display name — so renames don't break the rule. + +### Default policies created + +| Policy | Workload | Triggers when… | Action | +|---|---|---|---| +| **SMBTool - DLP - Confidential and HC external (EXO)** | Exchange Online | An email labelled with any Confidential or Highly Confidential sub-label is sent **outside the organisation** | Block + notify sender / site admin / last modifier | +| **SMBTool - DLP - Confidential and HC external (SPO+ODB)** | SharePoint + OneDrive | A labelled file is **shared externally** (anonymous or guest) | Block external recipients only — internal sharing untouched | +| **SMBTool - DLP - Endpoint Confidential and HC** *(E5 only)* | Endpoint (Windows / macOS devices) | A labelled file is **copy-pasted, printed, written to USB, or copied to a network share** on a managed device | Audit each event — telemetry visible in DLP Activity Explorer | + +### Simulation mode (key safety feature) + +By default (`DlpStartInSimulation = $true`) every DLP policy is created in +**`TestWithoutNotifications`** mode: + +* The rule **runs and logs hits** in DLP Activity Explorer. +* **Users are not blocked or notified**. +* The Purview portal exposes a "Turn the policy on if it's not edited + within fifteen days" toggle — flick it once you've reviewed the + telemetry. + +Set `DlpStartInSimulation = $false` to deploy directly into Enable mode +(only do this if you've already validated against a pilot tenant). + +> **Important behaviour.** On every re-run the toolkit reconciles policy +> mode against config. If you flip a policy to Enable in the portal but +> leave `DlpStartInSimulation = $true`, the next run pulls it back to +> simulation. Either flip the config flag or use `-SkipDLP`. + +### Endpoint DLP actions (E5 only) + +By default the Endpoint policy **audits** these device actions on +labelled content: + +* Copy & Paste +* Print +* Copy to Removable Media (USB drives, SD cards) +* Copy to Network Share + +Change `Audit` → `Block` / `Warn` / `BlockOverride` in +`DlpPolicies[Workload='Endpoint'].EndpointDlpRestrictions` once you've seen +the telemetry. Other supported actions (ScreenCapture, UnallowedApps, +CloudEgress, RemoteDesktopServices, browser print/copy/save, etc.) can be +added the same way. + +--- + +## Scenario 4 — Retention + +**Module:** [`Setup-Retention.ps1`](../Modules/Setup-Retention.ps1) +**Opt-in.** Only runs when `-ApplyRetention` is passed. + +> 🚨 **Retention is opt-in for a reason.** The shipped default deletes +> Exchange mail older than 7 years tenant-wide. That duration aligns with +> most common SMB regulatory frameworks (ATO / IRS / SEC / ASIC) but is +> still wrong for some verticals (paediatric healthcare, long-life +> construction warranties) and for customers who want no automatic +> deletion at all. See +> construction, real estate). Before passing `-ApplyRetention`, read +> [`docs/Retention-Default-Risk.md`](Retention-Default-Risk.md) and +> pick the right duration for the vertical. + +| Setting | Default | Tunable in config | +|---|---|---| +| Locations | Exchange mailboxes | Add `SharePoint`, `OneDrive` | +| Duration | 7 years | `Retention.DurationDays` | +| Action at end of period | Delete | `Retention.Action` (`KeepAndDelete` / `Keep` / `Delete`) | +| Trigger | Days since item creation | `Retention.ExpirationDateOption` | + +The policy is gentle by design — it only deletes mail older than 7 years +from when it was created, never anything newer. To pilot, narrow the scope +in config to a single mailbox before the wider rollout. + +--- + +## Scenario 5 — AI governance (Microsoft 365 Copilot DLP) + +**Module:** [`Setup-AIGovernance.ps1`](../Modules/Setup-AIGovernance.ps1) +**Default ON for E5 / Purview Suite tenants. Auto-skipped on Business Premium (`-BPOnly`). Opt out with `-SkipAIControls`.** + +| Policy | What it does | +|---|---| +| **SMBTool - AI - Block Copilot for Highly Confidential** | Stops Microsoft 365 Copilot from grounding, summarising, or surfacing in search any content labelled `Highly Confidential` (or its sub-labels). Aligned to Microsoft's [AI_054 Zero Trust Assessment control](https://microsoft.github.io/zerotrustassessment/docs/workshop-guidance/AI/AI_054). | + +This honours `DlpStartInSimulation` the same way as the other DLP policies +— safe to deploy in audit mode first. + +> **Licensing note.** Requires **Microsoft 365 E5 / Purview Suite** for the +> DLP-for-Copilot policy plane. Per +> [Microsoft Learn](https://learn.microsoft.com/purview/dlp-microsoft365-copilot-location-learn-about), +> the policy enforces against both paid **Microsoft 365 Copilot** and the +> free **Microsoft 365 Copilot Chat** experience that's included in +> eligible M365 licenses — so creation succeeds on any E5 / Purview Suite +> tenant regardless of whether paid Copilot per-user licenses are present. +> On Business Premium the policy plane is not available, so the toolkit +> auto-skips this step when `-BPOnly` is set (manually or via license +> auto-detect). + +--- + +## How licensing changes the picture + +The toolkit detects the customer's licensing and adjusts behaviour +automatically. Use `-BPOnly` to hard-block any E5-only feature, or +`-NoLicenseAutoDetect` to keep things explicit. + +| Capability | Business Premium | E5 / Purview Suite | Microsoft 365 Copilot | +|---|---|---|---| +| Sensitivity labels (Public → Highly Confidential) | ✅ created + published | ✅ | ✅ | +| Encryption + visual marking on labels | ✅ | ✅ | ✅ | +| DLP — Exchange | ✅ | ✅ | ✅ | +| DLP — SharePoint + OneDrive | ✅ | ✅ | ✅ | +| Retention (Exchange) | ✅ | ✅ | ✅ | +| Standard audit log (90-day) | ✅ | ✅ | ✅ | +| **Container labels** (Teams / M365 Groups / SPO sites) | ✅ auto-on (BP has AAD P1+); opt out via `-NoLicenseAutoDetect` | ✅ auto-on | ✅ | +| **Premium audit** (1-yr retention, `SearchQueryInitiated`) | ❌ | ✅ via `-EnablePremiumAudit` | ✅ | +| **Endpoint DLP** (devices) | ❌ blocked by `-BPOnly` | ✅ created in simulation | ✅ | +| **Copilot DLP** (block Copilot for HC) | ❌ blocked by `-BPOnly` | ✅ default ON (opt-out via `-SkipAIControls`) | ✅ | +| DLP for Defender for Cloud Apps / on-prem / Power BI | Not implemented | Not implemented | Not implemented | + +Entries for these unsupported workloads are skipped with structured evidence. +Adding them to config or upgrading licensing does not create the policy. + +### What changes when a customer upgrades from Business Premium to E5 + +Re-run the script with no `-BPOnly` flag. On the same config: + +1. **Container labels** auto-enable (no extra switch needed — license + detection handles it). +2. **Endpoint DLP** policy starts being created (the third entry in + `DlpPolicies` is no longer rejected). +3. **Copilot DLP / AI governance** starts being created (default-on for + E5 / Purview Suite). Lands in simulation mode while + `DlpStartInSimulation = $true`. Pass `-SkipAIControls` if the + customer has a specific reason to defer this. +4. **Premium audit** stays off until you opt in with + `-EnablePremiumAudit -PremiumAuditMailbox …` — you shouldn't + automatically expand audit scope without a conversation. + +Re-runs are **idempotent** — labels, DLP, retention objects already in +place are detected, left alone, or updated only where config has drifted. + +### What changes when a customer adds Microsoft 365 Copilot + +Nothing extra needs to be passed. AI governance is already default-on for +E5 / Purview Suite tenants and protects both paid Copilot and the free +Copilot Chat experience. The policy is created in simulation mode by +default (consistent with the rest of the toolkit) — once you're happy +with what it would block, flip `DlpStartInSimulation` to `$false` in +config and re-run. + +--- + +## What the script does NOT do + +This is deliberately **not** a full Purview implementation tool. The +following are out of scope: + +* **Insider Risk Management** — workflow + privacy controls require their + own consultation. +* **Communication Compliance** — same reason. +* **eDiscovery** (Premium) — case management is operator-driven. +* **Auto-labelling** (client-side and service-side) — high false-positive + risk; configure deliberately in the Purview portal once your team has + reviewed match patterns. +* **Customer-specific protection scopes** (per-domain, per-group rights) — + needs per-customer trust decisions. +* **Multi-geo configurations** — SharePoint URL handling is single-region. + +For the long-form Microsoft Purview guide, see +[aka.ms/Purview_LightweightGuide_PDF](https://aka.ms/Purview_LightweightGuide_PDF). + +--- + +## Safety mechanisms (good to know before you run it) + +| Mechanism | What it gives you | +|---|---| +| **`-WhatIf`** | Preview every state-changing call without executing it. | +| **Simulation mode** | DLP policies start in audit-only mode; user impact is zero until you promote them. | +| **`ManagedByTag`** | Every object the toolkit creates is stamped `[Managed by SMBTool Purview Toolkit]` — re-runs detect it and update in place. | +| **`-AdoptExisting`** | Required to update objects with the same name that the toolkit didn't create. Prevents accidental overwrite. | +| **Preflight summary + `y/N` prompt** | Master script prints what it's about to do and waits for confirmation. Skip with `-NonInteractive`. | +| **`-BPOnly`** | Hard-block E5-only features so you can't accidentally enable Endpoint DLP / container labels / premium audit on a Business Premium tenant. | +| **PowerShell 7 gate** | Refuses to run on PS 5.1 (silent EXOv3 / Graph SDK failures otherwise). | +| **Cleanup script** | [`Tests/Invoke-PurviewCleanup.ps1`](../../../Tests/Invoke-PurviewCleanup.ps1) removes every object stamped with `ManagedByTag`. | + +--- + +## Quick scenarios cheat-sheet + +| You want to… | Run | +|---|---| +| Preview everything, no changes | `-WhatIf` | +| Standard Business Premium customer | *(no extra flags — defaults are SMB-tuned)* | +| Block all E5 features explicitly | `-BPOnly` | +| Skip Copilot DLP guardrail (E5 / Purview Suite default-on) | `-SkipAIControls` | +| Customer has E5 + wants container labels | *(auto-enabled — no flag needed)* | +| Update labels / DLP that already exist (not toolkit-created) | `-AdoptExisting` | +| Re-deploy only DLP after fixing config | `-SkipTenantSettings -SkipLabels` (retention is opt-in already) | +| Office co-authoring on encrypted labels (tenant-wide, one-way) | `-EnableLabelCoAuthoring` | +| Roll back everything | Run [`Tests/Invoke-PurviewCleanup.ps1`](../../../Tests/Invoke-PurviewCleanup.ps1) | + +--- + +*Last updated to reflect Endpoint DLP, AI Copilot DLP, and license +auto-detect. All defaults are defined in +[`Config/PurviewConfig.psd1`](../Config/PurviewConfig.psd1) — fork and edit +to customise.* diff --git a/Products/Purview/docs/Tenant-Validation-Report.md b/Products/Purview/docs/Tenant-Validation-Report.md new file mode 100644 index 0000000..f6628f3 --- /dev/null +++ b/Products/Purview/docs/Tenant-Validation-Report.md @@ -0,0 +1,520 @@ +--- +title: Running the Tenant Validation Report +layout: default +parent: Purview +nav_order: 9 +permalink: /purview/tenant-validation-report/ +--- + +# Running the Tenant Validation Report +{: .no_toc } + +`Test-PurviewTenantConfiguration.ps1` reads the current Purview configuration +and writes a Tenant Validation Report. It can compare the tenant with one +approved Deployment Plan or assess the pinned Priority 1 (Good), Priority 2 +(Better), and Priority 3 (Best) guide controls without a plan. + +The command is read-only. It has no repair switch and does not change tenant +state. The report describes configuration alignment, not compliance or control +effectiveness. + +Use this page for installation, run commands, output locations, and module +troubleshooting. See [Configuration Validation](Configuration-Validation.md) +for the comparison model, result axes, scoring rules, permissions, and known +limitations. + +## Before you run + +1. Use PowerShell 7. +2. Wait for Purview changes to propagate. Labels, DLP, and tenant settings can + take up to 24 hours to become visible to a read. +3. Decide whether to compare with a Deployment Plan or run a guide-only + assessment. +4. Confirm the required PowerShell modules are installed. +5. Use an account with the required read permissions and service roles. +6. Choose a private location for the HTML and JSON evidence. + +The validator confirms tenant identity before collecting configuration. A +wrong-tenant result is fatal and no report is written. + +## Required PowerShell modules + +The validator first checks whether each required command is already available. +If a command is not loaded, it imports an installed copy of the module that +provides it. When the module is missing, the validator prompts to install it or +installs it automatically when `-AutoInstallModules` is set. The current +validator does not pin exact module versions or maintain a tested-profile +allowlist. + +| Module | Used for | Requirement | +|---|---|---| +| `Microsoft.Graph.Authentication` | Graph sign-in and tenant identity | Core. Every validation run uses Graph. | +| `ExchangeOnlineManagement` | Exchange Online and Security & Compliance PowerShell sessions | Core. Every validation run uses both sessions. | +| `Microsoft.Graph.Beta.Identity.DirectoryManagement` | Reading the `Group.Unified` container-label directory setting | Conditional. Without it, the container-label action is `Not evaluated`. | +| `Microsoft.Online.SharePoint.PowerShell` | Reading SharePoint and OneDrive label integration and the PDF setting | Conditional. It is needed when scored actions use `Get-SPOTenant`. A connection failure makes those actions `Not evaluated` while other actions continue. | + +`Connect-IPPSSession` is supplied by `ExchangeOnlineManagement`, so there is no +separate Security & Compliance module to install. + +### Check installed versions + +Run this in a fresh PowerShell 7 session: + +```powershell +Get-Module ` + Microsoft.Graph.Authentication, ` + Microsoft.Graph.Beta.Identity.DirectoryManagement, ` + ExchangeOnlineManagement, ` + Microsoft.Online.SharePoint.PowerShell ` + -ListAvailable | + Sort-Object Name, Version -Descending | + Select-Object Name, Version, Path +``` + +Check which versions are already loaded: + +```powershell +Get-Module ` + Microsoft.Graph.Authentication, ` + Microsoft.Graph.Beta.Identity.DirectoryManagement, ` + ExchangeOnlineManagement, ` + Microsoft.Online.SharePoint.PowerShell | + Select-Object Name, Version, Path +``` + +### Install missing modules + +The simplest operator path is to let the validator install missing modules: + +```powershell +.\Test-PurviewTenantConfiguration.ps1 ` + -TenantAdminUpn admin@contoso.onmicrosoft.com ` + -AutoInstallModules +``` + +For manual installation: + +```powershell +Install-Module Microsoft.Graph.Authentication ` + -Scope CurrentUser -Force + +Install-Module Microsoft.Graph.Beta.Identity.DirectoryManagement ` + -Scope CurrentUser -Force + +Install-Module ExchangeOnlineManagement ` + -Scope CurrentUser -Force + +Install-Module Microsoft.Online.SharePoint.PowerShell ` + -Scope CurrentUser -Force +``` + +Install Graph Authentication and the Graph Beta directory module from the same +Microsoft Graph PowerShell SDK release. The Beta module manifest can require an +exact Authentication version. A mixed Graph pair can fail during import even +though both modules are installed. + +Close the PowerShell process after installing or changing Graph or Exchange +Online modules. Start a new `pwsh -NoProfile` process so an older MSAL assembly +does not remain loaded. + +## Permissions and sign-in + +The validation profile requests these delegated Microsoft Graph scopes: + +| Scope | Purpose | +|---|---| +| `Organization.Read.All` | Resolve and verify the connected tenant. | +| `GroupSettings.Read.All` | Read the tenant-wide container-label directory setting. | + +The validator deliberately does not request the deployment profile's Graph +write permissions. + +After connection, the validator checks the effective Graph token. It permits +only the two resource scopes above and the standard OpenID identity scopes. A +cached or newly issued token containing another Graph permission is rejected +before tenant collection. + +When an existing token is broader, close the PowerShell process and open a +fresh session: + +```powershell +pwsh -NoProfile +``` + +Rerun the validator in that process. It requests the two read scopes and checks +the new effective token before any tenant collection. Starting a new process +clears the in-process context, but the shared Microsoft Graph PowerShell client +or Windows broker can still reuse an earlier grant. The validator deliberately +does not clear the shared broker automatically because that could sign the user +out of other broker-enabled applications. + +If the fresh token remains broader, use an approved tenant-local public client +configured with only `Organization.Read.All` and +`GroupSettings.Read.All`. The validator does not create the app or grant +consent: + +```powershell +.\Test-PurviewTenantConfiguration.ps1 ` + -TenantAdminUpn 'admin@contoso.onmicrosoft.com' ` + -ClientId '' ` + -UseDeviceAuthentication +``` + +Exchange Online, Security & Compliance PowerShell, and SharePoint Online use +the signed-in account's service roles. Microsoft does not document a +least-privileged role for every cmdlet used by the validator. Those role +mappings still require pilot verification. SharePoint Online access is +optional because a missing SharePoint session affects only the SharePoint +backed actions. + +## Find a Deployment Plan + +Deployment Plans are written to the working directory from which the deployment +script was invoked unless `-DeploymentPlanPath` selected another location. + +The JSON filename normally resembles: + +```text +Deploy-PurviewBestPractice-Plan-PUR-20260914-144842-1234ABCD.json +``` + +Find plans under a deployment folder: + +```powershell +Get-ChildItem 'C:\Demo Deployment\BestPractice_Deploy-Scripts' ` + -Filter 'Deploy-PurviewBestPractice-Plan-*.json' ` + -Recurse +``` + +Plan comparison accepts a Deployment Plan JSON sidecar at schema 1.2 or later. +The validator checks artifact identity and fingerprints before authentication. + +## Run a plan comparison + +From the Purview product folder: + +```powershell +cd 'C:\Demo Deployment\BestPractice_Deploy-Scripts\Products\Purview' + +.\Test-PurviewTenantConfiguration.ps1 ` + -PlanPath 'C:\Demo Deployment\Evidence\Deploy-PurviewBestPractice-Plan-PUR-20260914-144842-1234ABCD.json' ` + -TenantAdminUpn 'admin@contoso.onmicrosoft.com' +``` + +The validator compares observable toolkit-owned fields with the plan's +sanitized intended state. Customer-managed differences are retained as +redacted, unscored context. + +## Run a guide-only assessment + +Omit `-PlanPath`: + +```powershell +.\Test-PurviewTenantConfiguration.ps1 ` + -TenantAdminUpn 'admin@contoso.onmicrosoft.com' +``` + +Guide-only mode assesses the pinned guide controls without loading the default +Purview configuration or claiming what the deployment intended. + +## Run through GDAP + +```powershell +.\Test-PurviewTenantConfiguration.ps1 ` + -PlanPath 'C:\Demo Deployment\Evidence\plan.json' ` + -TenantAdminUpn 'partneradmin@fabrikam.onmicrosoft.com' ` + -DelegatedOrganization 'contoso.onmicrosoft.com' +``` + +The customer domain supplied through `-DelegatedOrganization` is used for the +tenant identity check. + +## Specify SharePoint and output paths + +Use an explicit SharePoint admin URL for multi-geo, renamed, or vanity-domain +tenants: + +```powershell +.\Test-PurviewTenantConfiguration.ps1 ` + -PlanPath 'C:\Demo Deployment\Evidence\plan.json' ` + -TenantAdminUpn 'admin@contoso.onmicrosoft.com' ` + -SharePointAdminUrl 'https://contoso-admin.sharepoint.com' +``` + +Choose the HTML report location with `-OutputPath`: + +```powershell +.\Test-PurviewTenantConfiguration.ps1 ` + -PlanPath 'C:\Demo Deployment\Evidence\plan.json' ` + -TenantAdminUpn 'admin@contoso.onmicrosoft.com' ` + -OutputPath 'C:\Demo Deployment\Evidence\Contoso-Purview-Validation.html' +``` + +The JSON sidecar uses the same directory and basename: + +```text +C:\Demo Deployment\Evidence\Contoso-Purview-Validation.html +C:\Demo Deployment\Evidence\Contoso-Purview-Validation.json +``` + +Without `-OutputPath`, both files are written to the current working directory: + +```text +Test-PurviewTenantConfiguration-.html +Test-PurviewTenantConfiguration-.json +``` + +Validation output contains tenant observations. Store it in the approved +private evidence location. Do not commit or publish the files. + +## Run without prompts + +Use `-NonInteractive` for automation: + +```powershell +.\Test-PurviewTenantConfiguration.ps1 ` + -PlanPath 'C:\Demo Deployment\Evidence\plan.json' ` + -TenantAdminUpn 'admin@contoso.onmicrosoft.com' ` + -OutputPath 'C:\Demo Deployment\Evidence\validation.html' ` + -NonInteractive + +$validationExitCode = $LASTEXITCODE +``` + +Install modules before a noninteractive run, or combine the command with +`-AutoInstallModules`. + +## Read the result + +Each action receives one operational result: + +| Result | Meaning | +|---|---| +| `Matched` | Every scored toolkit-owned field matched. | +| `Drift` | A managed field differed after retries. | +| `Not evaluated` | Entitlement is explicitly known to be absent, or a required service session, property, or capability was unavailable. | +| `Informational` | The plan excluded or did not configure the action. | +| `Collection failed` | The tenant read failed after transient retries. | + +Unreadable or missing license inventory is provisional and does not block a +workload read that can prove support. The validator does not request +license-assignment scope; it reports `Not evaluated` only when entitlement is +explicitly known to be absent, or when another required session, property, or +capability is unavailable. + +Exit codes use a bitmask: + +| Code | Meaning | +|---|---| +| `0` | No drift and no collection failures. | +| `2` | Drift is present. | +| `4` | A collection failure is present. | +| `6` | Both drift and a collection failure are present. | +| `1` | Fatal plan, schema, tenant identity, or connection failure. | + +The validator has no propagation status. A setting that has not converged when +it is read is reported as drift. Wait for the documented propagation window +before acting on a new deployment's first validation report. + +## Troubleshooting + +### Deployment Plan JSON not found + +The supplied path does not identify an existing file. Search for the generated +JSON sidecar and pass its exact path. Current plan filenames include the +`PUR--` reference. + +### A required module is not installed + +Install the named module or rerun with `-AutoInstallModules`. In +`-NonInteractive` mode, a missing core module is fatal. + +### An exact approved module version is rejected + +The current validator does not enforce exact module versions. This error +normally means you are running the earlier `Test-PurviewConfiguration.ps1` +prototype or another development branch. Confirm the script filename, +repository branch, and module path before installing or removing modules. + +Do not bypass an exact-version guard in a script that contains one. Either run +the current `Test-PurviewTenantConfiguration.ps1` command or satisfy that +script's documented profile. + +### Graph Authentication and Graph Beta do not load together + +Check the Graph Beta manifest requirement: + +```powershell +$beta = Get-Module Microsoft.Graph.Beta.Identity.DirectoryManagement ` + -ListAvailable | + Sort-Object Version -Descending | + Select-Object -First 1 + +(Import-PowerShellDataFile -LiteralPath $beta.Path).RequiredModules +``` + +Install a matching Graph Authentication and Graph Beta pair, close PowerShell, +and retry from `pwsh -NoProfile`. + +### Exchange Online fails after Graph connects + +Graph is connected first because Graph and Exchange Online can load different +MSAL versions. The validator passes `-DisableWAM` to Exchange Online and +Security & Compliance after Graph is loaded. If the process already loaded +conflicting assemblies, close it and retry in a fresh PowerShell 7 process. + +### SharePoint Online cannot connect + +The validator continues. Actions that depend on `Get-SPOTenant` are reported as +`Not evaluated`. Confirm the SharePoint module, admin URL, role, and service +health before rerunning. + +### Tenant identity mismatch + +Validation stops rather than produce a report for the wrong tenant. Confirm the +admin UPN suffix, `-DelegatedOrganization`, and the account selected during +interactive sign-in. + +### Graph context contains permissions outside the validator allowlist + +The validator found an effective token with a resource permission other than +`Organization.Read.All` or `GroupSettings.Read.All`. It disconnects that +context and stops before tenant collection. Close the process, start a fresh +`pwsh -NoProfile` session, and rerun. If the newly authenticated token still +contains broader permissions, use an approved tenant-local public client +restricted to the two validation permissions and pass its application ID with +`-ClientId`. Do not broaden the validator allowlist to accommodate an existing +deployment consent grant. + +### A recent deployment reports drift + +Wait up to 24 hours for Purview propagation, then rerun. The validator does not +guess that a mismatch is still propagating. + +## Current module-version support issue + +The current validator confirms module names and required commands, but it does +not distinguish between tested and untested version combinations. PowerShell +can auto-load the newest installed version, and a module that works by itself +can still conflict with another module through Graph SDK or MSAL dependencies. + +There is no established list of approved module profiles. An installed +combination must not be described as qualified only because imports or +authentication succeeded once. + +## Proposed tested module profiles + +A future module profile should be a complete tuple rather than independent +minimum-version ranges. An illustrative record could contain: + +```powershell +@{ + Id = 'validator-2026-09' + Status = 'Candidate' + PowerShell = @{ + MinimumVersion = '7.4' + MaximumMajor = 7 + } + Modules = @{ + MicrosoftGraphAuthentication = '' + MicrosoftGraphBetaDirectory = '' + ExchangeOnlineManagement = '' + SharePointOnline = '' + } + Connection = @{ + Order = @('Graph', 'ExchangeOnline', 'IPPS', 'SharePoint') + DisableWamAfterGraphLoad = $true + } + PilotEvidence = '' +} +``` + +This is a proposed support model, not a configuration recognized by the +current validator. + +The selector should: + +1. Consider only complete installed profiles. +2. Reject Graph Beta and Graph Authentication versions that do not match the + Beta module manifest. +3. Prefer the newest profile with `Approved` status. +4. Never select a `Candidate` or `Rejected` profile automatically. +5. Record the selected profile and exact module versions in the report. +6. Run validation in a fresh child PowerShell process when loaded assemblies + could conflict with the selected profile. + +## Qualifying another profile + +Do not build a Cartesian matrix of every module version. Maintain a short list: +the current approved profile, the previous approved profile for rollback, and +one candidate under qualification. + +### 1. Check manifests and commands + +- Confirm every exact version is available from the approved package source. +- Confirm Graph Beta requires the selected Graph Authentication version. +- Confirm each required command is exported by the expected module. +- Reject incomplete profiles and duplicate profile IDs. +- Keep executable commands and arbitrary module paths out of profile data. + +### 2. Test imports in a clean process + +The profile test should start `pwsh -NoProfile`, import each exact version in +the required order, and confirm command provenance. It must detect assembly +load failures, missing methods, command shadowing, and mixed Graph versions. + +### 3. Run deterministic validator fixtures + +Run: + +```powershell +pwsh -File .\Products\Purview\Tests\Test-TenantConfigurationValidation.ps1 +``` + +The fixtures must continue to cover schema rejection, matching state, drift, +unscored customer differences, prerequisites, retries, collection failures, +partial reports, redaction, and exit codes. + +### 4. Test authentication and every collector + +On an approved non-production tenant: + +1. Connect Graph first with the validator's read-only scopes. +2. Read tenant identity and directory settings. +3. Connect Exchange Online and Security & Compliance PowerShell. +4. Connect SharePoint Online when required. +5. Exercise each validation collector. +6. Repeat the full run in another fresh process to catch cached-session and + assembly-order problems. + +### 5. Test operational scenarios + +Record results for: + +- a matching tenant; +- deliberate toolkit-managed drift; +- wrong-tenant rejection; +- supported and unsupported licensing; +- unreadable prerequisites; +- missing optional SharePoint access; +- transient and permanent service failures; +- customer-managed objects excluded from scoring; +- HTML and JSON parity and redaction; +- exit codes `0`, `2`, `4`, and `6`. + +### 6. Record approval evidence + +An approved profile record should include: + +- exact module and PowerShell versions; +- operating system and build; +- test date and reviewer; +- deterministic test output; +- non-identifying pilot classification; +- collector coverage; +- known limitations; +- approval owner; +- review or expiration date. + +Until this evidence exists, keep the profile at `Candidate` and do not present +it as supported. diff --git a/Products/Purview/docs/What-This-Tool-Does.html b/Products/Purview/docs/What-This-Tool-Does.html index 914f93a..26bc2f0 100644 --- a/Products/Purview/docs/What-This-Tool-Does.html +++ b/Products/Purview/docs/What-This-Tool-Does.html @@ -1,648 +1,675 @@ - - - - -What the Purview Best Practice Toolkit does in your tenant - - - - -
- -
-
Microsoft Purview Best Practice Toolkit
-

What this script does in your tenant

-

A single-page summary of every change the toolkit makes when you run - Deploy-PurviewBestPractice.ps1. Read this before deploying so you (and your - customer) know exactly what will land in the tenant, what is on by default, and what is - explicitly opt-in.

- -
- Default ON — always applied - Optional — you must pass a switch - Auto-detected — turned on based on tenant licence - Off by default — safe to ignore - Destructive / deploy-team decision -
- - -
- - -
-

1 Run modes — how the script behaves when you launch it

-
-
-

Apply Default

-
No switch needed
-

Every change is applied to the tenant. The script asks for confirmation once at - the start (skip with -NonInteractive).

-
-
-

Preview -WhatIf

-
Read-only
-

Walks through every step and prints what would happen, without writing - anything to the tenant. Use this for change-approval review.

-
-
-

Per-action prompt -Confirm

-
Interactive
-

Pauses before each individual change. Useful when migrating an existing tenant and - you want to inspect each step.

-
-
-

Unattended -NonInteractive

-
CI / automation
-

Skips the preflight confirmation. Combined with -AutoInstallModules - the script runs end-to-end with no human input. Tenant-identity guard still aborts - on mismatch before any write.

-
-
-
- - -
-

2 Five tasks — what the script touches, in order

-
-
-

1. Tenant settings Default

-

Foundational on/off switches: unified audit log, AIP×SPO integration, PDF labels.

-

Container labels default-on (opt out with -SkipContainerLabels) · -EnablePremiumAudit

-
-
-

2. Sensitivity labels Default

-

Creates 3 label groups (Public is standalone) & 6 assignable children, orders them, applies encryption to Highly Confidential and Confidential\Trusted People, and publishes the user-facing subset.

-

-SkipLabels to opt out

-
-
-

3. DLP Default

-

Two policies block external sharing of Confidential / Highly Confidential content — one for Exchange, one for SharePoint+OneDrive.

-

-SkipDLP to opt out · created in simulation by default

-
-
-

4. Retention Opt-in

-

7-year retain-then-delete on Exchange mailboxes. Off by default — you must pass -ApplyRetention after confirming the duration matches the customer's vertical.

-

Destructive deletes mail older than 7 years

-
-
-

5. AI governance Auto-detected

-

Blocks Microsoft 365 Copilot from grounding on Highly Confidential content. Default ON for E5 / Purview Suite tenants; auto-skipped on Business Premium.

-

-SkipAIControls · -BPOnly hard-skip

-
-
-
- - -
-

3 Tenant settings applied

- - - - - - - - - - - - - - - - -
SettingValueModeWhat it does in your tenant
Unified Audit LogEnabledDefaultTurns on the cross-workload activity log used by every Purview investigation, eDiscovery and DLP report.
AIP integration in SharePoint OnlineEnabledDefaultLets SPO/OneDrive open, index and search content encrypted by sensitivity labels.
Sensitivity label for PDFEnabledDefaultOffice can apply (and persist) sensitivity labels when saving as PDF.
Label co-authoringEnabled-EnableLabelCoAuthoringTenant-wide switch that lets multiple users edit encrypted Office files at once. One-way change — disabling it later loses labels on unencrypted Office files, breaks third-party tools that read labels from the old metadata location (AIP scanner < v3.0, OneDrive sync < 19.002, MIP SDK < 1.7, custom DLP scanners, custom Exchange mail-flow rules). Off by default.
Container labels (Teams / Sites / Groups)Group.Unified.EnableMIPLabelsDefaultLets sensitivity labels be applied to M365 Groups, Teams and SharePoint sites. Requires Microsoft Graph beta consent. Enabled by default on every run — Business Premium is the toolkit's licensing floor and BP includes Entra ID P1, the AAD-side requirement. Pass -SkipContainerLabels to opt out. License auto-detect skips it automatically when the tenant has no recognised M365 BP/E5/Purview Suite SKU.
Premium audit (mailbox SearchQueryInitiated)per-mailbox-EnablePremiumAuditAdds the SearchQueryInitiated audit event to nominated mailboxes. Requires E5 / Audit (Premium) licensing.
-
- - -
-

4 Sensitivity labels created

-

Ten labels — Public plus three label groups (General, Confidential, - Highly Confidential), each with two assignable children — ordered from least sensitive - (top) to most sensitive (bottom). Labels are identified/created by Microsoft's stable - internal signature so the same taxonomy lands correctly on any tenant, locale, or label - scheme (classic or modern). Encryption is intentionally only applied to - Highly Confidential\All Employees (Template), Highly Confidential\Specific - People and Confidential\Trusted People (both UserDefined); every other - label carries footer/watermark marking only, which is the SMB-friendly profile that - minimises breakage with third-party tooling.

- -

- The Applies to line under each label reflects the scope the script sets. - The four published labels (Public, - General — Anyone (unrestricted), - Confidential — All Employees, - Highly Confidential — All Employees) are the only ones shown in the - Office Sensitivity picker; the other three (General \ All Employees - (unrestricted), Confidential \ Trusted People, Highly - Confidential \ Specific People) are created so DLP can match on them but stay off - the client UI. Of the published set, General — Anyone (unrestricted), - Confidential — All Employees and - Highly Confidential — All Employees ship with - Files · Emails · Groups & sites so they appear in the - Sensitivity picker when a user creates a new Team, M365 Group or SharePoint site; - every other label starts at Files · Emails only. Pass - -SkipContainerLabels to drop the Groups & sites bits - (labels still ship with Files & Emails). You can widen any label later in the - Purview portal without losing the deployment baseline — adoption uses - UNION-not-replace, so any scope you add by hand is preserved on the next re-run. -

- -

- The colored dot beside each label name matches the per-label - Color the script writes to the label (with drift correction - on re-run). Operators see this same color in the Purview portal and end - users see it on the Sensitivity button in Word, Excel, - PowerPoint and Outlook. -

- -
- -
-
1
-
Public - Business data approved for public consumption. No protection. -
Applies to: - 📄Files - ✉️Emails - 👥Groups & sites - 📅Meetings -
-
-
No encryptionNo marking
-
- -
-
2
-
General - Label group — non-applicable container; users pick one of the two children below. -
Applies to: - 📄Files - ✉️Emails - 👥Groups & sites - 📅Meetings -
-
-
Label group
-
-
-
3
-
General — Anyone (unrestricted) - Internal data, may be shared with external partners. No protection. Default for new email. -
Applies to: - 📄Files - ✉️Emails - 👥Groups & sites - 📅Meetings -
-
-
No encryptionNo markingEmail defaultPublished
-
-
-
4
-
General — All Employees (unrestricted) - Internal data, not for external sharing without confirming with the data owner. No protection. Not published by default. -
Applies to: - 📄Files - ✉️Emails - 👥Groups & sites - 📅Meetings -
-
-
No encryptionNo marking
-
- -
-
5
-
Confidential - Label group — non-applicable container; users pick one of the two children below. -
Applies to: - 📄Files - ✉️Emails - 👥Groups & sites - 📅Meetings -
-
-
Label group
-
-
-
6
-
Confidential — All Employees - Footer marking "Classified as Confidential". Default for new documents. -
Applies to: - 📄Files - ✉️Emails - 👥Groups & sites - 📅Meetings -
-
-
No encryptionFooterDocument defaultPublished
-
-
-
7
-
Confidential — Trusted People - User-defined encryption — Outlook applies Encrypt-Only (trusted recipients can reshare); Office prompts the user to pick recipients at apply time. Footer marking added. Not published by default. -
Applies to: - 📄Files - ✉️Emails - 👥Groups & sites - 📅Meetings -
-
-
Encryption (user-defined)Footer
-
- -
-
8
-
Highly Confidential - Label group. Adds watermark "HIGHLY CONFIDENTIAL" to all children. -
Applies to: - 📄Files - ✉️Emails - 👥Groups & sites - 📅Meetings -
-
-
WatermarkLabel group
-
-
-
9
-
Highly Confidential — All Employees - Encrypted with a per-label Co-Author rights bundle scoped to your tenant. All staff can view, edit, save, copy, print, allow macros, reply and forward — wider than the global Reviewer default so Office co-authoring and Microsoft Search work cleanly. -
Applies to: - 📄Files - ✉️Emails - 👥Groups & sites - 📅Meetings -
-
-
Encryption (template)FooterPublished
-
-
-
10
-
Highly Confidential — Specific People - User-defined encryption. Outlook applies Do Not Forward; Office prompts the user to pick recipients. Not published by default. -
Applies to: - 📄Files - ✉️Emails - 👥Groups & sites - 📅Meetings -
-
-
Encryption (user-defined)Footer
-
-
- -
- i -
- Rights bundle: by default encrypted labels grant Microsoft's - Reviewer bundle (View, Edit, Save, Reply, Reply All, Forward). - Highly Confidential — All Employees overrides this with the - wider Co-Author bundle (adds Copy, Print, Allow Macros — - needed for Office co-authoring and any third-party tool that reads doc - metadata via the Office object model). Each label can declare its own - EncryptionRightsDefinitions in PurviewConfig.psd1; - labels without an override fall back to the global default. Edit either - and validate in a pilot tenant before rolling out. -
-
-
- ! -
- Visual content marking is off by default. Footer text and the - "HIGHLY CONFIDENTIAL" watermark are configured on the labels but only render once - you flip EnableContentMarking = $true in PurviewConfig.psd1. - This lets you stage the rollout without surprising end-users. -
-
-
- - -
-

5 Label publishing — what end users see

-

All ten labels are created in the tenant. - Only the labels below are published to end users via the - SMBTool - Default Label Policy. Sub-label names are auto-expanded to include their - parent so the Purview hierarchy stays valid.

- - - - - - - - - - - - - - - - - -
SettingValueWhat it means for end users
Published labelsPublic · General / Anyone (unrestricted) · Confidential / All Employees · Highly Confidential / All EmployeesThe four labels that appear in the Office "Sensitivity" picker for every licensed user.
Default for documentsConfidential / All EmployeesEvery new Word, Excel and PowerPoint document is automatically tagged so it carries the Confidential footer.
Default for emailGeneral / Anyone (unrestricted)Every new Outlook message is pre-tagged General so routine internal mail doesn't trigger DLP. General itself is a non-applicable label group.
Inherit label from attachments ONAttachmentAction = 'Automatic'If an outgoing email has a labelled attachment with a higher-priority label than the email's current label, Outlook silently upgrades the email to match — no prompt.
Mandatory labelling ON$trueOffice prompts the user to pick a sensitivity label before saving a new document or sending a new email. Closes the untagged-content hole that breaks DLP, auto-classification and Copilot exclusions.
Downgrade justification ON$trueUsers must give a written reason when downgrading a label (e.g. Confidential → General).
-
- - -
-

6 DLP policies created

-

Both policies start in simulation - (TestWithoutNotifications) with Purview's native "Turn the policy on if it's not edited - within fifteen days of simulation" flag set. Purview itself owns the countdown; the - toolkit does not auto-enforce.

- -
-
-

Exchange — block Confidential / HC external Default

-
Simulation SMBTool - DLP - Confidential and HC external (EXO)
-
    -
  • Matches every Confidential and Highly Confidential sub-label.
  • -
  • Blocks the message when any recipient is outside the organisation.
  • -
  • Notifies the sender (SiteAdmin, LastModifier, Owner roles).
  • -
  • Incident report goes to SiteAdmin.
  • -
-
-
-

SharePoint + OneDrive — block external share Default

-
Simulation SMBTool - DLP - Confidential and HC external (SPO+ODB)
-
    -
  • Same six labels matched.
  • -
  • Blocks external sharing only (internal collaboration unaffected).
  • -
  • Notifies SiteAdmin, LastModifier, Owner.
  • -
-
-
-

Endpoint DLP — audit copy / print / USB E5 / Purview Suite

-
Simulation SMBTool - DLP - Endpoint Confidential and HC
-
    -
  • Audits Copy/Paste, Print, RemovableMedia and NetworkShare actions on managed devices.
  • -
  • Flip individual entries to Block / Warn / BlockOverride after validating in DLP Activity Explorer.
  • -
  • Rejected automatically when the script runs with -BPOnly.
  • -
-
-
-
- - -
-

7 Retention — opt-in only

-
-

Exchange 7-year retain-then-delete -ApplyRetention Destructive

-
SMBTool - Exchange 7-year retention
-

Retains every mailbox item for 7 years from creation, then permanently deletes it tenant-wide. - Off by default — you must pass -ApplyRetention. The 7-year - default is wrong for some regulated verticals (law, accounting, healthcare, financial advisors, - construction, real estate); confirm against the customer's record-keeping obligations before enabling.

-

See docs/Retention-Default-Risk.md for the decision matrix.

-
-
- - -
-

8 AI governance — auto-detected by licence

-
-

Block Microsoft 365 Copilot from Highly Confidential content Auto-detected

-
SMBTool - AI - Block Copilot for Highly Confidential - · aligns to AI_054
-

Prevents Microsoft 365 Copilot (paid & free Copilot Chat) from grounding, summarising - or returning search results from any document or email labelled Highly Confidential.

-
    -
  • On automatically when the tenant has Microsoft 365 E5 or a Purview Suite SKU - (detected via Graph /subscribedSkus).
  • -
  • Skipped automatically on Business Premium tenants (-BPOnly), - because the Copilot DLP location is not licensed there.
  • -
  • Opt out explicitly with -SkipAIControls on any tenant.
  • -
-
-
- - -
-

9 Safety guarantees — how to re-run without breaking the tenant

-
-
-

Idempotent Re-runnable

-

The script can be re-run any number of times. Existing labels, policies and rules created by - the toolkit are updated in place, not duplicated.

-
-
-

Ownership marker Tagged

-

Every object the toolkit creates carries [Managed by SMBTool Purview Toolkit] in - its description. Untagged objects are left alone unless you pass -AdoptExisting.

-
-
-

Tenant-identity guard Pre-flight

-

After sign-in the script checks the connected tenant's verified domains against the expected - domain (from -DelegatedOrganization or your admin UPN suffix). On mismatch the - script aborts before any write.

-
-
-

Simulation mode DLP only

-

DLP policies are created in TestWithoutNotifications by default. Validate in DLP Activity - Explorer first, then let Purview's 15-day flag flip them to enforce — or set - DlpStartInSimulation = $false in the config to enforce immediately.

-
-
-

HTML & JSON report Every run

-

Each run writes Deploy-PurviewBestPractice-Report-<timestamp>.html and a - machine-readable .json sidecar next to the script. Forward the HTML to the - customer / project manager as evidence.

-
-
-

Secrets stripped Always

-

The report HTML-encodes every value and strips any parameter whose name matches - password / token / secret / credential / key, so logs are safe to email.

-
-
- -
- i -
- Label and policy changes can take up to 24 hours to fully propagate across - Microsoft 365 workloads. Always pilot in a test tenant before production rollout. -
-
-
- -
-
Microsoft Purview Best Practice Toolkit · overview page
-
Source of truth: Products/Purview/Config/PurviewConfig.psd1
-
- -
- - + + + + +What the Purview Best Practice Toolkit does in your tenant + + + + +
+ +
+
Microsoft Purview Best Practice Toolkit
+

What this script does in your tenant

+

A single-page summary of every change the toolkit makes when you run + Deploy-PurviewBestPractice.ps1. Read this before deploying so you (and your + customer) know exactly what will land in the tenant, what is on by default, and what is + explicitly opt-in.

+ +
+ Default ON — always applied + Optional — you must pass a switch + Auto-detected — turned on based on tenant licence + Off by default — safe to ignore + Destructive / deploy-team decision +
+ + +
+ + +
+

1 Run modes — how the script behaves when you launch it

+
+
+

Apply Default

+
No switch needed
+

Every change is applied to the tenant. The script asks for confirmation once at + the start (skip with -NonInteractive).

+
+
+

Preview -WhatIf

+
Read-only
+

Walks through every step and prints what would happen, without writing + anything to the tenant. Use this for change-approval review.

+
+
+

Per-action prompt -Confirm

+
Interactive
+

Pauses before each individual change. Useful when migrating an existing tenant and + you want to inspect each step.

+
+
+

Unattended -NonInteractive

+
CI / automation
+

Skips the preflight confirmation. Combined with -AutoInstallModules + the script runs end-to-end with no human input. Tenant-identity guard still aborts + on mismatch before any write.

+
+
+
+ + +
+

2 Five tasks — what the script touches, in order

+
+
+

1. Tenant settings Default

+

Foundational on/off switches: unified audit log, AIP×SPO integration, PDF labels.

+

Container labels default-on (opt out with -SkipContainerLabels) · -EnablePremiumAudit

+
+
+

2. Sensitivity labels Default

+

Creates 3 label groups (Public is standalone) & 6 assignable children, orders them, applies encryption to Highly Confidential and Confidential\Trusted People, and publishes the user-facing subset.

+

-SkipLabels to opt out

+
+
+

3. DLP Default

+

Two policies block external sharing of Confidential / Highly Confidential content — one for Exchange, one for SharePoint+OneDrive.

+

-SkipDLP to opt out · created in simulation by default

+
+
+

4. Retention Opt-in

+

7-year retain-then-delete on Exchange mailboxes. Off by default — you must pass -ApplyRetention after confirming the duration matches the customer's vertical.

+

Destructive deletes mail older than 7 years

+
+
+

5. AI governance Auto-detected

+

Blocks Microsoft 365 Copilot from grounding on Highly Confidential content. Default ON for E5 / Purview Suite tenants; auto-skipped on Business Premium.

+

-SkipAIControls · -BPOnly hard-skip

+
+
+
+ + +
+

3 Tenant settings applied

+ + + + + + + + + + + + + + + + +
SettingValueModeWhat it does in your tenant
Unified Audit LogEnabledDefaultTurns on the cross-workload activity log used by every Purview investigation, eDiscovery and DLP report.
AIP integration in SharePoint OnlineEnabledDefaultLets SPO/OneDrive open, index and search content encrypted by sensitivity labels.
Sensitivity label for PDFEnabledDefaultOffice can apply (and persist) sensitivity labels when saving as PDF.
Label co-authoringEnabled-EnableLabelCoAuthoringTenant-wide switch that lets multiple users edit encrypted Office files at once. One-way change — disabling it later loses labels on unencrypted Office files, breaks third-party tools that read labels from the old metadata location (AIP scanner < v3.0, OneDrive sync < 19.002, MIP SDK < 1.7, custom DLP scanners, custom Exchange mail-flow rules). Off by default.
Container labels (Teams / Sites / Groups)Group.Unified.EnableMIPLabelsDefaultLets sensitivity labels be applied to M365 Groups, Teams and SharePoint sites. Requires Microsoft Graph beta consent. Enabled by default on every run — Business Premium is the toolkit's licensing floor and BP includes Entra ID P1, the AAD-side requirement. Pass -SkipContainerLabels to opt out. License auto-detect skips it automatically when the tenant has no recognised M365 BP/E5/Purview Suite SKU.
Premium audit (mailbox SearchQueryInitiated)per-mailbox-EnablePremiumAuditAdds the SearchQueryInitiated audit event to nominated mailboxes. Requires E5 / Audit (Premium) licensing.
+
+ + +
+

4 Sensitivity labels created

+

Ten labels — Public plus three label groups (General, Confidential, + Highly Confidential), each with two assignable children — ordered from least sensitive + (top) to most sensitive (bottom). Labels are identified/created by Microsoft's stable + internal signature so the same taxonomy lands correctly on any tenant, locale, or label + scheme (classic or modern). Encryption is intentionally only applied to + Highly Confidential\All Employees (Template), Highly Confidential\Specific + People and Confidential\Trusted People (both UserDefined); every other + label carries footer/watermark marking only, which is the SMB-friendly profile that + minimises breakage with third-party tooling.

+ +

+ The Applies to line under each label reflects the scope the script sets. + The four published labels (Public, + General — Anyone (unrestricted), + Confidential — All Employees, + Highly Confidential — All Employees) are the only ones shown in the + Office Sensitivity picker; the other three (General \ All Employees + (unrestricted), Confidential \ Trusted People, Highly + Confidential \ Specific People) are created so DLP can match on them but stay off + the client UI. Of the published set, General — Anyone (unrestricted), + Confidential — All Employees and + Highly Confidential — All Employees ship with + Files · Emails · Groups & sites so they appear in the + Sensitivity picker when a user creates a new Team, M365 Group or SharePoint site; + every other label starts at Files · Emails only. Pass + -SkipContainerLabels to drop the Groups & sites bits + (labels still ship with Files & Emails). You can widen any label later in the + Purview portal without losing the deployment baseline — adoption uses + UNION-not-replace, so any scope you add by hand is preserved on the next re-run. +

+ +

+ The colored dot beside each label name matches the per-label + Color the script writes to the label (with drift correction + on re-run). Operators see this same color in the Purview portal and end + users see it on the Sensitivity button in Word, Excel, + PowerPoint and Outlook. +

+ +
+ +
+
1
+
Public + Business data approved for public consumption. No protection. +
Applies to: + 📄Files + ✉️Emails + 👥Groups & sites + 📅Meetings +
+
+
No encryptionNo marking
+
+ +
+
2
+
General + Label group — non-applicable container; users pick one of the two children below. +
Applies to: + 📄Files + ✉️Emails + 👥Groups & sites + 📅Meetings +
+
+
Label group
+
+
+
3
+
General — Anyone (unrestricted) + Internal data, may be shared with external partners. No protection. Default for new email. +
Applies to: + 📄Files + ✉️Emails + 👥Groups & sites + 📅Meetings +
+
+
No encryptionNo markingEmail defaultPublished
+
+
+
4
+
General — All Employees (unrestricted) + Internal data, not for external sharing without confirming with the data owner. No protection. Not published by default. +
Applies to: + 📄Files + ✉️Emails + 👥Groups & sites + 📅Meetings +
+
+
No encryptionNo marking
+
+ +
+
5
+
Confidential + Label group — non-applicable container; users pick one of the two children below. +
Applies to: + 📄Files + ✉️Emails + 👥Groups & sites + 📅Meetings +
+
+
Label group
+
+
+
6
+
Confidential — All Employees + Footer marking "Classified as Confidential". Default for new documents. +
Applies to: + 📄Files + ✉️Emails + 👥Groups & sites + 📅Meetings +
+
+
No encryptionFooterDocument defaultPublished
+
+
+
7
+
Confidential — Trusted People + User-defined encryption — Outlook applies Encrypt-Only (trusted recipients can reshare); Office prompts the user to pick recipients at apply time. Footer marking added. Not published by default. +
Applies to: + 📄Files + ✉️Emails + 👥Groups & sites + 📅Meetings +
+
+
Encryption (user-defined)Footer
+
+ +
+
8
+
Highly Confidential + Label group. Adds watermark "HIGHLY CONFIDENTIAL" to all children. +
Applies to: + 📄Files + ✉️Emails + 👥Groups & sites + 📅Meetings +
+
+
WatermarkLabel group
+
+
+
9
+
Highly Confidential — All Employees + Encrypted with a per-label Co-Author rights bundle scoped to your tenant. All staff can view, edit, save, copy, print, allow macros, reply and forward — wider than the global Reviewer default so Office co-authoring and Microsoft Search work cleanly. +
Applies to: + 📄Files + ✉️Emails + 👥Groups & sites + 📅Meetings +
+
+
Encryption (template)FooterPublished
+
+
+
10
+
Highly Confidential — Specific People + User-defined encryption. Outlook applies Do Not Forward; Office prompts the user to pick recipients. Not published by default. +
Applies to: + 📄Files + ✉️Emails + 👥Groups & sites + 📅Meetings +
+
+
Encryption (user-defined)Footer
+
+
+ +
+ i +
+ Rights bundle: by default encrypted labels grant Microsoft's + Reviewer bundle (View, Edit, Save, Reply, Reply All, Forward). + Highly Confidential — All Employees overrides this with the + wider Co-Author bundle (adds Copy, Print, Allow Macros — + needed for Office co-authoring and any third-party tool that reads doc + metadata via the Office object model). Each label can declare its own + EncryptionRightsDefinitions in PurviewConfig.psd1; + labels without an override fall back to the global default. Edit either + and validate in a pilot tenant before rolling out. +
+
+
+ ! +
+ Visual content marking is off by default. Footer text and the + "HIGHLY CONFIDENTIAL" watermark are configured on the labels but only render once + you flip EnableContentMarking = $true in PurviewConfig.psd1. + This lets you stage the rollout without surprising end-users. +
+
+
+ + +
+

5 Label publishing — what end users see

+

All ten labels are created in the tenant. + Only the labels below are published to end users via the + SMBTool - Default Label Policy. Sub-label names are auto-expanded to include their + parent so the Purview hierarchy stays valid.

+ + + + + + + + + + + + + + + + + +
SettingValueWhat it means for end users
Published labelsPublic · General / Anyone (unrestricted) · Confidential / All Employees · Highly Confidential / All EmployeesThe four labels that appear in the Office "Sensitivity" picker for every licensed user.
Default for documentsConfidential / All EmployeesEvery new Word, Excel and PowerPoint document is automatically tagged so it carries the Confidential footer.
Default for emailGeneral / Anyone (unrestricted)Every new Outlook message is pre-tagged General so routine internal mail doesn't trigger DLP. General itself is a non-applicable label group.
Inherit label from attachments ONAttachmentAction = 'Automatic'If an outgoing email has a labelled attachment with a higher-priority label than the email's current label, Outlook silently upgrades the email to match — no prompt.
Mandatory labelling ON$trueOffice prompts the user to pick a sensitivity label before saving a new document or sending a new email. Closes the untagged-content hole that breaks DLP, auto-classification and Copilot exclusions.
Downgrade justification ON$trueUsers must give a written reason when downgrading a label (e.g. Confidential → General).
+
+ + +
+

6 DLP policies created

+

Both policies start in simulation + (TestWithoutNotifications) with Purview's native "Turn the policy on if it's not edited + within fifteen days of simulation" flag set. Purview itself owns the countdown; the + toolkit does not auto-enforce.

+ +
+
+

Exchange — block Confidential / HC external Default

+
Simulation SMBTool - DLP - Confidential and HC external (EXO)
+
    +
  • Matches every Confidential and Highly Confidential sub-label.
  • +
  • Blocks the message when any recipient is outside the organisation.
  • +
  • Notifies the sender (SiteAdmin, LastModifier, Owner roles).
  • +
  • Incident report goes to SiteAdmin.
  • +
+
+
+

SharePoint + OneDrive — block external share Default

+
Simulation SMBTool - DLP - Confidential and HC external (SPO+ODB)
+
    +
  • Same six labels matched.
  • +
  • Blocks external sharing only (internal collaboration unaffected).
  • +
  • Notifies SiteAdmin, LastModifier, Owner.
  • +
+
+
+

Endpoint DLP — audit copy / print / USB E5 / Purview Suite

+
Simulation SMBTool - DLP - Endpoint Confidential and HC
+
    +
  • Audits Copy/Paste, Print, RemovableMedia and NetworkShare actions on managed devices.
  • +
  • Flip individual entries to Block / Warn / BlockOverride after validating in DLP Activity Explorer.
  • +
  • Rejected automatically when the script runs with -BPOnly.
  • +
+
+
+
+ + +
+

7 Retention — opt-in only

+
+

Exchange 7-year retain-then-delete -ApplyRetention Destructive

+
SMBTool - Exchange 7-year retention
+

Retains every mailbox item for 7 years from creation, then permanently deletes it tenant-wide. + Off by default — you must pass -ApplyRetention. The 7-year + default is wrong for some regulated verticals (law, accounting, healthcare, financial advisors, + construction, real estate); confirm against the customer's record-keeping obligations before enabling.

+

See docs/Retention-Default-Risk.md for the decision matrix.

+
+
+ + +
+

8 AI governance — auto-detected by licence

+
+

Block Microsoft 365 Copilot from Highly Confidential content Auto-detected

+
SMBTool - AI - Block Copilot for Highly Confidential + · aligns to AI_054
+

Prevents Microsoft 365 Copilot (paid & free Copilot Chat) from grounding, summarising + or returning search results from any document or email labelled Highly Confidential.

+
    +
  • On automatically when the tenant has Microsoft 365 E5 or a Purview Suite SKU + (detected via Graph /subscribedSkus).
  • +
  • Skipped automatically on Business Premium tenants (-BPOnly), + because the Copilot DLP location is not licensed there.
  • +
  • Opt out explicitly with -SkipAIControls on any tenant.
  • +
+
+
+ + +
+

9 Safety guarantees — how to re-run without breaking the tenant

+
+
+

Idempotent Re-runnable

+

The script can be re-run any number of times. Existing labels, policies and rules created by + the toolkit are updated in place, not duplicated.

+
+
+

Ownership marker Tagged

+

Every object the toolkit creates carries [Managed by SMBTool Purview Toolkit] in + its description. Untagged objects are left alone unless you pass -AdoptExisting.

+
+
+

Tenant-identity guard Pre-flight

+

After sign-in the script checks the connected tenant's verified domains against the expected + domain (from -DelegatedOrganization or your admin UPN suffix). On mismatch the + script aborts before any write.

+
+
+

Simulation mode DLP only

+

DLP policies are created in TestWithoutNotifications by default. Validate in DLP Activity + Explorer first, then let Purview's 15-day flag flip them to enforce — or set + DlpStartInSimulation = $false in the config to enforce immediately.

+
+
+

Deployment Plan HTML & JSON Before sign-in

+

Each invocation writes an offline plan from config and switches. It includes fingerprints, + action intent, the supplied Good / Better / Best SMB guide comparison, and a separate + Microsoft Learn supporting reference. A short plan reference identifies the HTML and JSON + pair. The JSON carries a sanitized intended-state snapshot for a dependent stacked + tenant-validation feature. It remains private customer-sensitive evidence because configured + names needed for matching can appear. The snapshot uses the canonical + Microsoft365Copilot token for the public Copilot location, opaque digests for + custom AI locations, records unsupported retention destinations separately, and rejects malformed + built-in label signatures before sign-in. + The plan does not inspect tenant state or declare compliance. + The Action Preview labels the guide tiers as + Deployment Priority Level values: Priority 1 (Good), Priority 2 (Better), and Priority 3 + (Best), and includes a closed explanation panel for intent, priority, and comparison terms. + Good includes audit, baseline labels, container and SharePoint/OneDrive label enablement, + and core DLP; Better adds Exchange retention; Best adds advanced DLP, auto-labeling, + encryption, custom SITs, and DSPM. Unassigned capabilities are labeled as toolkit + extensions.

+
+
+

Configuration Validation HTML & JSON Separate, read-only

+

Test-PurviewTenantConfiguration.ps1 is a separate command you run later. It reads the + tenant and compares it with schema 1.2 intended state, or runs a guide-only assessment, reporting each action as Matched, Drift, + Not evaluated, Informational or Collection failed. It changes nothing and it is not a + compliance assessment. It also refuses a Graph token with resource permissions beyond its two documented read scopes; retry from a fresh PowerShell session first, then use an approved isolated public client if the shared Graph client still returns broader consent.

+
+
+

HTML & JSON report Every run

+

Each run writes Deploy-PurviewBestPractice-Report-<timestamp>.html and a + machine-readable .json sidecar to the selected output location. This separate + report records what the tenant deployment actually did.

+
+
+

Secrets stripped Always

+

The report HTML-encodes every value and strips any parameter whose name matches + password / token / secret / credential / key, so logs are safe to email.

+
+
+ +
+ i +
+ Label and policy changes can take up to 24 hours to fully propagate across + Microsoft 365 workloads. Always pilot in a test tenant before production rollout. +
+
+
+ +
+
Microsoft Purview Best Practice Toolkit · overview page
+
Source of truth: Products/Purview/Config/PurviewConfig.psd1
+
+ +
+ + diff --git a/Products/Purview/docs/What-This-Tool-Does.md b/Products/Purview/docs/What-This-Tool-Does.md index 91672ef..d74b148 100644 --- a/Products/Purview/docs/What-This-Tool-Does.md +++ b/Products/Purview/docs/What-This-Tool-Does.md @@ -1,57 +1,92 @@ ---- -title: What's Changing - Support Team Guide -layout: default -parent: Purview -nav_order: 1 -permalink: /purview/whats-changing/ ---- - -# What's Changing — Support Team Guide -{: .no_toc } - -A single-screen, **visual** summary of every change the Purview toolkit makes -when it runs — what's **on by default**, what's **opt-in**, what's -**auto-detected from the licence**, and what's **destructive**. Colour-coded so -it's scannable. - -**Who this is for:** the **support team / service desk**. Read it -*before* a deployment so you know exactly what lands in the customer's tenant — -and, just as importantly, **what does _not_ change** — so you're ready for any -support call about the rollout (new labels in Office, "why can't I share this -file externally", the 30-day simulation window, Copilot behaviour, etc.). - -> 🧭 **Need the full technical detail?** This page is the at-a-glance impact -> view. For the complete scenario matrix, per-licence behaviour, and what's -> deliberately out of scope, see -> [**Scenarios & Capabilities**](scenarios/). For the day-by-day delivery -> runbook, see the -> [**Change-Management Playbook**](change-management-playbook/). - -[Open full-screen ↗]({{ '/Products/Purview/docs/What-This-Tool-Does.html' | relative_url }}){: .btn .btn-purple target="_blank" } -[Download the standalone file ↗]({{ '/Products/Purview/docs/What-This-Tool-Does.html' | relative_url }}){: .btn target="_blank" download } - -> 💡 The guide below is a **single self-contained HTML file** -> (`Products/Purview/docs/What-This-Tool-Does.html`). It needs no server, so you -> can share it with the support team on its own, pin it in a Teams channel, or -> open it offline. - - - ---- - -## How this differs from "Scenarios & Capabilities" -{: .no_toc } - -| | **This page** — What's Changing | [Scenarios & Capabilities](scenarios/) | -|---|---|---| -| **Audience** | Support team / service desk | Evaluators, reviewers, security leads | -| **Angle** | What lands in the tenant · on / off / auto · **impact & no-impact** | Full scenario matrix · per-licence behaviour · **out-of-scope** | -| **Format** | Visual, colour-coded, scan-in-2-minutes | Reference prose + tables | - -Use this one to **get the support team ready**; use Scenarios & Capabilities -when you need the exhaustive technical reference. +--- +title: What's Changing - Support Team Guide +layout: default +parent: Purview +nav_order: 1 +permalink: /purview/whats-changing/ +--- + +# What's Changing — Support Team Guide +{: .no_toc } + +A single-screen, **visual** summary of every change the Purview toolkit makes +when it runs — what's **on by default**, what's **opt-in**, what's +**auto-detected from the licence**, and what's **destructive**. Colour-coded so +it's scannable. + +**Who this is for:** the **support team / service desk**. Read it +*before* a deployment so you know exactly what lands in the customer's tenant — +and, just as importantly, **what does _not_ change** — so you're ready for any +support call about the rollout (new labels in Office, "why can't I share this +file externally", the 30-day simulation window, Copilot behaviour, etc.). + +> 🧭 **Need the full technical detail?** This page is the at-a-glance impact +> view. For the complete scenario matrix, per-licence behaviour, and what's +> deliberately out of scope, see +> [**Scenarios & Capabilities**](scenarios/). For the day-by-day delivery +> runbook, see the +> [**Change-Management Playbook**](change-management-playbook/). + +[Open full-screen ↗]({{ '/Products/Purview/docs/What-This-Tool-Does.html' | relative_url }}){: .btn .btn-purple target="_blank" } +[Download the standalone file ↗]({{ '/Products/Purview/docs/What-This-Tool-Does.html' | relative_url }}){: .btn target="_blank" download } + +> 💡 The guide below is a **single self-contained HTML file** +> (`Products/Purview/docs/What-This-Tool-Does.html`). It needs no server, so you +> can share it with the support team on its own, pin it in a Teams channel, or +> open it offline. + + + +--- + +## How this differs from "Scenarios & Capabilities" +{: .no_toc } + +| | **This page** — What's Changing | [Scenarios & Capabilities](scenarios/) | +|---|---|---| +| **Audience** | Support team / service desk | Evaluators, reviewers, security leads | +| **Angle** | What lands in the tenant · on / off / auto · **impact & no-impact** | Full scenario matrix · per-licence behaviour · **out-of-scope** | +| **Format** | Visual, colour-coded, scan-in-2-minutes | Reference prose + tables | + +Use this one to **get the support team ready**; use Scenarios & Capabilities +when you need the exhaustive technical reference. + +## Reports operators receive + +Before authentication, the toolkit writes a self-contained Deployment Plan HTML +file and matching JSON sidecar. They summarize config intent, effective +switches, fingerprints, and action mappings to the supplied Good, Better, and +Best SMB guide. Microsoft Learn remains visible as a separate supporting +reference. A short `PUR-...` reference identifies the generated pair, and the +JSON carries a sanitized intended-state snapshot for a dependent stacked +tenant-validation feature. The snapshot includes matching fields needed for +later comparison but remains private customer-sensitive evidence. The plan +does not inspect the tenant or claim compliance. It uses the canonical +`Microsoft365Copilot` token for the public Copilot location and opaque digests +for custom locations, records unsupported retention destinations separately, +and fails local preflight when a non-empty built-in label signature is +malformed. +The Action Preview labels these as Deployment Priority Level values: Priority 1 +(Good), Priority 2 (Better), and Priority 3 (Best), and provides a closed +explanation panel for the intent, priority, and comparison vocabulary. +Good includes audit, baseline labels, container and SharePoint/OneDrive label +enablement, and core DLP; Better adds Exchange retention; Best adds advanced +DLP, auto-labeling, encryption, custom SITs, and DSPM. Unassigned capabilities +are labeled as toolkit extensions. + +After deployment, a separate HTML and JSON report records the actions that +actually ran, skipped, retried, or failed. `-NoDeploymentPlan` suppresses only +the pre-connection plan. `-NoReport` suppresses only the end-of-run report. + +Later, `Test-PurviewTenantConfiguration.ps1` reads the tenant and can compare it with one +Deployment Plan. It is a separate read-only command, it changes nothing, and it +is not a compliance assessment. It also refuses a Graph token that contains +resource permissions beyond its two documented read scopes; an approved +fresh PowerShell sign-in is attempted first, and an isolated public client can +be supplied when the shared Graph client still has broader consent. See +[Configuration Validation](configuration-validation/). diff --git a/README.md b/README.md index 1bc76fb..864bcff 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,73 @@ -# SMBBestPracticeTool - -PowerShell automation that configures Microsoft 365 tenants against -Microsoft's recommended best-practice baselines for Small / Medium customers. - -Built for repeatable, idempotent configuration of Microsoft 365 tenants — by -partners/MSPs and in-house IT teams alike. - -## Products - -| Product | Toolkit | Docs | Status | -|---------|---------|------|--------| -| Microsoft Purview (Data Security baseline for Business Premium) | [`Products/Purview/`](Products/Purview/README.md) | [Site](https://microsoft.github.io/BestPractice_Deploy-Scripts/purview/) | Available | - -> 📖 **Full documentation site:** -> — searchable HTML version of every product's docs, with sidebar -> navigation and per-product landing pages. Source markdown stays in -> `Products//docs/`; the site is built by GitHub Pages from -> the same files. - -## Prerequisites (at a glance) - -Each product toolkit documents its own detailed prerequisites in its README. -Common requirements across all toolkits: - -* **PowerShell 7+** (`pwsh.exe`) — Windows PowerShell 5.1 is **not supported**. - Install with `winget install --id Microsoft.PowerShell` or from - . -* **Tenant admin credentials** with the appropriate role(s) for the product - being deployed (see each product's README for the exact role mapping). -* **Required PowerShell modules** — the toolkits auto-detect missing modules - and offer to install them from PSGallery on first run. Pass - `-AutoInstallModules` to install silently, or install manually beforehand - using the commands listed in each product's README. - -For the full prerequisite list (licensing, admin roles, modules) for the -Purview toolkit, see [`Products/Purview/README.md`](Products/Purview/README.md#prerequisites). - -## Quick start - -```powershell -# Clone the repo, then run the toolkit for the product you want to configure. -# Example: Purview baseline against a Business Premium tenant. -cd Products/Purview -.\Deploy-PurviewBestPractice.ps1 -TenantAdminUpn admin@contoso.onmicrosoft.com -WhatIf -``` - -> ⚠️ **Always pilot in a test tenant before applying to production.** Many -> changes are tenant-wide and can take up to 24 hours to fully propagate. - -## License - -See [`LICENSE`](LICENSE). - -## Disclaimer - -This sample script is not supported under any Microsoft standard support -program or service. The sample script is provided AS IS without warranty of -any kind. Microsoft further disclaims all implied warranties including, -without limitation, any implied warranties of merchantability or of fitness -for a particular purpose. The entire risk arising out of the use or -performance of the sample scripts and documentation remains with you. In no -event shall Microsoft, its authors, or anyone else involved in the creation, -production, or delivery of the scripts be liable for any damages whatsoever -(including, without limitation, damages for loss of business profits, -business interruption, loss of business information, or other pecuniary loss) -arising out of the use of or inability to use the sample scripts or -documentation, even if Microsoft has been advised of the possibility of such -damages. - -Please do not contact Microsoft support with any issues or concerns regarding -this script. +# SMBBestPracticeTool + +PowerShell automation that configures Microsoft 365 tenants against +Microsoft's recommended best-practice baselines for Small / Medium customers. + +Built for repeatable, idempotent configuration of Microsoft 365 tenants — by +partners/MSPs and in-house IT teams alike. + +## Products + +| Product | Toolkit | Docs | Status | +|---------|---------|------|--------| +| Microsoft Purview (Data Security baseline for Business Premium) | [`Products/Purview/`](Products/Purview/README.md) | [Site](https://microsoft.github.io/BestPractice_Deploy-Scripts/purview/) | Available | +| Microsoft Defender (read-only default with pilot-validated ASR Audit configuration) | [`Products/Defender/`](Products/Defender/README.md) | [Site](https://microsoft.github.io/BestPractice_Deploy-Scripts/defender/) | Candidate; release authorization required | +| Microsoft Intune (device management & enrollment baseline) | [`Products/Intune/`](Products/Intune/README.md) | [Site](https://microsoft.github.io/BestPractice_Deploy-Scripts/intune/) | Candidate; release authorization required | +| Microsoft Entra (Conditional Access baseline for Business Premium) | [`Products/Entra/`](Products/Entra/README.md) | [Site](https://microsoft.github.io/BestPractice_Deploy-Scripts/entra/) | Candidate; release authorization required | + +> 📖 **Full documentation site:** +> — searchable HTML version of every product's docs, with sidebar +> navigation and per-product landing pages. Source markdown stays in +> `Products//docs/`; the site is built by GitHub Pages from +> the same files. + +## Prerequisites (at a glance) + +Each product toolkit documents its own detailed prerequisites in its README. +Common requirements across all toolkits: + +* **PowerShell 7+** (`pwsh.exe`) — Windows PowerShell 5.1 is **not supported**. + Install with `winget install --id Microsoft.PowerShell` or from + . +* **Tenant admin credentials** with the appropriate role(s) for the product + being deployed (see each product's README for the exact role mapping). +* **Required PowerShell modules**: follow the selected product's README. + Use `-AutoInstallModules` only where the product documents support for it. + +For the full prerequisite list (licensing, admin roles, modules) for the +Purview toolkit, see [`Products/Purview/README.md`](Products/Purview/README.md#prerequisites). + +## Quick start + +```powershell +# Clone the repo, then run the toolkit for the product you want to configure. +# Example: Purview baseline against a Business Premium tenant. +cd Products/Purview +.\Deploy-PurviewBestPractice.ps1 -TenantAdminUpn admin@contoso.onmicrosoft.com -WhatIf +``` + +> ⚠️ **Always pilot in a test tenant before applying to production.** Many +> changes are tenant-wide and can take up to 24 hours to fully propagate. + +## License + +See [`LICENSE`](LICENSE). + +## Disclaimer + +This sample script is not supported under any Microsoft standard support +program or service. The sample script is provided AS IS without warranty of +any kind. Microsoft further disclaims all implied warranties including, +without limitation, any implied warranties of merchantability or of fitness +for a particular purpose. The entire risk arising out of the use or +performance of the sample scripts and documentation remains with you. In no +event shall Microsoft, its authors, or anyone else involved in the creation, +production, or delivery of the scripts be liable for any damages whatsoever +(including, without limitation, damages for loss of business profits, +business interruption, loss of business information, or other pecuniary loss) +arising out of the use of or inability to use the sample scripts or +documentation, even if Microsoft has been advised of the possibility of such +damages. + +Please do not contact Microsoft support with any issues or concerns regarding +this script. diff --git a/_config.yml b/_config.yml index 1438709..8c71013 100644 --- a/_config.yml +++ b/_config.yml @@ -1,105 +1,122 @@ -title: SMB Best Practice Tool -description: >- - PowerShell automation that configures Microsoft 365 tenants against - Microsoft's recommended best-practice baselines for Small / Medium customers. -url: "https://microsoft.github.io" -baseurl: "/BestPractice_Deploy-Scripts" - -# Build from the repository root so existing docs in Products//docs/ -# remain the single source of truth. No duplication, no build script. -# GitHub Pages must be configured to "Deploy from a branch" -> root. - -remote_theme: just-the-docs/just-the-docs@v0.10.0 -color_scheme: light - -# just-the-docs nav / search behaviour -search_enabled: true -search: - heading_level: 3 - previews: 3 - preview_words_before: 5 - preview_words_after: 10 -heading_anchors: true -back_to_top: true -back_to_top_text: "Back to top" - -# Footer -footer_content: >- - © Microsoft partner sample. Provided AS IS with no warranty. - See repository - for the LICENSE and disclaimer. - -aux_links: - "GitHub": - - "https://github.com/microsoft/BestPractice_Deploy-Scripts" - -# What Jekyll should turn into pages. Anything not in include/ and not in the -# default Jekyll-tracked locations is ignored. Anything in exclude wins. -include: - - Products -exclude: - # Runtime PowerShell — never part of the docs site. - - "**/*.ps1" - - "**/*.psd1" - - "**/*.psm1" - # Per-run deployment reports (also gitignored, but defensive). - - "**/Deploy-*-Report-*.html" - - "**/Deploy-*-Report-*.json" - # Standalone HTML guides are published (embedded in their docs pages). - # Any .docx companion, if one is added later, should stay excluded (binary download). - # Code / data / sample directories — not docs. - - "Products/Purview/Modules" - - "Products/Purview/Config" - - "Products/Purview/Examples" - - "Products/Purview/AdHoc" - # Repo housekeeping. - - LICENSE - - Gemfile - - Gemfile.lock - - vendor - - node_modules - - .vscode - - Feedback - - Skills - - Design-Notes - - README.md # Site landing page lives in index.md; root README is GitHub-only. - -# Apply layout + nav defaults to existing markdown files so we don't have to -# touch most of them. Per-page front matter (title, nav_order, parent) is -# added in-place to the files that need to appear in the sidebar. -defaults: - - scope: - path: "" - type: "pages" - values: - layout: "default" - -plugins: - - jekyll-remote-theme - - jekyll-seo-tag - - jekyll-sitemap - - jekyll-relative-links - - jekyll-default-layout - - jekyll-titles-from-headings - -# Rewrite relative `*.md` links inside the existing docs to the rendered -# HTML URLs at build time, so we don't have to edit any cross-references -# inside the source markdown. -relative_links: - enabled: true - collections: true -titles_from_headings: - enabled: true - strip_title: false - collections: true - -# Markdown / kramdown — match GitHub's renderer as closely as possible. -markdown: kramdown -kramdown: - input: GFM - hard_wrap: false - syntax_highlighter: rouge - -# Don't try to publish drafts or future-dated posts. -future: false -show_drafts: false +title: SMB Best Practice Tool +description: >- + PowerShell automation that configures Microsoft 365 tenants against + Microsoft's recommended best-practice baselines for Small / Medium customers. +url: "https://microsoft.github.io" +baseurl: "/BestPractice_Deploy-Scripts" + +# Build from the repository root so existing docs in Products//docs/ +# remain the single source of truth. No duplication, no build script. +# GitHub Pages must be configured to "Deploy from a branch" -> root. + +remote_theme: just-the-docs/just-the-docs@v0.10.0 +color_scheme: light + +# just-the-docs nav / search behaviour +search_enabled: true +search: + heading_level: 3 + previews: 3 + preview_words_before: 5 + preview_words_after: 10 +heading_anchors: true +back_to_top: true +back_to_top_text: "Back to top" + +# Footer +footer_content: >- + © Microsoft partner sample. Provided AS IS with no warranty. + See repository + for the LICENSE and disclaimer. + +aux_links: + "GitHub": + - "https://github.com/microsoft/BestPractice_Deploy-Scripts" + +# What Jekyll should turn into pages. Anything not in include/ and not in the +# default Jekyll-tracked locations is ignored. Anything in exclude wins. +include: + - Products +exclude: + # Source-package files that must not become documentation pages. + - "Products/*/Tests" + - "Products/*/Reports" + - "Products/*/Modules" + - "Products/*/Config" + - "Products/*/References" + - "Products/*/AdHoc" + - "Products/*/Examples" + - "**/Deploy-*-Plan-*.html" + - "**/Deploy-*-Plan-*.json" + - "**/Test-PurviewTenantConfiguration-*.html" + - "**/Test-PurviewTenantConfiguration-*.json" + - "**/*.docx" + - "Plans" + - "docs" + - "scripts" + - "AGENTS.md" + # Runtime PowerShell — never part of the docs site. + - "**/*.ps1" + - "**/*.psd1" + - "**/*.psm1" + # Per-run deployment reports (also gitignored, but defensive). + - "**/Deploy-*-Report-*.html" + - "**/Deploy-*-Report-*.json" + # Standalone HTML guides are published (embedded in their docs pages). + # Any .docx companion, if one is added later, should stay excluded (binary download). + # Code / data / sample directories — not docs. + - "Products/Purview/Modules" + - "Products/Purview/Config" + - "Products/Purview/Examples" + - "Products/Purview/AdHoc" + # Repo housekeeping. + - LICENSE + - Gemfile + - Gemfile.lock + - vendor + - node_modules + - .vscode + - Feedback + - Skills + - Design-Notes + - README.md # Site landing page lives in index.md; root README is GitHub-only. + +# Apply layout + nav defaults to existing markdown files so we don't have to +# touch most of them. Per-page front matter (title, nav_order, parent) is +# added in-place to the files that need to appear in the sidebar. +defaults: + - scope: + path: "" + type: "pages" + values: + layout: "default" + +plugins: + - jekyll-remote-theme + - jekyll-seo-tag + - jekyll-sitemap + - jekyll-relative-links + - jekyll-default-layout + - jekyll-titles-from-headings + +# Rewrite relative `*.md` links inside the existing docs to the rendered +# HTML URLs at build time, so we don't have to edit any cross-references +# inside the source markdown. +relative_links: + enabled: true + collections: true +titles_from_headings: + enabled: true + strip_title: false + collections: true + +# Markdown / kramdown — match GitHub's renderer as closely as possible. +markdown: kramdown +kramdown: + input: GFM + hard_wrap: false + syntax_highlighter: rouge + +# Don't try to publish drafts or future-dated posts. +future: false +show_drafts: false diff --git a/docs/conventions.md b/docs/conventions.md index a160b77..8f3f4c9 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -14,8 +14,8 @@ rather than copy it. PowerShell automation that configures Microsoft 365 tenants against Microsoft's recommended best-practice baselines. `Products//` is the layout for -each product; today the only shipped product is **Purview** -(`Products/Purview/`). Everything below is Purview-specific unless noted. +each product. See each product README for its release status and supported +scope. The deployment-flow examples below describe Purview. Within each product, `Deploy-*.ps1` is the orchestrator, `Modules/` holds task scripts and shared helpers, `Config/` holds PowerShell data files, `AdHoc/` @@ -31,8 +31,18 @@ pwsh scripts/verify.ps1 This syntax-checks every `*.ps1` under `Products/` and validates every `*.psd1` configuration as a PowerShell data file. It needs no tenant connection and does -not execute deployment scripts. This repo has no Pester/unit-test suite or -automated tenant-behavior tests; a successful static check is not tenant validation. +not execute deployment scripts. Run the offline regression checks separately: + +```powershell +pwsh -NoProfile -File .\scripts\Test-IdentityReviewFixes.ps1 +pwsh -NoProfile -File .\scripts\Test-IntuneReviewFixes.ps1 +pwsh -NoProfile -File .\scripts\Test-DefenderConnectionGuards.ps1 +``` + +These plain PowerShell checks use synthetic Graph responses and require no +Pester installation or credentials. They cover the corrected deployment paths, +not every product feature. Neither parsing nor mocked execution is tenant +validation. For deployment-code changes, completion also requires validation of the affected deployment path against an approved non-production tenant. Do not connect to a diff --git a/index.md b/index.md index e44f799..76e5cdb 100644 --- a/index.md +++ b/index.md @@ -1,88 +1,116 @@ ---- -title: Home -layout: home -nav_order: 1 -description: "Best-practice deployment toolkits for Microsoft 365 SMB tenants." -permalink: / ---- - -# SMB Best Practice Tool - -PowerShell automation that configures Microsoft 365 tenants against -Microsoft's recommended best-practice baselines for **Small / Medium** -customers. Built for repeatable, idempotent configuration of Microsoft 365 -tenants — by partners/MSPs and in-house IT teams alike. - -> ⚠️ **Always pilot in a test tenant before applying to production.** Many -> changes are tenant-wide and can take up to 24 hours to fully propagate. - ---- - -## Available product toolkits - -| Product | Toolkit | Status | -|---|---|---| -| [Microsoft Purview](purview/) — Data Security baseline (license-aware: Business Premium and up) | `Products/Purview/` | ✅ Available | -| Microsoft Entra | `Products/Entra/` | 🔜 Planned | -| Microsoft Intune | `Products/Intune/` | 🔜 Planned | -| Microsoft Defender | `Products/Defender/` | 🔜 Planned | - -> 🗺️ **See the method at a glance:** the -> [**Purview Deployment Framework**](purview/deployment-framework/) is an -> interactive visual model of how the toolkit deploys — pre-deployment to -> post-deployment, with the simulate-then-promote safety gate front and centre. - -Pick a product from the sidebar (or the table above) for its full -documentation: what it does, prerequisites, how to run, scenarios it -covers, change-management playbook, and end-user adoption material. - -> 🆕 **What's new?** See the [**Changelog**](changelog/) for new features, -> changed defaults, and fixes across releases. - ---- - -## Common prerequisites (all toolkits) - -Each toolkit documents its own detailed prerequisites. Common requirements -across all of them: - -* **PowerShell 7+** (`pwsh.exe`) to **run** the toolkits — the deploy - scripts hard-fail on Windows PowerShell 5.1 because the Exchange - Online v3 REST channel and Microsoft.Graph SDK depend on .NET Core - APIs that PS 5.1 doesn't expose. Install with - `winget install --id Microsoft.PowerShell` or from - . -* **Windows** host with PS 5.1 still present — the - `Microsoft.Online.SharePoint.PowerShell` module is Windows-only and - is loaded automatically via `Import-Module -UseWindowsPowerShell` - (a hidden PS 5.1 sub-process) so its bundled MSAL DLL never enters - PS 7's AppDomain. macOS and Linux are not supported. -* **Tenant admin credentials** with the appropriate role(s) for the - product being deployed (see each product's page for the exact role map). -* **Required PowerShell modules** — the toolkits auto-detect missing - modules and offer to install them from PSGallery on first run. Pass - `-AutoInstallModules` to install silently. - ---- - -## Quick start - -```powershell -# Clone the repo, then run the toolkit for the product you want to configure. -# Example: Purview baseline against a Business Premium tenant. -git clone https://github.com/microsoft/BestPractice_Deploy-Scripts.git -cd BestPractice_Deploy-Scripts\Products\Purview -.\Deploy-PurviewBestPractice.ps1 -TenantAdminUpn admin@contoso.onmicrosoft.com -WhatIf -``` - ---- - -## Disclaimer - -This sample script is **not** supported under any Microsoft standard -support program or service. The sample script is provided AS IS without -warranty of any kind. The full disclaimer and license are in the -[repository LICENSE file](https://github.com/microsoft/BestPractice_Deploy-Scripts/blob/main/LICENSE). - -Please do not contact Microsoft support with any issues or concerns -regarding this script. +--- +title: Home +layout: home +nav_order: 1 +description: "Best-practice deployment toolkits for Microsoft 365 SMB tenants." +permalink: / +--- + +# SMB Best Practice Tool + +PowerShell automation that configures Microsoft 365 tenants against +Microsoft's recommended best-practice baselines for **Small / Medium** +customers. Built for repeatable, idempotent configuration of Microsoft 365 +tenants — by partners/MSPs and in-house IT teams alike. + +> ⚠️ **Always pilot in a test tenant before applying to production.** Many +> changes are tenant-wide and can take up to 24 hours to fully propagate. + +--- + +## Available product toolkits + +| Product | Toolkit | Status | +|---|---|---| +| [Microsoft Purview](purview/) — Data Security baseline (license-aware: Business Premium and up) | `Products/Purview/` | ✅ Available | +| [Microsoft Entra](entra/) — Conditional Access baseline (report-only, break-glass safe) | `Products/Entra/` | Candidate; release authorization required | +| [Microsoft Intune](intune/) — device management & enrollment baseline | `Products/Intune/` | Candidate; release authorization required | +| [Microsoft Defender](defender/) - read-only default with pilot-validated ASR Audit configuration | `Products/Defender/` | Candidate; release authorization required | + +> 🗺️ **See the method at a glance:** the +> [**Purview Deployment Framework**](purview/deployment-framework/) is an +> interactive visual model of how the toolkit deploys — pre-deployment to +> post-deployment, with the simulate-then-promote safety gate front and centre. + +Pick a product from the sidebar (or the table above) for its full +documentation: what it does, prerequisites, how to run, scenarios it +covers, change-management playbook, and end-user adoption material. + +> 🆕 **What's new?** See the [**Changelog**](changelog/) for new features, +> changed defaults, and fixes across releases. + +--- + +## Common prerequisites (all toolkits) + +Each toolkit documents its own detailed prerequisites. Common requirements +across all of them: + +* **PowerShell 7+** (`pwsh.exe`) to **run** the toolkits — the deploy + scripts hard-fail on Windows PowerShell 5.1 because the Exchange + Online v3 REST channel and Microsoft.Graph SDK depend on .NET Core + APIs that PS 5.1 doesn't expose. Install with + `winget install --id Microsoft.PowerShell` or from + . +* **Windows** host with PS 5.1 still present — the + `Microsoft.Online.SharePoint.PowerShell` module is Windows-only and + is loaded automatically via `Import-Module -UseWindowsPowerShell` + (a hidden PS 5.1 sub-process) so its bundled MSAL DLL never enters + PS 7's AppDomain. macOS and Linux are not supported. +* **Tenant admin credentials** with the appropriate role(s) for the + product being deployed (see each product's page for the exact role map). +* **Required PowerShell modules** - each product documents its supported + installation path. Do not assume `-AutoInstallModules` is available unless + that product's documentation explicitly says so. + +### Shared delegated Graph authentication + +Purview, Entra, and Intune use the same delegated Microsoft Graph operator +flow. Supply `-TenantAdminUpn` for a direct tenant run, or add +`-DelegatedOrganization` to target a GDAP customer tenant. Each product +connects Graph once per run, reuses a cached context only when its account, +scopes, and tenant match, and verifies live tenant identity before setup. + +Each product still requests only its own configured Graph scopes. Use +`-AutoInstallModules` for documented module installation. `-NonInteractive` +suppresses toolkit prompts but does not enable app-only authentication or +guarantee a prompt-free first sign-in. + +--- + +## Quick start + +**Trying Intune?** Start with the +[Intune first-run guide](Products/Intune/README.md#first-time-here-start-with-a-preview). +It leads from prerequisites to a read-only preview, then to pilot group setup, +separate high-risk stages, and troubleshooting. A successful preview does not +prove that tenant writes or recovery have been validated. + +**Trying Entra?** Use the +[Entra first-run guide](Products/Entra/README.md#first-time-here-start-with-a-preview) +for an assessment without tenant changes, then the +[operator guide](Products/Entra/docs/Operator-Guide.md) for emergency access, +pilot scope, and approval gates. A blocked first run or indeterminate health +verdict needs review, not removal of existing protection. + +For a Purview preview: + +```powershell +# Clone the repo, then run the toolkit for the product you want to configure. +# Example: Purview baseline against a Business Premium tenant. +git clone https://github.com/microsoft/BestPractice_Deploy-Scripts.git +cd BestPractice_Deploy-Scripts\Products\Purview +.\Deploy-PurviewBestPractice.ps1 -TenantAdminUpn admin@contoso.onmicrosoft.com -WhatIf +``` + +--- + +## Disclaimer + +This sample script is **not** supported under any Microsoft standard +support program or service. The sample script is provided AS IS without +warranty of any kind. The full disclaimer and license are in the +[repository LICENSE file](https://github.com/microsoft/BestPractice_Deploy-Scripts/blob/main/LICENSE). + +Please do not contact Microsoft support with any issues or concerns +regarding this script. diff --git a/scripts/Test-DefenderConnectionGuards.ps1 b/scripts/Test-DefenderConnectionGuards.ps1 new file mode 100644 index 0000000..75a7246 --- /dev/null +++ b/scripts/Test-DefenderConnectionGuards.ps1 @@ -0,0 +1,229 @@ +#requires -Version 7.0 +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +$product = Join-Path $root 'Products\Defender' +$connectScript = Join-Path $product 'Modules\Connect-DefenderServices.ps1' +$preflightScript = Join-Path $product 'Modules\Setup-DefenderPreflight.ps1' +$config = Import-PowerShellDataFile (Join-Path $product 'Config\DefenderConfig.psd1') +$script:passed = 0 + +function Assert-True { + param([bool] $Condition, [string] $Message) + if (-not $Condition) { throw $Message } +} + +function Reset-TestState { + $global:DefenderConnectionTest = @{ + Operator = 'operator@partner.example.invalid' + CustomerDomain = 'customer.example.invalid' + CustomerId = '11111111-1111-4111-8111-111111111111' + HomeId = '22222222-2222-4222-8222-222222222222' + Context = $null + ConnectCount = 0 + DisconnectCount = 0 + Requests = [Collections.Generic.List[string]]::new() + ConnectArguments = @{} + WrongAccount = $false + WrongTenant = $false + ReadDenied = $false + } + $global:DefenderRunLog = [Collections.Generic.List[hashtable]]::new() + $global:DefenderRunLogPath = $null +} + +function New-TestContext { + param([string] $Account, [string] $Tenant, [string[]] $Scopes = @('User.Read'), [string] $AuthType = 'Delegated') + return [pscustomobject]@{ Account = $Account; TenantId = $Tenant; Scopes = $Scopes; AuthType = $AuthType } +} + +function Get-MgContext { + [CmdletBinding()] + param() + return $global:DefenderConnectionTest.Context +} + +function Disconnect-MgGraph { + [CmdletBinding()] + param() + $global:DefenderConnectionTest.DisconnectCount++ + $global:DefenderConnectionTest.Context = $null +} + +function Connect-MgGraph { + [CmdletBinding()] + param([string] $TenantId, [string[]] $Scopes, [string] $ContextScope, [switch] $NoWelcome, [switch] $UseDeviceAuthentication) + $s = $global:DefenderConnectionTest + $s.ConnectCount++ + $s.ConnectArguments = @{ TenantId = $TenantId; Device = $UseDeviceAuthentication.IsPresent; Scopes = $Scopes } + $tenant = if ($TenantId -in @($s.CustomerDomain, $s.CustomerId) -and -not $s.WrongTenant) { + $s.CustomerId + } else { $s.HomeId } + $account = if ($s.WrongAccount) { 'different@partner.example.invalid' } else { $s.Operator } + $s.Context = New-TestContext -Account $account -Tenant $tenant -Scopes $Scopes +} + +function Invoke-MgGraphRequest { + [CmdletBinding()] + param([string] $Method, [string] $Uri) + $s = $global:DefenderConnectionTest + Assert-True ($Method -eq 'GET' -and $Uri -eq 'https://graph.microsoft.com/v1.0/organization?$select=id,verifiedDomains') 'Unexpected request outside the synthetic identity endpoint' + $s.Requests.Add($Uri) + if ($s.ReadDenied) { + $exception = [Exception]::new('Synthetic tenant read denied.') + $exception | Add-Member -NotePropertyName Response -NotePropertyValue ([pscustomobject]@{StatusCode=403}) + throw $exception + } + $domain = if ($s.Context.TenantId -eq $s.CustomerId) { $s.CustomerDomain } else { 'partner.example.invalid' } + return @{ value = @(@{ id = $s.Context.TenantId; verifiedDomains = @(@{name=$domain}) }) } +} + +function Invoke-TestConnection { + param([switch] $ExplicitId, [switch] $Preflight, [switch] $ExpectFailure, [switch] $Device) + $s = $global:DefenderConnectionTest + $arguments = @{ + TenantAdminUpn = $s.Operator + DelegatedOrganization = $s.CustomerDomain + ConnectGraph = $true + GraphScopes = @('User.Read') + UseDeviceAuthentication = $Device.IsPresent + } + if ($ExplicitId) { $arguments.TenantId = $s.CustomerId } + $failure = $null + try { + if ($Preflight) { + $context = @{ + DelegatedOrganization = $s.CustomerDomain + TenantId = $arguments.TenantId + PermissionPlan = @{ GraphDelegatedScopes = @('User.Read') } + } + & $preflightScript -Config $config -Context $context -TenantAdminUpn $s.Operator | Out-Null + } + else { & $connectScript @arguments | Out-Null } + } + catch { $failure = $_ } + if (-not $ExpectFailure -and $failure) { throw $failure } + Assert-True (($null -ne $failure) -eq $ExpectFailure.IsPresent) 'Connection accepted an invalid account or tenant' + if ($ExpectFailure) { + Assert-True (@($global:DefenderRunLog | Where-Object Status -eq 'Failed').Count -gt 0) 'Connection failure was not recorded' + Assert-True (@($global:DefenderRunLog | Where-Object { + $_.Action -eq 'ApiCapability' -and $_.Status -eq 'Succeeded' + }).Count -eq 0) 'Failed authentication was reported as successful capability evidence' + } +} + +function Test-Case { + param([string] $Name, [scriptblock] $Test) + Reset-TestState + & $Test + $script:passed++ + Write-Host "PASS $Name" +} + +try { + Test-Case 'fresh GDAP sign-in selects the customer tenant without a GUID' { + Invoke-TestConnection + Assert-True ($global:DefenderConnectionTest.ConnectArguments.TenantId -eq 'customer.example.invalid') 'Customer domain was not passed to Connect-MgGraph' + } + Test-Case 'explicit tenant GUID remains authoritative' { + Invoke-TestConnection -ExplicitId + Assert-True ($global:DefenderConnectionTest.ConnectArguments.TenantId -eq $global:DefenderConnectionTest.CustomerId) 'Explicit tenant GUID was replaced' + } + Test-Case 'device authentication retains customer targeting' { + Invoke-TestConnection -Device + Assert-True $global:DefenderConnectionTest.ConnectArguments.Device 'Device authentication switch was lost' + Assert-True ($global:DefenderConnectionTest.Context.TenantId -eq $global:DefenderConnectionTest.CustomerId) 'Device authentication used the home tenant' + } + Test-Case 'verified same-operator customer context is reused' { + $s = $global:DefenderConnectionTest + $s.Context = New-TestContext -Account $s.Operator.ToUpperInvariant() -Tenant $s.CustomerId + Invoke-TestConnection + Assert-True ($s.ConnectCount -eq 0 -and $s.DisconnectCount -eq 0) 'Valid cached context was unnecessarily reconnected' + Assert-True ($s.Requests.Count -gt 0) 'Cached domain was not verified against the service' + } + Test-Case 'home-tenant cache is replaced for a delegated customer' { + $s = $global:DefenderConnectionTest + $s.Context = New-TestContext -Account $s.Operator -Tenant $s.HomeId + Invoke-TestConnection + Assert-True ($s.ConnectCount -eq 1 -and $s.DisconnectCount -eq 1) 'Wrong-tenant cache was reused' + } + Test-Case 'same-tenant different-operator cache is replaced before preflight' { + $s = $global:DefenderConnectionTest + $s.Context = New-TestContext -Account 'different@partner.example.invalid' -Tenant $s.CustomerId + Invoke-TestConnection -Preflight + Assert-True ($s.ConnectCount -eq 1 -and $s.DisconnectCount -eq 1) 'Another operator context was silently reused' + Assert-True (@($global:DefenderRunLog | Where-Object { + $_.Action -eq 'ApiCapability' -and $_.Status -eq 'Succeeded' + }).Count -eq 1) 'Valid reconnected preflight did not complete' + } + Test-Case 'missing scopes force a scoped reconnect' { + $s = $global:DefenderConnectionTest + $s.Context = New-TestContext -Account $s.Operator -Tenant $s.CustomerId -Scopes @() + Invoke-TestConnection + Assert-True ($s.ConnectCount -eq 1) 'Missing scope did not trigger a reconnect' + Assert-True (($s.ConnectArguments.Scopes -join ',') -eq 'User.Read') 'Connection broadened the requested scopes' + } + Test-Case 'an app-only cache is not reused for a delegated run' { + $s = $global:DefenderConnectionTest + $s.Context = New-TestContext -Account $s.Operator -Tenant $s.CustomerId -AuthType 'AppOnly' + Invoke-TestConnection + Assert-True ($s.ConnectCount -eq 1 -and $s.Context.AuthType -eq 'Delegated') 'App-only cache was accepted as delegated authentication' + } + Test-Case 'wrong operator chosen during fresh sign-in fails closed' { + $global:DefenderConnectionTest.WrongAccount = $true + Invoke-TestConnection -Preflight -ExpectFailure + } + Test-Case 'explicit tenant mismatch after sign-in fails closed' { + $global:DefenderConnectionTest.WrongTenant = $true + Invoke-TestConnection -ExplicitId -ExpectFailure + } + Test-Case 'unreadable cached tenant is not reported verified' { + $s = $global:DefenderConnectionTest + $s.Context = New-TestContext -Account $s.Operator -Tenant $s.CustomerId + $s.ReadDenied = $true + Invoke-TestConnection -ExpectFailure + } + Test-Case 'non-GDAP sign-in explicitly targets the operator tenant' { + $s = $global:DefenderConnectionTest + & $connectScript -TenantAdminUpn $s.Operator -ConnectGraph -GraphScopes @('User.Read') | Out-Null + Assert-True ($s.ConnectArguments.TenantId -eq 'partner.example.invalid') 'Default sign-in was not tenant-bound' + } + Test-Case 'capability evidence rejects an account changed after connection' { + $s = $global:DefenderConnectionTest + $context = @{ + DelegatedOrganization = $s.CustomerDomain + PermissionPlan = @{ GraphDelegatedScopes = @('User.Read') } + } + . $preflightScript -Config $config -Context $context -TenantAdminUpn $s.Operator | Out-Null + $global:DefenderRunLog.Clear() + $s.Context.Account = 'different@partner.example.invalid' + $failed = $false + try { Test-DefenderApiCapability -ApiDefinition $config.Api -TenantAdminUpn $s.Operator } + catch { $failed = $true } + Assert-True $failed 'Preflight accepted an operator changed after connection' + Assert-True (@($global:DefenderRunLog | Where-Object { + $_.Action -eq 'ApiCapability' -and $_.Status -eq 'Succeeded' + }).Count -eq 0) 'Preflight reported a different operator as authenticated' + } + foreach ($mode in @('NonInteractive', 'PartialCertificate', 'AppOnly')) { + Test-Case "$mode remains blocked before authentication" { + $s = $global:DefenderConnectionTest + $args = @{ TenantAdminUpn=$s.Operator; ConnectGraph=$true; GraphScopes=@('User.Read') } + if ($mode -eq 'NonInteractive') { $args.NonInteractive = $true } + else { + $args.ClientId = '33333333-3333-4333-8333-333333333333' + $args.CertificateThumbprint = '0' * 40 + if ($mode -eq 'AppOnly') { $args.TenantId = $s.CustomerId } + } + $failed = $false + try { & $connectScript @args | Out-Null } catch { $failed = $true } + Assert-True ($failed -and $s.ConnectCount -eq 0) 'Unsupported authentication reached Connect-MgGraph' + } + } + Write-Host "Defender connection checks passed ($script:passed scenarios). No live authentication." +} +finally { + Remove-Variable -Name DefenderConnectionTest -Scope Global -ErrorAction SilentlyContinue +} diff --git a/scripts/Test-IdentityReviewFixes.ps1 b/scripts/Test-IdentityReviewFixes.ps1 new file mode 100644 index 0000000..a6fc2f1 --- /dev/null +++ b/scripts/Test-IdentityReviewFixes.ps1 @@ -0,0 +1,235 @@ +#requires -Version 7.0 +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +$entra = Join-Path $root 'Products\Entra' +$script:assertions = 0 + +function Assert-True { + param([bool] $Condition, [string] $Message) + if (-not $Condition) { throw $Message } + $script:assertions++ +} + +function Copy-TestValue { + param($Value) + return $Value | ConvertTo-Json -Depth 40 | ConvertFrom-Json -AsHashtable +} + +# Synthetic responses only. Any unexpected request fails instead of reaching Graph. +function Invoke-MgGraphRequest { + [CmdletBinding()] + param([string] $Method, [string] $Uri, $Body, [string] $ContentType) + $mock = $global:IdentityReviewMock + if ($Method -eq 'GET' -and $Uri -match '/policies/identitySecurityDefaultsEnforcementPolicy$') { + return @{ isEnabled = $mock.SecurityDefaults } + } + if ($Method -eq 'GET' -and $Uri -match '/identity/conditionalAccess/policies$') { + return @{ value = @($mock.Existing) } + } + if ($Method -eq 'POST' -and $Uri -match '/identity/conditionalAccess/policies$') { + $mock.Writes++ + $mock.Created = $Body | ConvertFrom-Json -AsHashtable + $mock.Created.id = '22222222-2222-2222-2222-222222222222' + return Copy-TestValue $mock.Created + } + if ($Method -eq 'GET' -and $Uri -match '/identity/conditionalAccess/policies/22222222-2222-2222-2222-222222222222$') { + $response = Copy-TestValue $mock.Created + if ($mock.Mutate) { & $mock.Mutate $response } + if ($mock.AsObject) { return $response | ConvertTo-Json -Depth 40 | ConvertFrom-Json } + return $response + } + if ($Method -ne 'GET') { $mock.Writes++ } + throw "Unexpected mocked request: $Method $Uri" +} + +function Invoke-BaselineScenario { + param( + [string] $Key, + [object[]] $Existing = @(), + [scriptblock] $Mutate, + [switch] $Preview, + [switch] $Adopt, + [switch] $Withhold, + [switch] $TenantWide, + [switch] $SecurityDefaults, + [switch] $AsObject, + [switch] $ExpectFailure + ) + $config = Import-PowerShellDataFile (Join-Path $entra 'Config\EntraConfig.psd1') + $config.ConditionalAccess.Policies = @($config.ConditionalAccess.Policies | Where-Object Key -eq $Key) + Assert-True ($config.ConditionalAccess.Policies.Count -eq 1) "Missing policy reference: $Key" + $config.ConditionalAccess.Policies[0].Enabled = $true + $global:IdentityReviewMock = @{ + Existing = $Existing; Created = $null; Writes = 0; Mutate = $Mutate + SecurityDefaults = [bool] $SecurityDefaults; AsObject = [bool] $AsObject + } + $global:EntraRunLog = [System.Collections.Generic.List[hashtable]]::new() + $global:EntraRunLogPath = $null + $context = @{ + TenantAdminUpn = 'admin@example.invalid' + AssignmentScope = $(if ($TenantWide) { 'TenantWide' } else { 'PilotGroup' }) + PilotGroupId = '33333333-3333-3333-3333-333333333333' + BreakGlassUserIds = @('11111111-1111-1111-1111-111111111111') + BreakGlassGroupIds = @() + BlockedItemKeys = @() + WriteBlockedItemKeys = $(if ($Withhold) { @('conditional-access-baseline') } else { @() }) + } + $failure = $null + try { + & (Join-Path $entra 'Modules\Setup-ConditionalAccessBaseline.ps1') ` + -Config $config -Context $context -WhatIf:$Preview -AdoptExisting:$Adopt | Out-Null + } + catch { $failure = $_ } + if ($ExpectFailure) { + Assert-True ($null -ne $failure) "$Key accepted corrupt readback" + Assert-True (@($global:EntraRunLog | Where-Object { + $_.Action -eq 'Readback' -and $_.Status -eq 'Failed' -and $_.Readback -eq 'Mismatch' + }).Count -eq 1) "$Key did not record failed readback" + } + elseif ($failure) { throw $failure } + return @{ + Created = $global:IdentityReviewMock.Created + Writes = $global:IdentityReviewMock.Writes + Log = @($global:EntraRunLog) + } +} + +try { + . (Join-Path $root 'Products\Defender\Modules\DefenderRunLog.ps1') + $verdict = Get-DefenderModuleVerdict -Entries @( + @{ Status = 'Skipped'; Disposition = 'Blocked' }, + @{ Status = 'Failed'; Disposition = 'Applicable' }) + Assert-True ($verdict -eq 'FAILED') 'A blocked entry masked an operation failure' + Assert-True ((Get-DefenderModuleVerdict -Entries @( + @{ Status = 'Info'; Disposition = 'Blocked'; Readback = 'Failed' })) -eq 'FAILED') 'A blocked entry masked a readback failure' + Assert-True ((Get-DefenderModuleVerdict -Entries @( + @{ Status = 'Skipped'; Disposition = 'Blocked' })) -eq 'BLOCKED') 'Blocked-only verdict changed' + $defenderConfig = Import-PowerShellDataFile (Join-Path $root 'Products\Defender\Config\DefenderConfig.psd1') + $identity = @($defenderConfig.PermissionModel.Operations | Where-Object Key -eq 'tenant-identity')[0] + Assert-True (($identity.GraphDelegatedScopes -join ',') -eq 'User.Read') 'Delegated identity consent was broadened unnecessarily' + Assert-True ($identity.GraphApplicationPermissions -contains 'Organization.Read.All') 'Application permission contract changed' + + $config = Import-PowerShellDataFile (Join-Path $entra 'Config\EntraConfig.psd1') + foreach ($reference in $config.ConditionalAccess.Policies) { + $result = Invoke-BaselineScenario -Key $reference.Key -AsObject + Assert-True ($result.Writes -eq 1) "$($reference.Key) did not create exactly one policy" + Assert-True ($result.Created.state -eq 'enabledForReportingButNotEnforced') 'Creation enabled enforcement' + Assert-True (@($result.Log | Where-Object { + $_.Action -eq 'Readback' -and $_.Readback -eq 'Verified' + }).Count -eq 1) "$($reference.Key) did not verify the roundtrip" + if ($reference.Key -in @('require-mfa-admins', 'require-mfa-admin-portals')) { + Assert-True (@($result.Created.conditions.users.includeRoles).Count -gt 0) 'Admin role targeting was lost' + Assert-True (-not $result.Created.conditions.users.ContainsKey('includeGroups')) 'Pilot members were added to role-scoped policy' + } + if ($reference.Key -eq 'require-mfa-azure-management') { + Assert-True (($result.Created.conditions.users.includeUsers -join ',') -eq 'All') 'Azure management policy lost all-user targeting' + Assert-True (-not $result.Created.conditions.users.ContainsKey('includeGroups')) 'Azure management still targets pilot only' + } + if ($reference.Key -eq 'require-compliant-device-or-mfa') { + Assert-True ($result.Created.grantControls.operator -eq 'OR' -and + 'mfa' -in $result.Created.grantControls.builtInControls -and + 'compliantDevice' -in $result.Created.grantControls.builtInControls -and + 'domainJoinedDevice' -in $result.Created.grantControls.builtInControls) 'Device-or-MFA fallback is missing' + } + if ($reference.Key -eq 'require-mfa-guests') { + $users = $result.Created.conditions.users + Assert-True (($users.includeUsers -join ',') -ceq 'GuestsOrExternalUsers') 'Guest MFA must select guest/external users, not the workforce pilot group' + Assert-True (-not $users.ContainsKey('includeGroups') -and -not $users.ContainsKey('includeRoles')) 'Guest MFA must not union pilot groups or roles into guest scope' + Assert-True ($users.excludeUsers -contains '11111111-1111-1111-1111-111111111111') 'Guest MFA lost emergency-access exclusions' + } + $preview = Invoke-BaselineScenario -Key $reference.Key -Preview + Assert-True ($preview.Writes -eq 0) "$($reference.Key) wrote during WhatIf" + } + + $guest = Invoke-BaselineScenario -Key 'require-mfa-guests' -TenantWide + Assert-True (($guest.Created.conditions.users.includeUsers -join ',') -ceq 'GuestsOrExternalUsers') 'Tenant-wide mode broadened guest MFA to workforce users' + $legacyGuest = Copy-TestValue $guest.Created + $legacyGuest.conditions.users.Remove('includeUsers') | Out-Null + $legacyGuest.conditions.users.includeGroups = @('33333333-3333-3333-3333-333333333333') + $legacyGuest.state = 'enabled' + $legacyBefore = $legacyGuest | ConvertTo-Json -Depth 30 -Compress + foreach ($adopt in @($false, $true)) { + $legacyResult = Invoke-BaselineScenario -Key 'require-mfa-guests' -Existing @($legacyGuest) -Adopt:$adopt + Assert-True ($legacyResult.Writes -eq 0) 'Legacy group-scoped guest policy was automatically changed or duplicated' + Assert-True (@($legacyResult.Log | Where-Object { + $_.Disposition -eq 'GuidedOnly' -and $_.Readback -eq 'Mismatch' + }).Count -eq 1) 'Legacy guest targeting did not require manual migration review' + Assert-True (($legacyGuest | ConvertTo-Json -Depth 30 -Compress) -ceq $legacyBefore) 'Legacy guest enforcement or targeting was mutated' + } + foreach ($mutation in @( + { param($p) $p.conditions.users.includeUsers = @('All') }, + { param($p) $p.conditions.users.Remove('includeUsers') | Out-Null }, + { param($p) $p.conditions.users.includeGroups = @('33333333-3333-3333-3333-333333333333') }, + { param($p) $p.conditions.users.includeRoles = @('62e90394-69f5-4237-9190-012177145e10') }, + { param($p) $p.conditions.users.excludeGuestsOrExternalUsers = @{ guestOrExternalUserTypes = 'b2bCollaborationGuest' } }, + { param($p) $p.conditions.users.excludeUsers = @() })) { + $null = Invoke-BaselineScenario -Key 'require-mfa-guests' -Mutate $mutation -ExpectFailure + } + + foreach ($mutation in @( + { param($p) $p.Remove('sessionControls') | Out-Null }, + { param($p) $p.sessionControls.persistentBrowser.mode = 'always' }, + { param($p) $p.sessionControls.signInFrequency.value = 24 }, + { param($p) $p.sessionControls.signInFrequency.isEnabled = $false }, + { param($p) $p.sessionControls.signInFrequency.isEnabled = 'true' }, + { param($p) $p.conditions.devices.deviceFilter.rule = 'device.isCompliant -eq True' }, + { param($p) $p.state = 'enabled' })) { + $null = Invoke-BaselineScenario -Key 'no-persistent-browser-session' -Mutate $mutation -ExpectFailure + } + $session = Invoke-BaselineScenario -Key 'no-persistent-browser-session' -Mutate { + param($p) + $p.sessionControls.applicationEnforcedRestrictions = $null + $p.sessionControls.signInFrequency.extraServerMetadata = 'ignored' + } + Assert-True ($session.Writes -eq 1) 'Server-only session properties prevented readback' + $existingSession = Copy-TestValue $session.Created + $existingSession.Remove('sessionControls') + $unchanged = $existingSession | ConvertTo-Json -Depth 30 -Compress + $result = Invoke-BaselineScenario -Key 'no-persistent-browser-session' -Existing @($existingSession) -Adopt + Assert-True ($result.Writes -eq 0) 'Existing session policy was automatically adopted' + Assert-True (@($result.Log | Where-Object { + $_.Disposition -eq 'GuidedOnly' -and $_.Readback -eq 'Mismatch' + }).Count -eq 1) 'Missing session controls were not reported as drift' + Assert-True (($existingSession | ConvertTo-Json -Depth 30 -Compress) -ceq $unchanged) 'Existing session state was mutated' + + $strength = Invoke-BaselineScenario -Key 'require-phishing-resistant-mfa-admins' + $existingStrength = Copy-TestValue $strength.Created + $existingStrength.grantControls = @{ operator = 'OR'; builtInControls = @('mfa') } + $result = Invoke-BaselineScenario -Key 'require-phishing-resistant-mfa-admins' -Existing @($existingStrength) -Adopt + Assert-True ($result.Writes -eq 0) 'Phishing-resistant policy was automatically adopted' + Assert-True (@($result.Log | Where-Object { + $_.Disposition -eq 'GuidedOnly' -and $_.Readback -eq 'Mismatch' + }).Count -eq 1) 'Missing authentication strength was not reported' + $null = Invoke-BaselineScenario -Key 'require-phishing-resistant-mfa-admins' -Mutate { + param($p) $p.grantControls.authenticationStrength.id = '00000000-0000-0000-0000-000000000002' + } -ExpectFailure + $null = Invoke-BaselineScenario -Key 'require-mfa-admins' -Mutate { + param($p) $p.conditions.users.includeRoles = @() + } -ExpectFailure + + foreach ($key in @('require-mfa-admins', 'require-mfa-admin-portals', 'require-mfa-azure-management', 'require-compliant-device-or-mfa', 'require-mfa-guests')) { + $created = (Invoke-BaselineScenario -Key $key).Created + $rerun = Invoke-BaselineScenario -Key $key -Existing @($created) -Adopt + Assert-True ($rerun.Writes -eq 0) "$key silently migrated an existing policy" + $blocked = Invoke-BaselineScenario -Key $key -Withhold + Assert-True ($blocked.Writes -eq 0) "$key bypassed the high-risk gate" + $protected = Invoke-BaselineScenario -Key $key -SecurityDefaults + Assert-True ($protected.Writes -eq 0) "$key bypassed Security Defaults" + } + . (Join-Path $entra 'Modules\EntraPolicyComparison.ps1') + foreach ($key in @('no-persistent-browser-session', 'require-phishing-resistant-mfa-admins', 'require-mfa-guests')) { + $invalidConfig = Copy-TestValue $config.ConditionalAccess + ($invalidConfig.Policies | Where-Object Key -eq $key).ReviewExistingOnly = $false + $rejected = $false + try { Assert-EntraPolicyMigrationConfig -ConditionalAccess $invalidConfig } + catch { $rejected = $true } + Assert-True $rejected "$key accepted removal of the manual-review gate" + } + Write-Host "Identity regression checks passed ($script:assertions assertions)." +} +finally { + Remove-Variable -Name IdentityReviewMock -Scope Global -ErrorAction SilentlyContinue +} diff --git a/scripts/Test-IntuneReviewFixes.ps1 b/scripts/Test-IntuneReviewFixes.ps1 new file mode 100644 index 0000000..a9463d0 --- /dev/null +++ b/scripts/Test-IntuneReviewFixes.ps1 @@ -0,0 +1,486 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Offline synthetic regressions for Intune policy readback and collision guards. +.DESCRIPTION + Runs the public modules with an in-memory Graph fake. No SDK, Pester, + network, credentials, tenant exports, or additional fixture files are used. +#> +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$global:IntuneReviewTest = @{} +$root = Split-Path -Parent $PSScriptRoot +$product = Join-Path $root 'Products\Intune' +$modules = Join-Path $product 'Modules' +$global:IntuneReviewTest.config = Import-PowerShellDataFile (Join-Path $product 'Config\IntuneConfig.psd1') +$global:IntuneReviewTest.context = @{ + TenantAdminUpn = 'operator@example.invalid' + IncludeHighRisk = $true + AssignmentScope = 'PilotGroup' + PilotGroupId = '11111111-1111-1111-1111-111111111111' +} +$global:IntuneReviewTest.passed = 0 + +function Assert-True($Condition, [string] $Message) { + if (-not $Condition) { throw "ASSERTION: $Message" } +} + +function Copy-Json($Value) { + return ConvertFrom-Json -InputObject (ConvertTo-Json -InputObject $Value -Depth 40) -AsHashtable -NoEnumerate +} + +function Reset-Evidence { + $global:IntuneRunLog = [System.Collections.Generic.List[hashtable]]::new() + $global:IntuneRunLogPath = $null + $global:IntuneRunMetadata = @{} + $global:IntuneReviewTest.calls.Clear() + $global:IntuneReviewTest.lastGraphError = $null +} + +function Reset-State([string] $Kind, [string] $Fault = '', [switch] $PSObjects) { + $global:IntuneReviewTest.kind = $Kind + $global:IntuneReviewTest.fault = $Fault + $global:IntuneReviewTest.psObjects = $PSObjects.IsPresent + $global:IntuneReviewTest.objects = [ordered] @{} + $global:IntuneReviewTest.assignments = @{} + $global:IntuneReviewTest.actions = @{} + $global:IntuneReviewTest.calls = [System.Collections.Generic.List[object]]::new() + Reset-Evidence +} + +function Throw-Denied { + $error = [Exception]::new('Synthetic read denied.') + $error | Add-Member Response ([pscustomobject] @{ StatusCode = 403 }) + throw $error +} + +function Get-FakePage($Items, [string] $Uri) { + $page = @{ value = @($Items) } + if ($global:IntuneReviewTest.fault -eq 'MalformedCollection') { return @{ value = @{ wrong = 'not an array' } } } + if ($global:IntuneReviewTest.fault -eq 'MissingCollection') { return @{} } + if ($global:IntuneReviewTest.fault -eq 'NullItem') { return @{ value = @($null) } } + if ($global:IntuneReviewTest.fault -eq 'UnsafePage') { $page['@odata.nextLink'] = 'https://example.invalid/beta/data' } + if ($global:IntuneReviewTest.fault -eq 'WrongVersionPage') { $page['@odata.nextLink'] = $Uri.Replace('/beta/', '/v1.0/') } + if ($global:IntuneReviewTest.fault -eq 'WrongPathPage') { $page['@odata.nextLink'] = 'https://graph.microsoft.com/beta/organization' } + if ($global:IntuneReviewTest.fault -eq 'WrongPortPage') { $page['@odata.nextLink'] = $Uri.Replace('graph.microsoft.com', 'graph.microsoft.com:444') } + if ($global:IntuneReviewTest.fault -eq 'PageCycle') { $page['@odata.nextLink'] = $Uri } + if ($global:IntuneReviewTest.fault -in @('Paged', 'BroaderSecondPage')) { + if ($Uri.Contains('?')) { + $page.value = @($Items | Select-Object -Skip 1) + } + elseif (@($Items).Count -gt 1) { + $page.value = @($Items | Select-Object -First 1) + $page['@odata.nextLink'] = "$Uri`?`$skiptoken=synthetic" + } + } + return $page +} + +function Invoke-FakeGraph([string] $Method, [string] $Uri, [string] $Body) { + $global:IntuneReviewTest.calls.Add(@{ Method = $Method; Uri = $Uri }) + $path = ([uri] $Uri).AbsolutePath + Assert-True (([uri] $Uri).Host -eq 'graph.microsoft.com') 'Unexpected Graph host.' + + if ($global:IntuneReviewTest.kind -eq 'App') { + if ($Method -ne 'GET') { throw 'Unexpected app protection write.' } + if ($path -match '^/v1.0/deviceAppManagement/(androidManagedAppProtections|iosManagedAppProtections)$') { + return @{ value = @($global:IntuneReviewTest.objects.Values | Where-Object resource -eq $Matches[1]) } + } + if ($path -match '^/v1.0/deviceAppManagement/(androidManagedAppProtections|iosManagedAppProtections)/[^/]+/(assignments|apps)$') { + return @{ value = @() } + } + throw "Unexpected app route: $Method $path" + } + + $basePath = if ($global:IntuneReviewTest.kind -eq 'Compliance') { + '/v1.0/deviceManagement/deviceCompliancePolicies' + } else { '/beta/deviceManagement/deviceEnrollmentConfigurations' } + Assert-True ($path -eq $basePath -or $path.StartsWith("$basePath/")) 'Wrong API version or collection.' + $suffix = $path.Substring($basePath.Length).TrimStart('/') + $parts = @($suffix.Split('/')) + $id = [uri]::UnescapeDataString($parts[0]) + + if ($suffix -eq '') { + if ($Method -eq 'GET') { + if ($global:IntuneReviewTest.fault -eq 'InventoryDenied') { Throw-Denied } + return Get-FakePage @($global:IntuneReviewTest.objects.Values) $Uri + } + Assert-True ($Method -eq 'POST') 'The writer must remain create-only.' + $created = $Body | ConvertFrom-Json -AsHashtable + $id = if ($global:IntuneReviewTest.kind -eq 'Compliance') { + '00000000-0000-0000-0000-{0:d12}' -f ($global:IntuneReviewTest.objects.Count + 1) + } else { 'restriction/with space {0}' -f ($global:IntuneReviewTest.objects.Count + 1) } + if ($global:IntuneReviewTest.kind -eq 'Compliance') { + $global:IntuneReviewTest.actions[$id] = @($created.scheduledActionsForRule) + $null = $created.Remove('scheduledActionsForRule') + } + else { + Assert-True ($created.'@odata.type' -ceq '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration') 'Enrollment writer discriminator must be singular.' + Assert-True ($created.Contains('platformType') -and $created.Contains('platformRestriction')) 'Enrollment writer must use the singular payload shape.' + Assert-True (-not $created.Contains('iosRestriction')) 'Mixed enrollment payload shape.' + } + $created.id = $id + $created.version = 42 + $global:IntuneReviewTest.objects[$id] = $created + $global:IntuneReviewTest.assignments[$id] = @() + return @{ id = $id } + } + + Assert-True ($global:IntuneReviewTest.objects.Contains($id)) 'Unknown or incorrectly encoded object ID.' + if ($parts.Count -eq 1 -and $Method -eq 'GET') { + if ($global:IntuneReviewTest.fault -eq 'ReadDenied') { Throw-Denied } + $actual = Copy-Json $global:IntuneReviewTest.objects[$id] + if ($global:IntuneReviewTest.fault -eq 'WrongType') { $actual.'@odata.type' = '#microsoft.graph.unexpectedConfiguration' } + if ($global:IntuneReviewTest.fault -eq 'Ownership') { $actual.description = 'Customer policy' } + if ($global:IntuneReviewTest.kind -eq 'Compliance') { + switch ($global:IntuneReviewTest.fault) { + 'Setting' { $actual.passwordRequired = $false } + 'NullBoolean' { $actual.deviceThreatProtectionEnabled = $null } + 'StringBoolean' { $actual.deviceThreatProtectionEnabled = 'False' } + 'StringNumber' { $actual.passwordMinimumLength = '6' } + 'MissingField' { $null = $actual.Remove('passwordRequired') } + } + } + else { + switch ($global:IntuneReviewTest.fault) { + 'Setting' { $actual.platformRestriction.personalDeviceEnrollmentBlocked = $false } + 'NullBoolean' { $actual.platformRestriction.platformBlocked = $null } + 'StringBoolean' { $actual.platformRestriction.platformBlocked = 'False' } + 'NullString' { $actual.platformRestriction.osMinimumVersion = $null } + 'Platform' { $actual.platformType = 'windows' } + 'ArrayShape' { $actual.platformRestriction.blockedSkus = @{} } + 'MissingField' { $null = $actual.platformRestriction.Remove('platformBlocked') } + 'NoComplexAnnotation' { $null = $actual.platformRestriction.Remove('@odata.type') } + 'WrongComplexAnnotation' { $actual.platformRestriction.'@odata.type' = '#microsoft.graph.unexpectedType' } + } + } + return $actual + } + if ($parts.Count -eq 2 -and $parts[1] -eq 'assign' -and $Method -eq 'POST') { + if ($global:IntuneReviewTest.fault -eq 'AssignDenied') { Throw-Denied } + $bodyObject = $Body | ConvertFrom-Json -AsHashtable + $global:IntuneReviewTest.assignments[$id] = if ($global:IntuneReviewTest.kind -eq 'Compliance') { + @($bodyObject.assignments) + } else { @($bodyObject.enrollmentConfigurationAssignments) } + return $null + } + if ($parts.Count -eq 2 -and $parts[1] -eq 'assignments' -and $Method -eq 'GET') { + if ($global:IntuneReviewTest.fault -eq 'AssignmentDenied') { Throw-Denied } + $values = Copy-Json @($global:IntuneReviewTest.assignments[$id]) + if ($global:IntuneReviewTest.fault -eq 'MissingAssignment') { $values = @() } + if ($values.Count -gt 0) { + if ($global:IntuneReviewTest.fault -eq 'WrongAssignment') { $values[0].target.groupId = '22222222-2222-2222-2222-222222222222' } + if ($global:IntuneReviewTest.fault -eq 'FilteredAssignment') { + $values[0].target.deviceAndAppManagementAssignmentFilterType = 'include' + $values[0].target.deviceAndAppManagementAssignmentFilterId = 'synthetic-filter' + } + if ($global:IntuneReviewTest.fault -eq 'NoFilterDefaults') { + $values[0].target.deviceAndAppManagementAssignmentFilterType = 'none' + $values[0].target.deviceAndAppManagementAssignmentFilterId = $null + } + if ($global:IntuneReviewTest.fault -in @('BroaderAssignment', 'BroaderSecondPage')) { + $values += @{ target = @{ '@odata.type' = '#microsoft.graph.allLicensedUsersAssignmentTarget' } } + } + if ($global:IntuneReviewTest.fault -eq 'ExtraExclusion') { + $values += @{ target = @{ '@odata.type' = '#microsoft.graph.exclusionGroupAssignmentTarget'; groupId = 'excluded' } } + } + } + return Get-FakePage $values $Uri + } + if ($parts.Count -eq 2 -and $parts[1] -eq 'scheduledActionsForRule' -and $Method -eq 'GET') { + if ($global:IntuneReviewTest.fault -eq 'ActionsDenied') { Throw-Denied } + $rules = @( + for ($i = 0; $i -lt $global:IntuneReviewTest.actions[$id].Count; $i++) { + @{ id = "rule/$i with space"; ruleName = $global:IntuneReviewTest.actions[$id][$i].ruleName } + } + ) + if ($global:IntuneReviewTest.fault -eq 'ExtraRule') { $rules += @{ id = 'extra-rule'; ruleName = 'unexpected' } } + return Get-FakePage $rules $Uri + } + if ($parts.Count -eq 4 -and $parts[1] -eq 'scheduledActionsForRule' -and + $parts[3] -eq 'scheduledActionConfigurations' -and $Method -eq 'GET') { + if ($global:IntuneReviewTest.fault -eq 'ChildDenied') { Throw-Denied } + $ruleId = [uri]::UnescapeDataString($parts[2]) + if ($ruleId -eq 'extra-rule') { return @{ value = @() } } + Assert-True ($ruleId -match '^rule/(\d+) with space$') 'Child rule ID was not encoded correctly.' + $values = Copy-Json @($global:IntuneReviewTest.actions[$id][[int] $Matches[1]].scheduledActionConfigurations) + foreach ($child in $values) { $child.id = 'server-only-action-id' } + switch ($global:IntuneReviewTest.fault) { + 'ChildMismatch' { $values[0].gracePeriodHours = 72 } + 'ChildStringNumber' { $values[0].gracePeriodHours = '0' } + 'ChildNullString' { $values[0].notificationTemplateId = $null } + 'MissingChild' { $values = @() } + 'ExtraChild' { $values += @{ actionType = 'retire'; gracePeriodHours = 0 } } + } + return Get-FakePage $values $Uri + } + throw "Unexpected route: $Method $path" +} + +function Invoke-MgGraphRequest { + [CmdletBinding()] + param([string] $Method, [string] $Uri, [string] $Body, [string] $ContentType) + try { $result = Invoke-FakeGraph $Method $Uri $Body } + catch { $global:IntuneReviewTest.lastGraphError = $_; throw } + if ($global:IntuneReviewTest.psObjects -and $null -ne $result) { + return ConvertFrom-Json -InputObject (ConvertTo-Json -InputObject $result -Depth 40) -NoEnumerate + } + return $result +} + +function Invoke-RealModule([switch] $ExpectFailure, [switch] $WhatIf, [switch] $WithoutGates) { + $name = switch ($global:IntuneReviewTest.kind) { + 'Compliance' { 'Setup-DeviceCompliancePolicies.ps1' } + 'Enrollment' { 'Setup-EnrollmentRestrictions.ps1' } + 'App' { 'Setup-AppProtectionPolicies.ps1' } + } + $arguments = @{ Config = $global:IntuneReviewTest.config; Context = $global:IntuneReviewTest.context; WhatIf = $WhatIf.IsPresent } + if ($global:IntuneReviewTest.kind -ne 'App' -and -not $WithoutGates) { $arguments.IncludeHighRisk = $true } + if ($global:IntuneReviewTest.kind -eq 'Enrollment' -and -not $WithoutGates) { $arguments.EnableEnrollmentRestrictions = $true } + $caught = $null + try { & (Join-Path $modules $name) @arguments 6>$null } + catch { $caught = $_ } + if (-not $ExpectFailure -and $caught) { + throw "$caught`nMock diagnostic: $($global:IntuneReviewTest.lastGraphError)`n$($global:IntuneReviewTest.lastGraphError.ScriptStackTrace)" + } + Assert-True (([bool] $caught) -eq $ExpectFailure.IsPresent) "ExpectedFailure=$ExpectFailure for $($global:IntuneReviewTest.kind)/$($global:IntuneReviewTest.fault)." + if ($ExpectFailure) { + Assert-True (@($global:IntuneRunLog | Where-Object Status -eq 'Failed').Count -gt 0) 'Failure must be logged.' + Assert-True (@($global:IntuneRunLog | Where-Object Readback -eq 'Verified').Count -eq 0) 'Failure must not claim verification.' + } +} + +function Assert-NoWrites { + Assert-True (@($global:IntuneReviewTest.calls | Where-Object Method -ne 'GET').Count -eq 0) 'Unexpected write.' +} + +function Assert-Verified([int] $Count) { + Assert-True (@($global:IntuneRunLog | Where-Object Readback -eq 'Verified').Count -eq $Count) 'Expected verified readback for every policy.' +} + +function Test-Case([string] $Name, [scriptblock] $Test) { + try { + & $Test + $global:IntuneReviewTest.passed++ + Write-Host "PASS $Name" + } + catch { throw "FAIL $Name`n$($_ | Out-String)`n$($_.ScriptStackTrace)" } +} + +. (Join-Path $modules 'IntuneAssignmentScope.ps1') +. (Join-Path $modules 'IntunePolicyReadback.ps1') + +Test-Case 'assignment classifier empty, exclusions, broad precedence, malformed targets' { + $exclude = @{ target = @{ '@odata.type' = '#microsoft.graph.exclusionGroupAssignmentTarget'; groupId = 'excluded' } } + $broad = @{ target = @{ '@odata.type' = '#microsoft.graph.allDevicesAssignmentTarget' } } + Assert-True ((Get-IntuneAssignmentScopeResult -Assignments @()).Scope -eq 'None') 'Empty must be None.' + foreach ($pilot in @('', $global:IntuneReviewTest.context.PilotGroupId)) { + $result = Get-IntuneAssignmentScopeResult -Assignments @($exclude) -PilotGroupId $pilot -ExclusionBehavior Count + Assert-True ($result.Scope -eq 'Unknown' -and $result.ExclusionCount -eq 1) 'Exclusions alone must be Unknown.' + } + foreach ($targets in @(@($broad, $exclude), @($exclude, $broad))) { + $result = Get-IntuneAssignmentScopeResult -Assignments $targets -ExclusionBehavior Count + Assert-True ($result.Scope -eq 'Broad' -and $result.ExclusionCount -eq 1) 'Broad precedence and exclusion count.' + } + $caught = $false + try { Get-IntuneAssignmentScopeResult -Assignments @($broad, @{ target = $null }) } + catch { $caught = $true } + Assert-True $caught 'Malformed targets after broad must throw.' +} + +Test-Case 'managed JSON comparisons are typed and unordered only for set-like fields' { + foreach ($pair in @(@($false, 'False'), @($false, $null), @('', $null), @(0, '0'), @(@(), $null), @(@{}, @()))) { + Assert-True (-not (Test-IntuneManagedValue $pair[0] $pair[1])) 'Distinct JSON values compared equal.' + } + $expected = @{ roleScopeTagIds = @('1', '2'); scheduledActionsForRule = @( + @{ ruleName = 'A'; scheduledActionConfigurations = @(@{ actionType = 'block' }, @{ actionType = 'notification' }) }, + @{ ruleName = 'B'; scheduledActionConfigurations = @() } + ) } + $actual = [pscustomobject] @{ roleScopeTagIds = @('2', '1'); id = 'metadata'; scheduledActionsForRule = @( + [pscustomobject] @{ ruleName = 'B'; scheduledActionConfigurations = @(); id = 'server' }, + [pscustomobject] @{ ruleName = 'A'; scheduledActionConfigurations = @(@{ actionType = 'notification' }, @{ actionType = 'block' }) } + ) } + Assert-True (Test-IntuneManagedValue $expected $actual) 'Unordered collections or PSObject metadata mismatch.' + Assert-True (-not (Test-IntuneManagedValue @('1', '2') @('2', '1'))) 'Unknown arrays must remain ordered.' + Assert-True (Test-IntuneManagedValue 0 ([double] 0)) 'JSON numeric CLR variants must match.' +} + +foreach ($kindName in @('Compliance', 'Enrollment')) { + foreach ($objectShape in @($false, $true)) { + Test-Case "$kindName create and matching rerun, PSObjects=$objectShape" { + Reset-State $kindName -PSObjects:$objectShape + Invoke-RealModule + $count = $global:IntuneReviewTest.objects.Count + Assert-True ($count -gt 0) 'Create did not execute.' + Assert-Verified $count + if ($kindName -eq 'Compliance') { + Assert-True (@($global:IntuneReviewTest.calls | Where-Object Uri -match '/scheduledActionConfigurations$').Count -eq $count) 'Must read actual child endpoints.' + } + Reset-Evidence + $global:IntuneReviewTest.fault = 'Paged' + Invoke-RealModule + Assert-NoWrites + Assert-Verified $count + } + } + foreach ($failure in @('Setting', 'WrongType', 'Ownership', 'NullBoolean', 'StringBoolean', 'MissingField', + 'MissingAssignment', 'WrongAssignment', 'BroaderAssignment', 'BroaderSecondPage', 'FilteredAssignment', + 'ExtraExclusion', 'ReadDenied', 'AssignmentDenied')) { + Test-Case "$kindName rejects postwrite $failure" { + Reset-State $kindName $failure + Invoke-RealModule -ExpectFailure + } + } + foreach ($failure in @('Setting', 'MissingAssignment', 'WrongAssignment', 'BroaderAssignment', 'Ownership', 'ReadDenied')) { + Test-Case "$kindName rejects existing $failure without writes" { + Reset-State $kindName + Invoke-RealModule + Reset-Evidence + $global:IntuneReviewTest.fault = $failure + Invoke-RealModule -ExpectFailure + Assert-NoWrites + } + } + Test-Case "$kindName failed assignment stays blocked on rerun" { + Reset-State $kindName 'AssignDenied' + Invoke-RealModule -ExpectFailure + Reset-Evidence + $global:IntuneReviewTest.fault = '' + Invoke-RealModule -ExpectFailure + Assert-NoWrites + } + foreach ($collision in @('Unmanaged', 'Duplicate')) { + Test-Case "$kindName blocks $collision collisions" { + Reset-State $kindName + Invoke-RealModule + $firstId = @($global:IntuneReviewTest.objects.Keys)[0] + if ($collision -eq 'Unmanaged') { $global:IntuneReviewTest.objects[$firstId].description = 'Customer owned' } + else { + $copy = Copy-Json $global:IntuneReviewTest.objects[$firstId] + $copy.id = '99999999-9999-9999-9999-999999999999' + $global:IntuneReviewTest.objects[$copy.id] = $copy + $global:IntuneReviewTest.assignments[$copy.id] = $global:IntuneReviewTest.assignments[$firstId] + $global:IntuneReviewTest.actions[$copy.id] = $global:IntuneReviewTest.actions[$firstId] + } + Reset-Evidence + Invoke-RealModule -ExpectFailure + Assert-NoWrites + } + } + Test-Case "$kindName WhatIf and missing high-risk gates never write" { + Reset-State $kindName + Invoke-RealModule -WhatIf + Assert-NoWrites + Assert-True (@($global:IntuneRunLog | Where-Object Disposition -eq 'WillChange').Count -gt 0) 'Missing WhatIf evidence.' + Reset-Evidence + Invoke-RealModule -WithoutGates + Assert-NoWrites + } + Test-Case "$kindName rejects unapproved tenant-wide writes" { + Reset-State $kindName + $original = $global:IntuneReviewTest.context.AssignmentScope + try { + $global:IntuneReviewTest.context.AssignmentScope = 'TenantWide' + Invoke-RealModule -ExpectFailure + Assert-NoWrites + } finally { $global:IntuneReviewTest.context.AssignmentScope = $original } + } + Test-Case "$kindName accepts explicit no-filter defaults" { + Reset-State $kindName 'NoFilterDefaults' + Invoke-RealModule + Assert-Verified $global:IntuneReviewTest.objects.Count + } +} + +foreach ($failure in @('ChildMismatch', 'ChildDenied', 'ChildStringNumber', 'ChildNullString', + 'MissingChild', 'ExtraChild', 'ExtraRule', 'ActionsDenied', 'StringNumber')) { + Test-Case "Compliance rejects $failure" { + Reset-State Compliance $failure + Invoke-RealModule -ExpectFailure + } +} +foreach ($failure in @('Platform', 'NullString', 'ArrayShape', 'WrongComplexAnnotation', 'InventoryDenied', 'UnsafePage', 'WrongVersionPage', + 'WrongPathPage', 'WrongPortPage', 'PageCycle', 'MalformedCollection', 'MissingCollection', 'NullItem')) { + Test-Case "Enrollment rejects $failure" { + Reset-State Enrollment $failure + Invoke-RealModule -ExpectFailure + } +} + +Test-Case 'Enrollment inventories singular and plural shapes without confusing unrelated types' { + Reset-State Enrollment + Invoke-RealModule + $legacy = @{ + id = 'legacy/opaque id' + '@odata.type' = '#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration' + displayName = 'Legacy customer restrictions' + description = '' + } + foreach ($platform in @('ios', 'windows', 'windowsMobile', 'android', 'macOS')) { + $legacy["${platform}Restriction"] = @{ platformBlocked = $false; personalDeviceEnrollmentBlocked = $true } + } + $global:IntuneReviewTest.objects[$legacy.id] = $legacy + $global:IntuneReviewTest.assignments[$legacy.id] = @() + $global:IntuneReviewTest.objects['limit'] = @{ id = 'limit'; '@odata.type' = '#microsoft.graph.deviceEnrollmentLimitConfiguration' } + Reset-Evidence + Invoke-RealModule -WithoutGates + Assert-NoWrites + $expectedCount = $global:IntuneReviewTest.objects.Count - 1 + Assert-True (@($global:IntuneRunLog | Where-Object Detail -like "ConfigurationCount=$expectedCount;*").Count -eq 1) 'Both enrollment shapes must be inventoried.' + Assert-True (@($global:IntuneReviewTest.calls | Where-Object Uri -like '*/limit/assignments').Count -eq 0) 'Unrelated configurations must not be assessed as restrictions.' +} + +Test-Case 'Enrollment accepts an omitted non-derived complex type annotation' { + Reset-State Enrollment 'NoComplexAnnotation' + Invoke-RealModule + Assert-Verified $global:IntuneReviewTest.objects.Count +} + +foreach ($collisionPlatform in @('androidManagedAppProtections', 'iosManagedAppProtections')) { + Test-Case "App protection existing guard blocks $collisionPlatform collision" { + Reset-State App + foreach ($resource in @('androidManagedAppProtections', 'iosManagedAppProtections')) { + $definition = if ($resource.StartsWith('android')) { $global:IntuneReviewTest.config.AppProtection.Android } else { $global:IntuneReviewTest.config.AppProtection.Ios } + $global:IntuneReviewTest.objects[$resource] = @{ + id = $resource; resource = $resource; displayName = $definition.DisplayName + description = $(if ($resource -eq $collisionPlatform) { 'Customer owned' } else { $global:IntuneReviewTest.config.ManagedByTag }) + } + } + Invoke-RealModule -ExpectFailure + Assert-NoWrites + Assert-True (@($global:IntuneRunLog | Where-Object { $_.Disposition -eq 'Blocked' -and $_.Detail -like '*not managed by the toolkit*' }).Count -eq 1) 'Must exercise the actual unmanaged collision guard.' + } +} +Test-Case 'App protection WhatIf never posts' { + Reset-State App + Invoke-RealModule -WhatIf + Assert-NoWrites +} +Test-Case 'Existing managed app protection remains review-only' { + Reset-State App + foreach ($resource in @('androidManagedAppProtections', 'iosManagedAppProtections')) { + $definition = if ($resource.StartsWith('android')) { + $global:IntuneReviewTest.config.AppProtection.Android + } else { $global:IntuneReviewTest.config.AppProtection.Ios } + $global:IntuneReviewTest.objects[$resource] = @{ + id = $resource; resource = $resource; displayName = $definition.DisplayName + description = $global:IntuneReviewTest.config.ManagedByTag + } + } + Invoke-RealModule + Assert-NoWrites + Assert-True (@($global:IntuneRunLog | Where-Object { + $_.Action -eq 'Deploy' -and $_.Disposition -eq 'GuidedOnly' -and $_.Readback -eq 'NotAttempted' + }).Count -eq 2) 'Existing app protection must require review for both platforms.' + Assert-True (@($global:IntuneRunLog | Where-Object Readback -eq 'Verified').Count -eq 0) 'Inventory must not claim verified app protection.' +} +Test-Case 'Intune tenant identity scopes remain least privileged' { + Assert-True ($global:IntuneReviewTest.config.Api.GraphScopes.Count -eq 11) 'Unexpected scope count.' + Assert-True ($global:IntuneReviewTest.config.Api.GraphScopes -contains 'User.Read') 'Missing User.Read.' + Assert-True ($global:IntuneReviewTest.config.Api.GraphScopes -notcontains 'Organization.Read.All') 'Unnecessary tenant identity scope escalation.' +} + +Write-Host "$($global:IntuneReviewTest.passed) tests passed. Offline synthetic module execution only; no tenant validation." +Remove-Variable -Name IntuneReviewTest -Scope Global