mirror of
https://github.com/Micke-K/IntuneManagement.git
synced 2026-09-28 10:55:38 +02:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dfaf1bfba | ||
|
|
3545af455d | ||
|
|
b4c737dd6d | ||
|
|
bcc178ac99 | ||
|
|
0cbfc3ae87 | ||
|
|
7d357af03e | ||
|
|
3ae365cb4d | ||
|
|
b6eb4875ec | ||
|
|
15d7c4a423 | ||
|
|
a649c6ef49 | ||
|
|
bc19db7bb8 | ||
|
|
d61f9de2af | ||
|
|
337ff16f49 | ||
|
|
816b20caa8 | ||
|
|
1143fcc0c8 | ||
|
|
c820c9feb7 |
@@ -1,15 +0,0 @@
|
||||
*.cmd text eol=lf
|
||||
*.cs text eol=lf
|
||||
*.json text eol=lf
|
||||
*.md text eol=lf
|
||||
*.ps1 text eol=lf
|
||||
*.psd1 text eol=lf
|
||||
*.psm1 text eol=lf
|
||||
*.xaml text eol=lf
|
||||
# Avalonia markup was missing here while *.xaml was covered, so an editor that
|
||||
# wrote CRLF turned a one-row change into a whole-file diff. All 96 committed
|
||||
# .axaml files are already LF, so this normalizes nothing retroactively.
|
||||
*.axaml text eol=lf
|
||||
# Shell launchers must stay LF or they will not run on macOS/Linux
|
||||
*.command text eol=lf
|
||||
*.sh text eol=lf
|
||||
@@ -1,39 +0,0 @@
|
||||
title: ""
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Ideas is for shaping something before it becomes a request. Say what you
|
||||
are trying to achieve rather than the control you picture, and it will be
|
||||
clear whether the application should grow a feature or already has one.
|
||||
|
||||
- type: dropdown
|
||||
id: version
|
||||
attributes:
|
||||
label: Which version are you using?
|
||||
options:
|
||||
- 4.0 beta
|
||||
- 3.x
|
||||
- Neither yet, just looking
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: goal
|
||||
attributes:
|
||||
label: What are you trying to achieve?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: idea
|
||||
attributes:
|
||||
label: How do you picture it working?
|
||||
|
||||
- type: textarea
|
||||
id: scale
|
||||
attributes:
|
||||
label: How often, and at what scale?
|
||||
description: >
|
||||
How many tenants, how many policies, how often you do it. Scale changes
|
||||
the answer more than anything else here.
|
||||
@@ -1,64 +0,0 @@
|
||||
title: "[Question] "
|
||||
labels: ["needs-triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Most questions here turn out to be one of four things: a sign-in method
|
||||
the embedded window cannot complete, a list that looks short because the
|
||||
version you are on stops paging, a permission the app registration does
|
||||
not hold, or an object type that has no public Graph endpoint. The fields
|
||||
below let that be spotted straight away.
|
||||
|
||||
- type: dropdown
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
options:
|
||||
- 4.0 beta
|
||||
- 3.x
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: signin
|
||||
attributes:
|
||||
label: How do you sign in?
|
||||
options:
|
||||
- Interactive, embedded window (the default)
|
||||
- Interactive, Web Account Manager (WAM)
|
||||
- Interactive, system browser
|
||||
- Device code
|
||||
- Application and secret
|
||||
- Application and certificate
|
||||
- Managed identity or federated credential
|
||||
- Bring your own token
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: cloud
|
||||
attributes:
|
||||
label: Cloud
|
||||
options:
|
||||
- Public (commercial)
|
||||
- US Government (GCC High)
|
||||
- US Government (DoD)
|
||||
- China (21Vianet)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: question
|
||||
attributes:
|
||||
label: What are you trying to do?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: tried
|
||||
attributes:
|
||||
label: What have you tried, and what happened?
|
||||
description: >
|
||||
Paste any error text here. **Remove tenant identifiers, user names and
|
||||
tokens first.**
|
||||
@@ -1,4 +0,0 @@
|
||||
# GitHub renders this as the "Sponsor" button on the repository page
|
||||
# (public repositories, default branch). Buy Me a Coffee takes the
|
||||
# username, not the URL: https://buymeacoffee.com/MickeK
|
||||
buy_me_a_coffee: MickeK
|
||||
@@ -1,116 +0,0 @@
|
||||
name: Bug report (version 3.x)
|
||||
description: Something is wrong in the current release. Windows only.
|
||||
title: "[3.x] "
|
||||
labels: ["bug", "v3", "needs-triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Version 3 stays supported until 4.0 leaves beta.
|
||||
|
||||
Before filing, check whether 4.0 already fixes it. Several long-standing
|
||||
reports here are addressed there: sign-in with passkeys and other
|
||||
phishing-resistant methods, lists that stopped at 20, 100 or a few
|
||||
hundred objects, sovereign cloud sign-in, and running without a user
|
||||
present. The 4.0 beta lives on the `v4` branch.
|
||||
|
||||
- type: checkboxes
|
||||
id: preflight
|
||||
attributes:
|
||||
label: Before reporting
|
||||
options:
|
||||
- label: >
|
||||
If my sign-in involves a passkey, security key, Windows Hello or a
|
||||
phishing-resistant policy: I know the embedded sign-in window cannot
|
||||
complete those, and I have said so below rather than reporting it as
|
||||
a broken login.
|
||||
required: true
|
||||
- label: >
|
||||
I searched existing issues and discussions, including closed ones.
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
placeholder: 3.10.3
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: signin
|
||||
attributes:
|
||||
label: How did you sign in?
|
||||
options:
|
||||
- Interactive, embedded window (the default)
|
||||
- Interactive, other
|
||||
- Application and secret
|
||||
- Application and certificate
|
||||
- Not signed in / sign-in is the problem
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: cloud
|
||||
attributes:
|
||||
label: Cloud
|
||||
options:
|
||||
- Public (commercial)
|
||||
- US Government (GCC High)
|
||||
- US Government (DoD)
|
||||
- China (21Vianet)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: powershell
|
||||
attributes:
|
||||
label: PowerShell version
|
||||
description: Run `$PSVersionTable.PSVersion`.
|
||||
placeholder: "5.1.22621.4391"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Which part of the application?
|
||||
options:
|
||||
- Sign-in and authentication
|
||||
- Export
|
||||
- Import
|
||||
- Copy
|
||||
- Compare
|
||||
- Documentation output
|
||||
- Assignments, groups or filters
|
||||
- Bulk or silent operations
|
||||
- ADMX or tools
|
||||
- The user interface itself
|
||||
- Something else
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What happened, and what did you expect instead?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: log
|
||||
attributes:
|
||||
label: Log
|
||||
description: >
|
||||
The relevant lines, or the error text. **Remove tenant identifiers, user
|
||||
names, access tokens and secrets before pasting.**
|
||||
render: text
|
||||
validations:
|
||||
required: true
|
||||
@@ -1,154 +0,0 @@
|
||||
name: Bug report (version 4.0 beta)
|
||||
description: Something is wrong in 4.0. Runs on Windows, macOS and Linux.
|
||||
title: "[4.0] "
|
||||
labels: ["bug", "v4", "needs-triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for testing the beta. Four fields below do most of the work:
|
||||
**how you signed in**, **which cloud**, **which operating system** and
|
||||
**the log**. Reports without them usually need a round trip before
|
||||
anything can happen.
|
||||
|
||||
- type: checkboxes
|
||||
id: preflight
|
||||
attributes:
|
||||
label: Before reporting
|
||||
description: These three cover the majority of beta reports so far.
|
||||
options:
|
||||
- label: >
|
||||
I read the release notes for this beta, including the breaking changes.
|
||||
required: true
|
||||
- label: >
|
||||
If my sign-in involves a passkey, security key, Windows Hello or any
|
||||
phishing-resistant policy: I enabled **Use Web Account Manager (WAM)
|
||||
for login** or **Use system browser for login** in Settings, and tried
|
||||
again. The embedded window cannot complete those methods.
|
||||
required: true
|
||||
- label: >
|
||||
My problem is not Inventory Policies returning 403. That endpoint is
|
||||
not published on the public Graph API, so the application cannot read
|
||||
it with a normal sign-in. It is a Microsoft limitation, not a bug.
|
||||
A bring-your-own-token sign-in can reach it.
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: The About dialog, or the ModuleVersion in IntuneManagement.psd1.
|
||||
placeholder: 4.0.0-beta1
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: signin
|
||||
attributes:
|
||||
label: How did you sign in?
|
||||
description: >
|
||||
The single most useful field in this form. Roughly a third of all reports
|
||||
on this project have turned out to be sign-in behaviour rather than the
|
||||
feature being reported.
|
||||
options:
|
||||
- Interactive, embedded window (the default)
|
||||
- Interactive, Web Account Manager (WAM)
|
||||
- Interactive, system browser
|
||||
- Device code
|
||||
- Application and secret
|
||||
- Application and certificate
|
||||
- Managed identity or federated credential
|
||||
- Bring your own token
|
||||
- Not signed in / sign-in is the problem
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: cloud
|
||||
attributes:
|
||||
label: Cloud
|
||||
options:
|
||||
- Public (commercial)
|
||||
- US Government (GCC High)
|
||||
- US Government (DoD)
|
||||
- China (21Vianet)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating system
|
||||
description: 4.0 runs the full application outside Windows, so this now matters.
|
||||
options:
|
||||
- Windows
|
||||
- macOS (Apple Silicon)
|
||||
- macOS (Intel)
|
||||
- Linux
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: powershell
|
||||
attributes:
|
||||
label: PowerShell edition and version
|
||||
description: Run `$PSVersionTable.PSVersion` and `$PSVersionTable.PSEdition`.
|
||||
placeholder: "7.4.6, Core"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Which part of the application?
|
||||
options:
|
||||
- Sign-in and authentication
|
||||
- Export
|
||||
- Import
|
||||
- Copy
|
||||
- Compare
|
||||
- Documentation output
|
||||
- Assignments, groups or filters
|
||||
- Bulk operations
|
||||
- ADMX or tools
|
||||
- The user interface itself
|
||||
- Automation through the PowerShell commands
|
||||
- Something else
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: objecttype
|
||||
attributes:
|
||||
label: Which object type, if it is specific to one
|
||||
placeholder: Settings Catalog, Conditional Access, Win32 app, ...
|
||||
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What happened, and what did you expect instead?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
placeholder: |
|
||||
1. Sign in to ...
|
||||
2. Open ...
|
||||
3. Click ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: log
|
||||
attributes:
|
||||
label: Log
|
||||
description: >
|
||||
The relevant lines from the log file, or the error text. **Remove tenant
|
||||
identifiers, user names, access tokens and secrets before pasting.** If
|
||||
an exported policy file is needed, redact it or use one from a lab tenant.
|
||||
render: text
|
||||
validations:
|
||||
required: true
|
||||
@@ -1,17 +0,0 @@
|
||||
# Turns off the "open a blank issue" escape hatch, so every report arrives
|
||||
# through a form with the fields that make it answerable. Questions go to
|
||||
# Discussions, which is where most of them already end up.
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Question, or not sure it is a bug
|
||||
url: https://github.com/Micke-K/IntuneManagement/discussions/categories/q-a
|
||||
about: Ask in Q&A. Most "it does not show my policies" reports are answered there in a day.
|
||||
- name: Feature idea worth discussing first
|
||||
url: https://github.com/Micke-K/IntuneManagement/discussions/categories/ideas
|
||||
about: Ideas that are still taking shape belong here. A concrete request can go straight to an issue.
|
||||
- name: Version 4.0 beta feedback
|
||||
url: https://github.com/Micke-K/IntuneManagement/discussions/categories/announcements
|
||||
about: Testing the 4.0 beta? Start at the pinned announcement, which lists what to try first.
|
||||
- name: Security vulnerability
|
||||
url: https://github.com/Micke-K/IntuneManagement/security/advisories/new
|
||||
about: Never report a security problem in a public issue. Use private reporting.
|
||||
@@ -1,71 +0,0 @@
|
||||
name: Feature request
|
||||
description: Something the application should do and does not.
|
||||
title: "[Request] "
|
||||
labels: ["enhancement", "needs-triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
If the idea is still taking shape, [Ideas in
|
||||
Discussions](https://github.com/Micke-K/IntuneManagement/discussions/categories/ideas)
|
||||
is the better room. Use this form when you can describe the outcome you
|
||||
want.
|
||||
|
||||
- type: dropdown
|
||||
id: version
|
||||
attributes:
|
||||
label: Which version is this for?
|
||||
description: >
|
||||
This one is read by a human, not by automation, so say what you mean.
|
||||
A request that 4.0 already covers is worth checking against the release
|
||||
notes first.
|
||||
options:
|
||||
- Version 4.0
|
||||
- Version 3.x
|
||||
- Either
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What are you trying to do?
|
||||
description: >
|
||||
The situation, not the solution. "I move policies between two tenants
|
||||
every month and have to redo the assignments by hand" tells more than
|
||||
"add a button".
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: What would you like it to do?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: workaround
|
||||
attributes:
|
||||
label: How do you handle it today?
|
||||
description: Including "not at all", which is useful to know.
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Which part of the application?
|
||||
options:
|
||||
- Export
|
||||
- Import
|
||||
- Copy
|
||||
- Compare
|
||||
- Documentation output
|
||||
- Assignments, groups or filters
|
||||
- Bulk operations
|
||||
- ADMX or tools
|
||||
- The user interface
|
||||
- Automation through the PowerShell commands
|
||||
- A policy type that is not supported yet
|
||||
- Something else
|
||||
validations:
|
||||
required: true
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
.vs/
|
||||
.vscode/
|
||||
.git/
|
||||
/*.Log
|
||||
/*.Lo_
|
||||
/*.log
|
||||
/*.lo_
|
||||
/*.csv
|
||||
/*.zip
|
||||
/*.new
|
||||
/*.old
|
||||
/*.zip
|
||||
/TestResults
|
||||
/TestResults.xml
|
||||
/TestResults*.xml
|
||||
Start-BYODToken.ps1
|
||||
/UI/Avalonia/Bootstrap/bin/
|
||||
# Main-thread hook compile output (the one ~10 KB DLL it produces IS tracked,
|
||||
# under Bin/MainThreadHook; see UI/Avalonia/Bootstrap/Publish-MainThreadHook.ps1).
|
||||
/UI/Avalonia/Bootstrap/MainThreadHook/bin/
|
||||
/UI/Avalonia/Bootstrap/obj/
|
||||
IntuneManagement.log
|
||||
|
||||
# .NET build output / intermediates (the Avalonia payload is published into
|
||||
# Bin/Avalonia; these are the transient compile dirs and restore artifacts).
|
||||
# Note: only ignore the lowercase build dirs, never the tracked Bin/ runtime
|
||||
# folder — on case-insensitive filesystems a bare "bin/" would match it.
|
||||
**/obj/
|
||||
*.user
|
||||
project.assets.json
|
||||
*.nupkg
|
||||
|
||||
# Timestamped backup folders (e.g. Bin/MSAL_PS7.bak-20260517-181539) and other
|
||||
# scratch/temp artifacts.
|
||||
*.bak-*/
|
||||
*.bak/
|
||||
*.tmp
|
||||
*.swp
|
||||
|
||||
# Stray literal-path artifact created on non-Windows by older code that didn't
|
||||
# expand %LOCALAPPDATA% (kept ignored so it can never be re-committed).
|
||||
%LOCALAPPDATA%/
|
||||
|
||||
# Test run logs
|
||||
/Tests/Logs/
|
||||
|
||||
.claude/settings.local.json
|
||||
CLAUDE.local.md
|
||||
Internal/Documentation/IntuneManagement.log
|
||||
|
||||
# Residue if the OLD-project automation batch ever runs with an unreplaced
|
||||
# txtJsonFileName placeholder (see Tests/Setup/Invoke-AutomationOrchestrator.ps1)
|
||||
REPLACED_AT_RUNTIME
|
||||
|
||||
# Local-only online-test assets (binaries: .intunewin, vendor .admx/.adml).
|
||||
# Not committed - license/size. Tests skip when absent. See Docs/Testing.md.
|
||||
Tests/Fixtures/Automation.Local/
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,494 +0,0 @@
|
||||
{
|
||||
"runtimeTarget": {
|
||||
"name": ".NETCoreApp,Version=v8.0/win-x64",
|
||||
"signature": ""
|
||||
},
|
||||
"compilationOptions": {},
|
||||
"targets": {
|
||||
".NETCoreApp,Version=v8.0": {},
|
||||
".NETCoreApp,Version=v8.0/win-x64": {
|
||||
"AvaloniaPayload/1.0.0": {
|
||||
"dependencies": {
|
||||
"Avalonia": "11.2.3",
|
||||
"Avalonia.Controls.DataGrid": "11.2.3",
|
||||
"Avalonia.Desktop": "11.2.3",
|
||||
"Avalonia.Fonts.Inter": "11.2.3",
|
||||
"Avalonia.Markup.Xaml.Loader": "11.2.3",
|
||||
"Avalonia.Themes.Fluent": "11.2.3"
|
||||
},
|
||||
"runtime": {
|
||||
"AvaloniaPayload.dll": {}
|
||||
}
|
||||
},
|
||||
"Avalonia/11.2.3": {
|
||||
"dependencies": {
|
||||
"Avalonia.BuildServices": "0.0.29",
|
||||
"Avalonia.Remote.Protocol": "11.2.3",
|
||||
"MicroCom.Runtime": "0.11.0"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Avalonia.Base.dll": {
|
||||
"assemblyVersion": "11.2.3.0",
|
||||
"fileVersion": "11.2.3.0"
|
||||
},
|
||||
"lib/net8.0/Avalonia.Controls.dll": {
|
||||
"assemblyVersion": "11.2.3.0",
|
||||
"fileVersion": "11.2.3.0"
|
||||
},
|
||||
"lib/net8.0/Avalonia.DesignerSupport.dll": {
|
||||
"assemblyVersion": "0.7.0.0",
|
||||
"fileVersion": "0.7.0.0"
|
||||
},
|
||||
"lib/net8.0/Avalonia.Dialogs.dll": {
|
||||
"assemblyVersion": "11.2.3.0",
|
||||
"fileVersion": "11.2.3.0"
|
||||
},
|
||||
"lib/net8.0/Avalonia.Markup.Xaml.dll": {
|
||||
"assemblyVersion": "11.2.3.0",
|
||||
"fileVersion": "11.2.3.0"
|
||||
},
|
||||
"lib/net8.0/Avalonia.Markup.dll": {
|
||||
"assemblyVersion": "11.2.3.0",
|
||||
"fileVersion": "11.2.3.0"
|
||||
},
|
||||
"lib/net8.0/Avalonia.Metal.dll": {
|
||||
"assemblyVersion": "11.2.3.0",
|
||||
"fileVersion": "11.2.3.0"
|
||||
},
|
||||
"lib/net8.0/Avalonia.MicroCom.dll": {
|
||||
"assemblyVersion": "11.2.3.0",
|
||||
"fileVersion": "11.2.3.0"
|
||||
},
|
||||
"lib/net8.0/Avalonia.OpenGL.dll": {
|
||||
"assemblyVersion": "11.2.3.0",
|
||||
"fileVersion": "11.2.3.0"
|
||||
},
|
||||
"lib/net8.0/Avalonia.Vulkan.dll": {
|
||||
"assemblyVersion": "11.2.3.0",
|
||||
"fileVersion": "11.2.3.0"
|
||||
},
|
||||
"lib/net8.0/Avalonia.dll": {
|
||||
"assemblyVersion": "11.2.3.0",
|
||||
"fileVersion": "11.2.3.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Avalonia.Angle.Windows.Natives/2.1.22045.20230930": {
|
||||
"native": {
|
||||
"runtimes/win-x64/native/av_libglesv2.dll": {
|
||||
"fileVersion": "2.1.22045.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Avalonia.BuildServices/0.0.29": {},
|
||||
"Avalonia.Controls.DataGrid/11.2.3": {
|
||||
"dependencies": {
|
||||
"Avalonia": "11.2.3",
|
||||
"Avalonia.Remote.Protocol": "11.2.3"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Avalonia.Controls.DataGrid.dll": {
|
||||
"assemblyVersion": "11.2.3.0",
|
||||
"fileVersion": "11.2.3.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Avalonia.Desktop/11.2.3": {
|
||||
"dependencies": {
|
||||
"Avalonia": "11.2.3",
|
||||
"Avalonia.Native": "11.2.3",
|
||||
"Avalonia.Skia": "11.2.3",
|
||||
"Avalonia.Win32": "11.2.3",
|
||||
"Avalonia.X11": "11.2.3"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Avalonia.Desktop.dll": {
|
||||
"assemblyVersion": "11.2.3.0",
|
||||
"fileVersion": "11.2.3.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Avalonia.Fonts.Inter/11.2.3": {
|
||||
"dependencies": {
|
||||
"Avalonia": "11.2.3"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Avalonia.Fonts.Inter.dll": {
|
||||
"assemblyVersion": "11.2.3.0",
|
||||
"fileVersion": "11.2.3.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Avalonia.FreeDesktop/11.2.3": {
|
||||
"dependencies": {
|
||||
"Avalonia": "11.2.3",
|
||||
"Tmds.DBus.Protocol": "0.20.0"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Avalonia.FreeDesktop.dll": {
|
||||
"assemblyVersion": "11.2.3.0",
|
||||
"fileVersion": "11.2.3.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Avalonia.Markup.Xaml.Loader/11.2.3": {
|
||||
"dependencies": {
|
||||
"Avalonia": "11.2.3"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Avalonia.Markup.Xaml.Loader.dll": {
|
||||
"assemblyVersion": "11.2.3.0",
|
||||
"fileVersion": "11.2.3.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Avalonia.Native/11.2.3": {
|
||||
"dependencies": {
|
||||
"Avalonia": "11.2.3"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Avalonia.Native.dll": {
|
||||
"assemblyVersion": "11.2.3.0",
|
||||
"fileVersion": "11.2.3.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Avalonia.Remote.Protocol/11.2.3": {
|
||||
"runtime": {
|
||||
"lib/net8.0/Avalonia.Remote.Protocol.dll": {
|
||||
"assemblyVersion": "11.2.3.0",
|
||||
"fileVersion": "11.2.3.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Avalonia.Skia/11.2.3": {
|
||||
"dependencies": {
|
||||
"Avalonia": "11.2.3",
|
||||
"HarfBuzzSharp": "7.3.0.3",
|
||||
"HarfBuzzSharp.NativeAssets.Linux": "7.3.0.3",
|
||||
"HarfBuzzSharp.NativeAssets.WebAssembly": "7.3.0.3",
|
||||
"SkiaSharp": "2.88.9",
|
||||
"SkiaSharp.NativeAssets.Linux": "2.88.9",
|
||||
"SkiaSharp.NativeAssets.WebAssembly": "2.88.9"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Avalonia.Skia.dll": {
|
||||
"assemblyVersion": "11.2.3.0",
|
||||
"fileVersion": "11.2.3.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Avalonia.Themes.Fluent/11.2.3": {
|
||||
"dependencies": {
|
||||
"Avalonia": "11.2.3"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Avalonia.Themes.Fluent.dll": {
|
||||
"assemblyVersion": "11.2.3.0",
|
||||
"fileVersion": "11.2.3.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Avalonia.Win32/11.2.3": {
|
||||
"dependencies": {
|
||||
"Avalonia": "11.2.3",
|
||||
"Avalonia.Angle.Windows.Natives": "2.1.22045.20230930"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Avalonia.Win32.dll": {
|
||||
"assemblyVersion": "11.2.3.0",
|
||||
"fileVersion": "11.2.3.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Avalonia.X11/11.2.3": {
|
||||
"dependencies": {
|
||||
"Avalonia": "11.2.3",
|
||||
"Avalonia.FreeDesktop": "11.2.3",
|
||||
"Avalonia.Skia": "11.2.3"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Avalonia.X11.dll": {
|
||||
"assemblyVersion": "11.2.3.0",
|
||||
"fileVersion": "11.2.3.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"HarfBuzzSharp/7.3.0.3": {
|
||||
"dependencies": {
|
||||
"HarfBuzzSharp.NativeAssets.Win32": "7.3.0.3",
|
||||
"HarfBuzzSharp.NativeAssets.macOS": "7.3.0.3"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net6.0/HarfBuzzSharp.dll": {
|
||||
"assemblyVersion": "1.0.0.0",
|
||||
"fileVersion": "7.3.0.3"
|
||||
}
|
||||
}
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Linux/7.3.0.3": {
|
||||
"dependencies": {
|
||||
"HarfBuzzSharp": "7.3.0.3"
|
||||
}
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.macOS/7.3.0.3": {},
|
||||
"HarfBuzzSharp.NativeAssets.WebAssembly/7.3.0.3": {},
|
||||
"HarfBuzzSharp.NativeAssets.Win32/7.3.0.3": {
|
||||
"native": {
|
||||
"runtimes/win-x64/native/libHarfBuzzSharp.dll": {
|
||||
"fileVersion": "0.0.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"MicroCom.Runtime/0.11.0": {
|
||||
"runtime": {
|
||||
"lib/net5.0/MicroCom.Runtime.dll": {
|
||||
"assemblyVersion": "0.11.0.0",
|
||||
"fileVersion": "0.11.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SkiaSharp/2.88.9": {
|
||||
"dependencies": {
|
||||
"SkiaSharp.NativeAssets.Win32": "2.88.9",
|
||||
"SkiaSharp.NativeAssets.macOS": "2.88.9"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net6.0/SkiaSharp.dll": {
|
||||
"assemblyVersion": "2.88.0.0",
|
||||
"fileVersion": "2.88.9.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Linux/2.88.9": {
|
||||
"dependencies": {
|
||||
"SkiaSharp": "2.88.9"
|
||||
}
|
||||
},
|
||||
"SkiaSharp.NativeAssets.macOS/2.88.9": {},
|
||||
"SkiaSharp.NativeAssets.WebAssembly/2.88.9": {},
|
||||
"SkiaSharp.NativeAssets.Win32/2.88.9": {
|
||||
"native": {
|
||||
"runtimes/win-x64/native/libSkiaSharp.dll": {
|
||||
"fileVersion": "0.0.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"System.IO.Pipelines/8.0.0": {
|
||||
"runtime": {
|
||||
"lib/net8.0/System.IO.Pipelines.dll": {
|
||||
"assemblyVersion": "8.0.0.0",
|
||||
"fileVersion": "8.0.23.53103"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Tmds.DBus.Protocol/0.20.0": {
|
||||
"dependencies": {
|
||||
"System.IO.Pipelines": "8.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Tmds.DBus.Protocol.dll": {
|
||||
"assemblyVersion": "0.20.0.0",
|
||||
"fileVersion": "0.20.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"libraries": {
|
||||
"AvaloniaPayload/1.0.0": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Avalonia/11.2.3": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-pD6woFAUfGcyEvMmrpctntU4jv4fT8752pfx1J5iRORVX3Ob0oQi8PWo0TXVaAJZiSfH0cdKTeKx0w0DzD0/mg==",
|
||||
"path": "avalonia/11.2.3",
|
||||
"hashPath": "avalonia.11.2.3.nupkg.sha512"
|
||||
},
|
||||
"Avalonia.Angle.Windows.Natives/2.1.22045.20230930": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-Bo3qOhKC1b84BIhiogndMdAzB3UrrESKK7hS769f5HWeoMw/pcd42US5KFYW2JJ4ZSTrXnP8mXwLTMzh+S+9Lg==",
|
||||
"path": "avalonia.angle.windows.natives/2.1.22045.20230930",
|
||||
"hashPath": "avalonia.angle.windows.natives.2.1.22045.20230930.nupkg.sha512"
|
||||
},
|
||||
"Avalonia.BuildServices/0.0.29": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-U4eJLQdoDNHXtEba7MZUCwrBErBTxFp6sUewXBOdAhU0Kwzwaa/EKFcYm8kpcysjzKtfB4S0S9n0uxKZFz/ikw==",
|
||||
"path": "avalonia.buildservices/0.0.29",
|
||||
"hashPath": "avalonia.buildservices.0.0.29.nupkg.sha512"
|
||||
},
|
||||
"Avalonia.Controls.DataGrid/11.2.3": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-Ul6oWEoqs4eTQZIb4Hzf0+ajhgrCX9ypOj8TahKbkKoIznJkUoka3iV90Vpj/AuoT5AZsN6f+1+62SVPpeMApA==",
|
||||
"path": "avalonia.controls.datagrid/11.2.3",
|
||||
"hashPath": "avalonia.controls.datagrid.11.2.3.nupkg.sha512"
|
||||
},
|
||||
"Avalonia.Desktop/11.2.3": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-dX3zfgWplLqcgwQJLeC2ciqxE/GM3iw9HUNI22c8KgAAWMWl52NWCmjW228EPZG+4YbHwq8T40YARO2aQF+yqA==",
|
||||
"path": "avalonia.desktop/11.2.3",
|
||||
"hashPath": "avalonia.desktop.11.2.3.nupkg.sha512"
|
||||
},
|
||||
"Avalonia.Fonts.Inter/11.2.3": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-ConIy0i4S5xuWwYMihJt+0VVIFS1NqXtMzG7XYFO/L786P9qXlQBnHHuLt4kdw5kvJtZEhgii9E+/W7b35wtoQ==",
|
||||
"path": "avalonia.fonts.inter/11.2.3",
|
||||
"hashPath": "avalonia.fonts.inter.11.2.3.nupkg.sha512"
|
||||
},
|
||||
"Avalonia.FreeDesktop/11.2.3": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-BOivcAE3yYFKyYg5CztnTeIFX7ZHNaFiMrQ9WO4MgKyMwbPdH6jy6Mpfu+LY5FiYpleZdmXLJXZzzPon52DUVg==",
|
||||
"path": "avalonia.freedesktop/11.2.3",
|
||||
"hashPath": "avalonia.freedesktop.11.2.3.nupkg.sha512"
|
||||
},
|
||||
"Avalonia.Markup.Xaml.Loader/11.2.3": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-a11A13NtjNkGCqIO85q6YUN1uQFAwKHZwy7HtJt91+PujlewJ3+CO6Aw4evkyvg+rI7mcqyDE6FbCl3Juykqqg==",
|
||||
"path": "avalonia.markup.xaml.loader/11.2.3",
|
||||
"hashPath": "avalonia.markup.xaml.loader.11.2.3.nupkg.sha512"
|
||||
},
|
||||
"Avalonia.Native/11.2.3": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-yW9IGfa7kBuEcYP4ni7nGYNI2HjqaBg+cPJXZeiXf8RFptmluMv75hMyyq8FYIZwVcZIEcwEgff81a7b4aNTVQ==",
|
||||
"path": "avalonia.native/11.2.3",
|
||||
"hashPath": "avalonia.native.11.2.3.nupkg.sha512"
|
||||
},
|
||||
"Avalonia.Remote.Protocol/11.2.3": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-6V0aNtld48WmO8tAlWwlRlUmXYcOWv+1eJUSl1ETF+1blUe5yhcSmuWarPprO0hDk8Ta6wGfdfcrnVl2gITYcA==",
|
||||
"path": "avalonia.remote.protocol/11.2.3",
|
||||
"hashPath": "avalonia.remote.protocol.11.2.3.nupkg.sha512"
|
||||
},
|
||||
"Avalonia.Skia/11.2.3": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-jpzqkkmhzz6DSUy5dIw5T43MoHCdb05pmTvnsmHrbipA8mafI8RrO7tVnv1+ilFNV4516G9/kOpXjTLKjnnYrA==",
|
||||
"path": "avalonia.skia/11.2.3",
|
||||
"hashPath": "avalonia.skia.11.2.3.nupkg.sha512"
|
||||
},
|
||||
"Avalonia.Themes.Fluent/11.2.3": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-gPJWBWaeocvRhSrB977xsfH0Ame14PxRMIgEfezi2bTjNJ43JWzJtALgDfDZYMpZPDdeWU/mwDigR/kD+rJtlw==",
|
||||
"path": "avalonia.themes.fluent/11.2.3",
|
||||
"hashPath": "avalonia.themes.fluent.11.2.3.nupkg.sha512"
|
||||
},
|
||||
"Avalonia.Win32/11.2.3": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-VwdaOHvIowTSM2umeXOFIoUx4UydCXkXracwLQZaMlsWXCTJ+WwtlAIv0ZBCwQccAK+WELrdRXucvWWN8+sJCQ==",
|
||||
"path": "avalonia.win32/11.2.3",
|
||||
"hashPath": "avalonia.win32.11.2.3.nupkg.sha512"
|
||||
},
|
||||
"Avalonia.X11/11.2.3": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-0mr3zu5NEv2cTLsANyc3w51ctiLTWQia6TrlDdWCjfMx2k0VtCzgGBieByPgUl4iNWEDzgBEKek1EwJcGdJ+7g==",
|
||||
"path": "avalonia.x11/11.2.3",
|
||||
"hashPath": "avalonia.x11.11.2.3.nupkg.sha512"
|
||||
},
|
||||
"HarfBuzzSharp/7.3.0.3": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-Hq+5+gx10coOvuRgB13KBwiWxJq1QeYuhtVLbA01ZCWaugOnolUahF44KvrQTUUHDNk/C7HB6SMaebsZeOdhgg==",
|
||||
"path": "harfbuzzsharp/7.3.0.3",
|
||||
"hashPath": "harfbuzzsharp.7.3.0.3.nupkg.sha512"
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Linux/7.3.0.3": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-hkcHeTfOyIeJuPtO/QfoqkDvV/MXebZYaA/Bn/S+nXsjH3Wt9oQ6okH2kklYO+1UUdBSJFd67bi9IrpQXI2mPw==",
|
||||
"path": "harfbuzzsharp.nativeassets.linux/7.3.0.3",
|
||||
"hashPath": "harfbuzzsharp.nativeassets.linux.7.3.0.3.nupkg.sha512"
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.macOS/7.3.0.3": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-UAwIYnkbBTzBJv1Id8FijY/i8QiIepRemSXufU8fyzwWhYJdx4+ajG8yQUie5HW/uusbVLFSr26muSlJOFDgSw==",
|
||||
"path": "harfbuzzsharp.nativeassets.macos/7.3.0.3",
|
||||
"hashPath": "harfbuzzsharp.nativeassets.macos.7.3.0.3.nupkg.sha512"
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.WebAssembly/7.3.0.3": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-OpheDNp9a3nC6hWNACemWkNEXJ4tWP3Gw9bykw3FbyeEmU2nUDtLIp6VgNnjHAPRMgUs1Kl7m4gJpzVYwC7CZw==",
|
||||
"path": "harfbuzzsharp.nativeassets.webassembly/7.3.0.3",
|
||||
"hashPath": "harfbuzzsharp.nativeassets.webassembly.7.3.0.3.nupkg.sha512"
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Win32/7.3.0.3": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-RPxRXD16KtSs8Yxr2RK9Qs7AwyN9MlpqZIYs0AvfaJwl7RAtVhC0+u2f2SKwX0uMYYd3O98Z+OBA1sj6aWVKQA==",
|
||||
"path": "harfbuzzsharp.nativeassets.win32/7.3.0.3",
|
||||
"hashPath": "harfbuzzsharp.nativeassets.win32.7.3.0.3.nupkg.sha512"
|
||||
},
|
||||
"MicroCom.Runtime/0.11.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-MEnrZ3UIiH40hjzMDsxrTyi8dtqB5ziv3iBeeU4bXsL/7NLSal9F1lZKpK+tfBRnUoDSdtcW3KufE4yhATOMCA==",
|
||||
"path": "microcom.runtime/0.11.0",
|
||||
"hashPath": "microcom.runtime.0.11.0.nupkg.sha512"
|
||||
},
|
||||
"SkiaSharp/2.88.9": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-3MD5VHjXXieSHCleRLuaTXmL2pD0mB7CcOB1x2kA1I4bhptf4e3R27iM93264ZYuAq6mkUyX5XbcxnZvMJYc1Q==",
|
||||
"path": "skiasharp/2.88.9",
|
||||
"hashPath": "skiasharp.2.88.9.nupkg.sha512"
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Linux/2.88.9": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
|
||||
"path": "skiasharp.nativeassets.linux/2.88.9",
|
||||
"hashPath": "skiasharp.nativeassets.linux.2.88.9.nupkg.sha512"
|
||||
},
|
||||
"SkiaSharp.NativeAssets.macOS/2.88.9": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA==",
|
||||
"path": "skiasharp.nativeassets.macos/2.88.9",
|
||||
"hashPath": "skiasharp.nativeassets.macos.2.88.9.nupkg.sha512"
|
||||
},
|
||||
"SkiaSharp.NativeAssets.WebAssembly/2.88.9": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-kt06RccBHSnAs2wDYdBSfsjIDbY3EpsOVqnlDgKdgvyuRA8ZFDaHRdWNx1VHjGgYzmnFCGiTJBnXFl5BqGwGnA==",
|
||||
"path": "skiasharp.nativeassets.webassembly/2.88.9",
|
||||
"hashPath": "skiasharp.nativeassets.webassembly.2.88.9.nupkg.sha512"
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Win32/2.88.9": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w==",
|
||||
"path": "skiasharp.nativeassets.win32/2.88.9",
|
||||
"hashPath": "skiasharp.nativeassets.win32.2.88.9.nupkg.sha512"
|
||||
},
|
||||
"System.IO.Pipelines/8.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-FHNOatmUq0sqJOkTx+UF/9YK1f180cnW5FVqnQMvYUN0elp6wFzbtPSiqbo1/ru8ICp43JM1i7kKkk6GsNGHlA==",
|
||||
"path": "system.io.pipelines/8.0.0",
|
||||
"hashPath": "system.io.pipelines.8.0.0.nupkg.sha512"
|
||||
},
|
||||
"Tmds.DBus.Protocol/0.20.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-2gkt2kuYPhDKd8gtl34jZSJOnn4nRJfFngCDcTZT/uySbK++ua0YQx2418l9Rn1Y4dE5XNq6zG9ZsE5ltLlNNw==",
|
||||
"path": "tmds.dbus.protocol/0.20.0",
|
||||
"hashPath": "tmds.dbus.protocol.0.20.0.nupkg.sha512"
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,42 +0,0 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using Microsoft.Identity.Client;
|
||||
|
||||
public class HttpFactoryWithProxy : IMsalHttpClientFactory
|
||||
{
|
||||
private static HttpClient _httpClient;
|
||||
|
||||
public HttpFactoryWithProxy(string proxyURI) : this(proxyURI, null, null)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public HttpFactoryWithProxy(string proxyURI, string proxyUserName = null, string proxyPassword = null)
|
||||
{
|
||||
if (_httpClient == null)
|
||||
{
|
||||
var proxy = new WebProxy
|
||||
{
|
||||
Address = new Uri(proxyURI),
|
||||
BypassProxyOnLocal = false,
|
||||
UseDefaultCredentials = false,
|
||||
Credentials = new NetworkCredential(
|
||||
userName: proxyUserName,
|
||||
password: proxyPassword)
|
||||
};
|
||||
|
||||
var httpClientHandler = new HttpClientHandler
|
||||
{
|
||||
Proxy = proxy,
|
||||
};
|
||||
|
||||
_httpClient = new HttpClient(handler: httpClientHandler);
|
||||
}
|
||||
}
|
||||
|
||||
public HttpClient GetHttpClient()
|
||||
{
|
||||
return _httpClient;
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
// Updated original code from
|
||||
// Added support for custom file location
|
||||
// https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-net-token-cache-serialization#simple-token-cache-serialization-msal-only
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Identity.Client;
|
||||
|
||||
public static class TokenCacheHelperEx
|
||||
{
|
||||
public static void EnableSerialization(ITokenCache tokenCache, String fileName = @"%LOCALAPPDATA%\GraphPowerShellManager\MSALToken.bin")
|
||||
{
|
||||
tokenCache.SetBeforeAccess(BeforeAccessNotification);
|
||||
tokenCache.SetAfterAccess(AfterAccessNotification);
|
||||
|
||||
CacheFilePath = Environment.ExpandEnvironmentVariables(fileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Path to the token cache
|
||||
/// </summary>
|
||||
|
||||
public static string CacheFilePath { get; private set;}
|
||||
|
||||
private static readonly object FileLock = new object();
|
||||
|
||||
private static void BeforeAccessNotification(TokenCacheNotificationArgs args)
|
||||
{
|
||||
lock (FileLock)
|
||||
{
|
||||
args.TokenCache.DeserializeMsalV3(File.Exists(CacheFilePath)
|
||||
? ProtectedData.Unprotect(File.ReadAllBytes(CacheFilePath),
|
||||
null,
|
||||
DataProtectionScope.CurrentUser)
|
||||
: null);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AfterAccessNotification(TokenCacheNotificationArgs args)
|
||||
{
|
||||
// if the access operation resulted in a cache update
|
||||
if (args.HasStateChanged)
|
||||
{
|
||||
lock (FileLock)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(CacheFilePath));
|
||||
// reflect changes in the persistent store
|
||||
File.WriteAllBytes(CacheFilePath,
|
||||
ProtectedData.Protect(args.TokenCache.SerializeMsalV3(),
|
||||
null,
|
||||
DataProtectionScope.CurrentUser)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,364 +0,0 @@
|
||||
#ImportOrder 26
|
||||
|
||||
# MSAL implementation of AuthenticationProvider.
|
||||
#
|
||||
# Naming convention: every concrete auth provider is named Authentication<Backend>
|
||||
# so they sort together in the Classes/ folder (AuthenticationMgGraph, AuthenticationOAuth).
|
||||
#
|
||||
# This class is a facade over the MSAL functions in Internal/AuthenticationMSALHelpers.ps1
|
||||
# (Connect-EntraEnvironment / Connect-WithClientCredentials / Get-FullToken). Consumers
|
||||
# reach MSAL through the provider abstraction: Invoke-MSGraphAPI resolves a call's owning
|
||||
# provider by TokenId and asks it for the bearer via GetAccessToken.
|
||||
class AuthenticationMSAL : AuthenticationProvider {
|
||||
|
||||
# TokenIds currently inside a pre-flight silent refresh. Used by GetAccessToken
|
||||
# to break the re-entrancy cycle: Connect-EntraEnvironment itself calls back
|
||||
# into Graph (Organization / ME / photo) and those calls land here for the
|
||||
# bearer header. Without a guard the nested call sees the still-cached
|
||||
# expired token and recurses into Connect-EntraEnvironment forever — caught
|
||||
# in the wild as a "call depth overflow" crash. The first-in caller drives
|
||||
# the refresh; nested calls return the cached bearer (which Connect's inner
|
||||
# Invoke-MSGraphAPI -SkipAuthentication can already cope with).
|
||||
static [hashtable]$Refreshing = @{}
|
||||
|
||||
AuthenticationMSAL() {
|
||||
$this.Id = "MSAL"
|
||||
$this.DisplayName = "Microsoft Authentication Library"
|
||||
|
||||
# Required capabilities — all true (Interactive / ClientSecret / Certificate
|
||||
# default to $true on the base class). Set explicitly here as a contract
|
||||
# marker so a future edit can't accidentally turn one off.
|
||||
$this.SupportsInteractive = $true
|
||||
$this.SupportsClientSecret = $true
|
||||
$this.SupportsCertificate = $true
|
||||
|
||||
# Optional capability: MSAL.NET supports federated credentials via
|
||||
# WithClientAssertion + managed identity via WithAzureMSI, but
|
||||
# Connect-IntuneManagement does NOT expose those paths yet. Flag stays
|
||||
# $false until the parameter sets are added.
|
||||
$this.SupportsIdentityProvider = $false
|
||||
|
||||
# MSAL accepts BYO bearer tokens through Add-BYOTokenInfo.
|
||||
$this.SupportsBYOToken = $true
|
||||
|
||||
# MSAL re-mints tokens for CAE claims challenges (silent, escalating to
|
||||
# interactive when the caller allows it). See GetClaimsToken.
|
||||
$this.SupportsClaimsChallenge = $true
|
||||
|
||||
# This provider's flows run through the built-in Connect-EntraEnvironment /
|
||||
# Connect-WithClientCredentials entry points (see UsesBuiltInConnectPath on the
|
||||
# base): Connect-IntuneManagement, the interactive-login helper, and the profile
|
||||
# Refresh action drive MSAL through those functions directly, keeping the rich
|
||||
# cloud/token-id handling and behaviour identical to the pre-abstraction path.
|
||||
$this.UsesBuiltInConnectPath = $true
|
||||
|
||||
# MSAL can launch an interactive consent prompt via Start-MSALConsentPrompt.
|
||||
$this.SupportsConsentPrompt = $true
|
||||
}
|
||||
|
||||
# No initialization work — MSAL DLLs and settings are wired by Invoke-MSALInitialize
|
||||
# which runs from AuthenticationMSALHelpers.ps1 at module load.
|
||||
[void] Initialize() { }
|
||||
|
||||
[PSCustomObject] Connect([hashtable]$Arguments) {
|
||||
# Two entry points historically:
|
||||
# Connect-IntuneManagement : public API with explicit Secret / Certificate / Token
|
||||
# Connect-EntraEnvironment : internal — interactive, silent, refresh
|
||||
# Pick based on which fields are present in $Arguments.
|
||||
|
||||
# Copy first so any Cloud→legacy translation we do here doesn't mutate the
|
||||
# caller's hashtable. We translate Cloud→GraphEnvironment+GCCType because the
|
||||
# downstream MSAL functions haven't migrated to the new enum yet (Phase 4).
|
||||
$local = @{}
|
||||
foreach($key in $Arguments.Keys) { $local[$key] = $Arguments[$key] }
|
||||
if($local.ContainsKey('Cloud') -and $local.Cloud -and -not $local.ContainsKey('GraphEnvironment')) {
|
||||
$entry = Get-CloudByValue ([string]$local.Cloud)
|
||||
if($entry) {
|
||||
$local['GraphEnvironment'] = $entry.LegacyEnv
|
||||
if($entry.LegacyGCC) { $local['GCCType'] = $entry.LegacyGCC }
|
||||
}
|
||||
}
|
||||
|
||||
if($local.ContainsKey('Secret') -or $local.ContainsKey('Certificate') -or
|
||||
$local.ContainsKey('CertificatePath') -or $local.ContainsKey('Token')) {
|
||||
# Connect-IntuneManagement now understands -Cloud directly; we still pass
|
||||
# the legacy params for compatibility (Connect-IntuneManagement re-resolves
|
||||
# them with -Cloud taking precedence).
|
||||
return (Connect-IntuneManagement @local)
|
||||
}
|
||||
|
||||
# Connect-EntraEnvironment uses the internal -Environment parameter (the MSAL
|
||||
# function predates our public taxonomy). Translate before splatting so the
|
||||
# cloud picker dialog's choice actually reaches MSAL. GCCType is NOT a
|
||||
# Connect-EntraEnvironment parameter (only Connect-WithClientCredentials
|
||||
# / Add-BYOTokenInfo take it) — splatting it triggers "Cannot bind
|
||||
# positional parameters"; drop it. Cloud IS a parameter on
|
||||
# Connect-EntraEnvironment now, so we no longer need to drop it either.
|
||||
if($local.ContainsKey('GraphEnvironment')) {
|
||||
$local['Environment'] = $local['GraphEnvironment']
|
||||
$local.Remove('GraphEnvironment') | Out-Null
|
||||
}
|
||||
if($local.ContainsKey('GCCType')) { $local.Remove('GCCType') | Out-Null }
|
||||
return (Connect-EntraEnvironment @local)
|
||||
}
|
||||
|
||||
[bool] Disconnect([int]$TokenId) {
|
||||
try {
|
||||
Disconnect-EntraEnvironment -TokenID $TokenId
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-LogError "MSAL Disconnect failed for TokenId $TokenId" $_.Exception
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
[bool] Refresh([int]$TokenId) {
|
||||
try {
|
||||
return [bool](Connect-EntraEnvironment -TokenId $TokenId -ForceRefresh)
|
||||
}
|
||||
catch {
|
||||
Write-LogError "MSAL Refresh failed for TokenId $TokenId" $_.Exception
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# MSAL is the one provider that can do this: the same account can mint a second
|
||||
# token for the Azure Resource Manager audience, which is the only API that
|
||||
# enumerates a user's tenants (see Internal/EntraTenantList.ps1 for why Graph
|
||||
# cannot). Returns the result object that file documents, or $null when there
|
||||
# is no usable MSAL session to ask with.
|
||||
[PSCustomObject] GetAccessibleTenants([int]$TokenId) {
|
||||
$tokenInfo = Get-FullToken $TokenId
|
||||
if(-not $tokenInfo -or -not $tokenInfo.App -or -not $tokenInfo.Token -or -not $tokenInfo.Token.Account) {
|
||||
Write-LogDebug "MSAL GetAccessibleTenants: no usable token for id $TokenId"
|
||||
return $null
|
||||
}
|
||||
|
||||
# Only offer the no-prompt interactive fallback once the UI is up; in a
|
||||
# script there is nobody to answer a window that may appear.
|
||||
$interactive = ($script:MainAppStarted -eq $true)
|
||||
|
||||
return (Get-EntraAccessibleTenant -App $tokenInfo.App -Account $tokenInfo.Token.Account `
|
||||
-TenantId $tokenInfo.Token.TenantId -Cloud $tokenInfo.Cloud -AllowInteractive:$interactive)
|
||||
}
|
||||
|
||||
# Silent startup resume — re-establish the last session from the persisted MSAL
|
||||
# cache without prompting. Invoked once from Invoke-AuthCoreOnAppInitialized for
|
||||
# the active provider; restores the old Connect-MSALUser -Silent startup logon
|
||||
# that made the app auto-sign-in on launch. The fresh (no -TokenId) path resolves
|
||||
# the account from the on-disk cache via GetAccountsAsync matched against the
|
||||
# persisted LastLoggedOnUserId. -ForceSilent guarantees no interactive prompt: if
|
||||
# there is no cached account (or the broker can't silently reissue), it simply
|
||||
# returns $false and the user signs in manually. -DefaultToken makes the resumed
|
||||
# session the active default so the UI shows signed-in.
|
||||
[bool] TryResumeSession() {
|
||||
# Respect the "Remember Login" toggle — if the user disabled caching there is
|
||||
# nothing to resume and we should not touch the account cache.
|
||||
if(-not (Get-SettingValue "CacheMSALToken")) { return $false }
|
||||
# Cheap probe (settings + file existence only) BEFORE the MSAL runtime loads:
|
||||
# a fresh box with no cached session skips the DLL load entirely.
|
||||
if(-not (Test-MSALResumeLikely)) {
|
||||
Write-LogDebug "MSAL TryResumeSession skipped - no cached session to resume"
|
||||
return $false
|
||||
}
|
||||
try {
|
||||
$result = Connect-EntraEnvironment -ForceSilent -DefaultToken
|
||||
return [bool]$result
|
||||
}
|
||||
catch {
|
||||
Write-LogError "MSAL TryResumeSession failed" $_.Exception
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# Silent ambient refresh on view activation. Gated on the base no-op for other
|
||||
# providers so MgGraph mode isn't hijacked (a successful silent MSAL auth here would
|
||||
# flip the active provider). No -DefaultToken: this only refreshes, never promotes.
|
||||
[void] RefreshAmbientSession() {
|
||||
Connect-EntraEnvironment -ForceSilent | Out-Null
|
||||
}
|
||||
|
||||
# Native session inspector rows for the profile "Session Info" dialog: the MSAL
|
||||
# AuthenticationResult fields (minus the raw tokens).
|
||||
# Decoded id-token JWT for the profile popup's Id Token inspector. Reads MSAL's
|
||||
# own token entry from the registry; returns $null when there's no id token so the
|
||||
# UI hides the button. This keeps the id-token JWT confined to the MSAL provider.
|
||||
[object] GetIdTokenJwt([int]$TokenId) {
|
||||
$t = Get-FullToken $TokenId
|
||||
if($t -and $t.JWTIdToken) { return $t.JWTIdToken }
|
||||
return $null
|
||||
}
|
||||
|
||||
[PSCustomObject[]] GetSessionInfoRows() {
|
||||
$rows = @()
|
||||
if($script:MSALDefaultToken -and $script:MSALDefaultToken.Token) {
|
||||
foreach($prop in ($script:MSALDefaultToken.Token | Get-Member | Where-Object MemberType -eq Property)) {
|
||||
if($prop.Name -in @("AccessToken", "IdToken")) { continue }
|
||||
$value = if($prop.Name -eq "Scopes") { ($script:MSALDefaultToken.Token.Scopes -join "`n") }
|
||||
elseif($prop.Name -in @("ExpiresOn", "ExtendedExpiresOn")) { $script:MSALDefaultToken.Token."$($prop.Name)".LocalDateTime }
|
||||
else { $script:MSALDefaultToken.Token."$($prop.Name)" }
|
||||
$rows += [PSCustomObject]@{ Name = $prop.Name; Value = $value }
|
||||
}
|
||||
}
|
||||
return [PSCustomObject[]]$rows
|
||||
}
|
||||
|
||||
[bool] ForgetAccount([string]$AccountIdentifier) {
|
||||
if(-not $script:MSALAccounts) { return $false }
|
||||
$account = $script:MSALAccounts | Where-Object {
|
||||
$_.Username -eq $AccountIdentifier -or
|
||||
$_.HomeAccountId.Identifier -eq $AccountIdentifier
|
||||
} | Select-Object -First 1
|
||||
if(-not $account) { return $false }
|
||||
Remove-MSALAccount -Account $account
|
||||
return $true
|
||||
}
|
||||
|
||||
[string] GetAccessToken([int]$TokenId, [string]$Resource) {
|
||||
$tok = Get-FullToken $TokenId
|
||||
if(-not $tok -or -not $tok.Token) { return $null }
|
||||
|
||||
# Pre-flight refresh near expiry to avoid mid-batch 401s. Today the resource is
|
||||
# always Graph (the MSAL token caches a single audience); when other resources
|
||||
# are supported in a future phase, the provider should mint per-resource tokens
|
||||
# here via AcquireTokenSilent.WithScopes(resource/.default).
|
||||
#
|
||||
# Re-entrancy guard: Connect-EntraEnvironment internally calls Invoke-MSGraphAPI
|
||||
# ('Organization', 'ME', photo) before its own returns; those calls land back
|
||||
# in this method for the bearer header. The cached token is still the expired
|
||||
# one at that point — the new token isn't installed until Connect's
|
||||
# Add-MSALTokenInfo runs at the tail. Without a guard the nested call retries
|
||||
# the refresh, which calls Invoke-MSGraphAPI, which re-enters here, etc., until
|
||||
# PowerShell's call-depth limit aborts.
|
||||
if($tok.Token.ExpiresOn -lt [DateTimeOffset]::UtcNow.AddMinutes(5) -and
|
||||
-not [AuthenticationMSAL]::Refreshing.ContainsKey($TokenId)) {
|
||||
[AuthenticationMSAL]::Refreshing[$TokenId] = $true
|
||||
# Only let a refresh miss raise AuthenticationFailed once the token has
|
||||
# ACTUALLY expired. Within the 5-minute pre-flight window the current token
|
||||
# is still usable, so a silent-refresh miss (e.g. the WAM broker failing on
|
||||
# the refresh round-trip) must stay quiet - otherwise every near-expiry Graph
|
||||
# call falsely reports a failed login even though the call then succeeds on
|
||||
# the still-valid token. When truly expired, stay loud so the UI signs out.
|
||||
$tokenStillValid = $tok.Token.ExpiresOn -gt [DateTimeOffset]::UtcNow
|
||||
try {
|
||||
# Plain silent acquire (NO -ForceRefresh). AcquireTokenSilent already
|
||||
# renews an expired/near-expired access token from the refresh token
|
||||
# (or via the WAM broker) on its own. Forcing a refresh here made the
|
||||
# broker re-contact Entra ~5 min before every expiry, and WAM can surface
|
||||
# an interactive window on that forced round-trip - that was the ~70-min
|
||||
# re-login. The old 3.9.6 build never force-refreshed routinely (only on
|
||||
# an explicit user "Force refresh" link); this matches it. -ForceRefresh
|
||||
# is still used by Refresh() and the UI Refresh button where it is wanted.
|
||||
[void](Connect-EntraEnvironment -TokenId $TokenId -ForceSilent -SuppressFailedEvent:$tokenStillValid)
|
||||
}
|
||||
finally {
|
||||
[AuthenticationMSAL]::Refreshing.Remove($TokenId) | Out-Null
|
||||
}
|
||||
$tok = Get-FullToken $TokenId
|
||||
if(-not $tok -or -not $tok.Token) { return $null }
|
||||
}
|
||||
return $tok.Token.AccessToken
|
||||
}
|
||||
|
||||
# Satisfy a CAE claims challenge. Silent re-acquire first (broker/WAM can often
|
||||
# satisfy a CAE / sign-in-frequency challenge without a visible prompt); if that
|
||||
# fails and the caller allows interaction, escalate to an interactive acquire with
|
||||
# the same claims so the challenge is met with a single prompt instead of a dead
|
||||
# 401. When $AllowInteractive is $false (headless / nested auth-flow call) this
|
||||
# stays silent-only and returns $null if the challenge can't be met.
|
||||
[string] GetClaimsToken([int]$TokenId, [string]$Resource, [string]$ClaimsChallenge, [bool]$AllowInteractive) {
|
||||
[void](Connect-EntraEnvironment -TokenId $TokenId -ClaimsChallenge $ClaimsChallenge -ForceSilent)
|
||||
$token = $this.GetAccessToken($TokenId, $Resource)
|
||||
|
||||
if(-not $token -and $AllowInteractive) {
|
||||
Write-Log "CAE challenge could not be satisfied silently. Escalating to interactive login." 2
|
||||
[void](Connect-EntraEnvironment -TokenId $TokenId -ClaimsChallenge $ClaimsChallenge -ForceInteractive)
|
||||
$token = $this.GetAccessToken($TokenId, $Resource)
|
||||
}
|
||||
return $token
|
||||
}
|
||||
|
||||
[datetime] GetAccessTokenExpiry([int]$TokenId, [string]$Resource) {
|
||||
$tok = Get-FullToken $TokenId
|
||||
if(-not $tok -or -not $tok.Token -or -not $tok.Token.ExpiresOn) {
|
||||
return [datetime]::MaxValue
|
||||
}
|
||||
return $tok.Token.ExpiresOn.LocalDateTime
|
||||
}
|
||||
|
||||
[PSCustomObject] GetUserInfo([int]$TokenId) {
|
||||
$tok = Get-FullToken $TokenId
|
||||
if(-not $tok) { return $null }
|
||||
|
||||
$authType = if($tok.AuthType) { $tok.AuthType } else { "Interactive" }
|
||||
$expires = $null
|
||||
if($tok.Token -and $tok.Token.ExpiresOn) { $expires = $tok.Token.ExpiresOn.LocalDateTime }
|
||||
|
||||
$upn = $null
|
||||
if($tok.Token -and $tok.Token.Account) { $upn = $tok.Token.Account.Username }
|
||||
|
||||
$userId = $null
|
||||
if($tok.Token -and $tok.Token.Account -and $tok.Token.Account.HomeAccountId) {
|
||||
$userId = $tok.Token.Account.HomeAccountId.ObjectId
|
||||
}
|
||||
|
||||
return [PSCustomObject]@{
|
||||
Provider = $this.Id
|
||||
DisplayName = $upn
|
||||
UPN = $upn
|
||||
UserId = $userId
|
||||
TenantId = (?: $tok.Token $tok.Token.TenantId $null)
|
||||
TenantName = (?: $tok.Organization $tok.Organization.displayName $null)
|
||||
AppId = (?: $tok.EntraApp $tok.EntraApp.ClientId $null)
|
||||
AppName = (?: $tok.EntraApp $tok.EntraApp.Name $null)
|
||||
AuthType = $authType
|
||||
ExpiresOn = $expires
|
||||
}
|
||||
}
|
||||
|
||||
[PSCustomObject[]] GetCachedAccounts() {
|
||||
# Lazy-refresh from the on-disk MSAL cache. Same trick the UI already does in
|
||||
# Get-MSALUserProfile, but exposed at the provider level so any consumer
|
||||
# (CLI scripts, automation) sees the same list.
|
||||
if(($script:MSALAccounts | Measure-Object).Count -eq 0) {
|
||||
try {
|
||||
$app = $script:MSALApps | Select-Object -First 1
|
||||
if(-not $app) { $app = New-MSALApp }
|
||||
if($app) {
|
||||
$script:MSALAccounts = $app.GetAccountsAsync().GetAwaiter().GetResult()
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogDebug "MSAL GetCachedAccounts refresh failed: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
if(-not $script:MSALAccounts) { return [PSCustomObject[]]@() }
|
||||
|
||||
$rows = foreach($acc in $script:MSALAccounts) {
|
||||
[PSCustomObject]@{
|
||||
Provider = $this.Id
|
||||
Username = $acc.Username
|
||||
UserId = $acc.HomeAccountId.ObjectId
|
||||
TenantId = $acc.HomeAccountId.TenantId
|
||||
Native = $acc
|
||||
}
|
||||
}
|
||||
return [PSCustomObject[]]@($rows)
|
||||
}
|
||||
|
||||
[PSCustomObject[]] GetAvailableTenants([int]$TokenId) {
|
||||
$tok = Get-FullToken $TokenId
|
||||
if(-not $tok -or -not $tok.Tenants) { return [PSCustomObject[]]@() }
|
||||
|
||||
$rows = foreach($t in $tok.Tenants) {
|
||||
[PSCustomObject]@{
|
||||
Provider = $this.Id
|
||||
TenantId = $t.tenantId
|
||||
TenantName = $t.displayName
|
||||
Native = $t
|
||||
}
|
||||
}
|
||||
return [PSCustomObject[]]@($rows)
|
||||
}
|
||||
}
|
||||
@@ -1,652 +0,0 @@
|
||||
#ImportOrder 26
|
||||
|
||||
# Microsoft.Graph PowerShell SDK implementation of AuthenticationProvider.
|
||||
#
|
||||
# Wraps the Microsoft.Graph.Authentication module (Connect-MgGraph, Disconnect-MgGraph,
|
||||
# Get-MgContext). The SDK itself uses MSAL.NET underneath but with its own session
|
||||
# state and its own on-disk token cache, separate from our MSAL provider — by design,
|
||||
# per the user's decision to keep caches separate.
|
||||
#
|
||||
# Status:
|
||||
# * The class always registers (even without the SDK installed) so it is selectable
|
||||
# in Settings; the SDK modules are resolved / prompted-for on first Connect.
|
||||
# * `Connect-IntuneManagement -Provider MgGraph -...` routes here.
|
||||
# * Invoke-MSGraphAPI routes requests for MgGraph-owned tokens through this provider:
|
||||
# when the SDK's opaque cache can't yield a raw bearer, the request runs via the
|
||||
# provider's own pipeline (see InvokeWebRequest / Invoke-MgGraphRequestAsWebResponse).
|
||||
#
|
||||
# Token-extraction note: the SDK does not expose the raw access token via a public
|
||||
# cmdlet. We reach into [Microsoft.Graph.PowerShell.Authentication.GraphSession]::Instance
|
||||
# which is the documented (in source) but undocumented (in MS Learn) accessor. In SDK
|
||||
# v2 the AccessToken is a SecureString; we unprotect at the last moment.
|
||||
#
|
||||
# Minimum SDK version for CAE: Microsoft.Graph.Authentication 2.37.0+ is recommended.
|
||||
# Earlier versions had a token-cache bug (fixed by PR #3573, May 2026) where the
|
||||
# `caeEnabled: true` capability was not included when caching tokens — so a CAE
|
||||
# claim-challenge round-trip could re-prompt instead of resolving silently. Older
|
||||
# SDK versions still work for non-CAE flows.
|
||||
class AuthenticationMgGraph : AuthenticationProvider {
|
||||
|
||||
AuthenticationMgGraph() {
|
||||
$this.Id = "MgGraph"
|
||||
$this.DisplayName = "Microsoft Graph PowerShell SDK"
|
||||
|
||||
# Required capabilities (see AuthenticationProvider contract).
|
||||
$this.SupportsInteractive = $true
|
||||
$this.SupportsClientSecret = $true
|
||||
$this.SupportsCertificate = $true
|
||||
|
||||
# Optional: SDK has -Identity flag for managed identity.
|
||||
$this.SupportsIdentityProvider = $true
|
||||
|
||||
# Optional: SDK accepts -AccessToken.
|
||||
$this.SupportsBYOToken = $true
|
||||
|
||||
# The SDK keeps cached accounts in a private InMemoryTokenCache byte[] (the
|
||||
# serialized MSAL-v3 cache). GetCachedAccounts() reaches in via reflection
|
||||
# and rehydrates an MSAL public-client app to enumerate the accounts —
|
||||
# source pattern: github.com/microsoftgraph/msgraph-sdk-powershell.
|
||||
# NOTE: this cache is in-memory only (NOT persisted to disk) — accounts only
|
||||
# show up within the current PowerShell session, and clicking one cannot
|
||||
# "switch to" that account because Connect-MgGraph has no -LoginHint
|
||||
# parameter. The list is informational; the user must re-Connect-MgGraph
|
||||
# to change accounts.
|
||||
$this.SupportsCachedUsers = $true
|
||||
|
||||
# SDK has no per-call tenant switching — you Disconnect and Connect with a
|
||||
# different -TenantId. The active session is single-tenant.
|
||||
$this.SupportsMultiTenant = $false
|
||||
|
||||
# The SDK refreshes internally, but a manual "Refresh" action is meaningful
|
||||
# for users — we re-trigger Connect-MgGraph (silent if the cache has a
|
||||
# valid refresh token, interactive otherwise).
|
||||
$this.SupportsRefresh = $true
|
||||
|
||||
# No way to evict a single cached account from the SDK's token cache via
|
||||
# public cmdlets. Disconnect-MgGraph clears the active session only.
|
||||
$this.SupportsForget = $false
|
||||
}
|
||||
|
||||
[void] Initialize() {
|
||||
# Module presence check happens at registration time in
|
||||
# Internal/AuthenticationMgGraphHelpers.ps1 — by the time we get here, the SDK is
|
||||
# known to be installed. We do NOT eagerly Import-Module (load cost is
|
||||
# ~hundreds of ms); the first Connect() call imports lazily.
|
||||
}
|
||||
|
||||
# The SDK v2 in-memory token cache is opaque, so we can't hand Invoke-MSGraphAPI a
|
||||
# raw bearer. Route the request through the SDK's own pipeline (which auths it) and
|
||||
# wrap the result so it quacks like Invoke-WebRequest's response.
|
||||
[object] InvokeWebRequest([string]$Url, [string]$Method, [object]$Body, [hashtable]$Headers) {
|
||||
return (Invoke-MgGraphRequestAsWebResponse -Url $Url -Method $Method -Body $Body -Headers $Headers)
|
||||
}
|
||||
|
||||
# Native session inspector rows for the profile "Session Info" dialog: Get-MgContext
|
||||
# properties (the closest MgGraph equivalent of MSAL's AuthenticationResult).
|
||||
[PSCustomObject[]] GetSessionInfoRows() {
|
||||
$rows = @()
|
||||
try {
|
||||
$ctx = Get-MgContext -ErrorAction SilentlyContinue
|
||||
if($ctx) {
|
||||
foreach($prop in ($ctx | Get-Member -MemberType Properties)) {
|
||||
$value = $ctx."$($prop.Name)"
|
||||
if($prop.Name -eq "Scopes" -and $value) { $value = ($value -join "`n") }
|
||||
if($value -is [SecureString]) { $value = "<SecureString>" }
|
||||
$rows += [PSCustomObject]@{ Name = $prop.Name; Value = $value }
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
return [PSCustomObject[]]$rows
|
||||
}
|
||||
|
||||
# Silent cross-session resume. Microsoft.Graph SDK v2 persists credentials by
|
||||
# default (ContextScope.CurrentUser): the MSAL cache lives at
|
||||
# %LOCALAPPDATA%\.IdentityService\mg.msal.cache and the AuthenticationRecord
|
||||
# anchor at %USERPROFILE%\.mg\mg.authrecord.json. Both must exist; if so, a
|
||||
# plain Connect-MgGraph -NoWelcome silently rehydrates the session via
|
||||
# Azure.Identity's MsalCacheHelper. No browser, no prompt.
|
||||
[bool] TryResumeSession() {
|
||||
try {
|
||||
if(-not (Resolve-MgGraphModule)) { return $false }
|
||||
|
||||
$anchor = Join-Path $env:USERPROFILE ".mg\mg.authrecord.json"
|
||||
if(-not (Test-Path $anchor)) {
|
||||
Write-LogDebug "MgGraph: no auth record at $anchor - skipping silent resume"
|
||||
return $false
|
||||
}
|
||||
|
||||
try {
|
||||
Import-Module Microsoft.Graph.Authentication -ErrorAction Stop | Out-Null
|
||||
}
|
||||
catch {
|
||||
Write-LogError "MgGraph TryResumeSession: failed to import Microsoft.Graph.Authentication" $_.Exception
|
||||
return $false
|
||||
}
|
||||
|
||||
# If a context is already active (e.g. another caller already connected
|
||||
# during this session), respect it.
|
||||
$existing = $null
|
||||
try { $existing = Get-MgContext -ErrorAction SilentlyContinue } catch { }
|
||||
if($existing) {
|
||||
Write-Log "MgGraph: already signed in as $($existing.Account) (tenant $($existing.TenantId)); silent resume not needed"
|
||||
return $true
|
||||
}
|
||||
|
||||
Write-Log "MgGraph: attempting silent resume from persisted Azure.Identity cache..."
|
||||
|
||||
# NoWelcome suppresses banner; no Scopes parameter means Azure.Identity
|
||||
# uses whatever scopes were in the AuthenticationRecord. If the cache or
|
||||
# record is stale, Connect-MgGraph will throw / require interaction —
|
||||
# we treat any failure as "resume not possible, user must click Login".
|
||||
Connect-MgGraph -NoWelcome -ErrorAction Stop | Out-Null
|
||||
|
||||
$ctx = $null
|
||||
try { $ctx = Get-MgContext -ErrorAction SilentlyContinue } catch { }
|
||||
if($ctx) {
|
||||
Write-Log "MgGraph: silent resume succeeded - signed in as $($ctx.Account) (tenant $($ctx.TenantId))"
|
||||
return $true
|
||||
}
|
||||
Write-Log "MgGraph: Connect-MgGraph completed but Get-MgContext returned nothing - silent resume failed" 2
|
||||
return $false
|
||||
}
|
||||
catch {
|
||||
Write-LogDebug "MgGraph: silent resume failed: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
[PSCustomObject] Connect([hashtable]$Arguments) {
|
||||
Write-Log "AuthenticationMgGraph.Connect starting"
|
||||
|
||||
# Step 1: ensure the required SDK module is available. If not, offer to
|
||||
# install it. If the user declines or install fails, return $null so
|
||||
# Connect-IntuneManagement can fall back to MSAL.
|
||||
if(-not (Resolve-MgGraphModule)) {
|
||||
Write-Log "Microsoft.Graph.Authentication not available - MgGraph provider cannot connect" 2
|
||||
return $null
|
||||
}
|
||||
|
||||
try {
|
||||
Import-Module Microsoft.Graph.Authentication -ErrorAction Stop | Out-Null
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to import Microsoft.Graph.Authentication" $_.Exception
|
||||
return $null
|
||||
}
|
||||
|
||||
$mgArgs = @{ NoWelcome = $true }
|
||||
|
||||
if($Arguments.TenantId) { $mgArgs['TenantId'] = $Arguments.TenantId }
|
||||
if($Arguments.AppId) { $mgArgs['ClientId'] = $Arguments.AppId }
|
||||
|
||||
# Cloud / sovereign environment selection. Prefer the flat -Cloud arg
|
||||
# (Phase 1, 2026-05-22). Fall back to translating the legacy GraphEnvironment+GCCType
|
||||
# pair so direct provider callers passing the old shape still work during the
|
||||
# deprecation window. Connect-MgGraph -Environment accepts: Global / USGov / USGovDOD / China.
|
||||
$cloudValue = if($Arguments.Cloud) { [string]$Arguments.Cloud }
|
||||
elseif($Arguments.GraphEnvironment -or $Arguments.GCCType) {
|
||||
Convert-LegacyToCloud -GraphEnvironment ([string]$Arguments.GraphEnvironment) -GCCType ([string]$Arguments.GCCType)
|
||||
}
|
||||
else { "Public" }
|
||||
|
||||
$cloudEntry = Get-CloudByValue $cloudValue
|
||||
$mgEnv = $cloudEntry.MgEnvironment
|
||||
if($mgEnv -and $mgEnv -ne "Global") {
|
||||
$mgArgs['Environment'] = $mgEnv
|
||||
Write-LogDebug "MgGraph: using -Environment $mgEnv (Cloud=$cloudValue)"
|
||||
}
|
||||
|
||||
# Dispatch on auth method. Connect-MgGraph parameter sets are mutually
|
||||
# exclusive, so we pick exactly one.
|
||||
if($Arguments.ContainsKey('Secret') -and $Arguments.Secret) {
|
||||
if(-not $Arguments.AppId) { Write-Log "MgGraph: -AppId required with -Secret" 3; return $null }
|
||||
if(-not $Arguments.TenantId) { Write-Log "MgGraph: -TenantId required with -Secret" 3; return $null }
|
||||
$secStr = if($Arguments.Secret -is [SecureString]) { $Arguments.Secret }
|
||||
else { ConvertTo-SecureString ([string]$Arguments.Secret) -AsPlainText -Force }
|
||||
$mgArgs['ClientSecretCredential'] = [PSCredential]::new($Arguments.AppId, $secStr)
|
||||
# ClientId + TenantId are conveyed via the credential here; remove the
|
||||
# standalone entries so we don't conflict with the credential parameter set.
|
||||
$mgArgs.Remove('ClientId') | Out-Null
|
||||
}
|
||||
elseif($Arguments.ContainsKey('Certificate') -and $Arguments.Certificate) {
|
||||
if($Arguments.Certificate -is [System.Security.Cryptography.X509Certificates.X509Certificate2]) {
|
||||
$mgArgs['Certificate'] = $Arguments.Certificate
|
||||
}
|
||||
else {
|
||||
# Treat as thumbprint string
|
||||
$mgArgs['CertificateThumbprint'] = [string]$Arguments.Certificate
|
||||
}
|
||||
}
|
||||
elseif($Arguments.ContainsKey('CertificatePath') -and $Arguments.CertificatePath) {
|
||||
$cert = Get-PfxCertificate -FilePath $Arguments.CertificatePath -Password $Arguments.CertificatePassword -ErrorAction Stop
|
||||
$mgArgs['Certificate'] = $cert
|
||||
}
|
||||
elseif($Arguments.ContainsKey('Token') -and $Arguments.Token) {
|
||||
$tokStr = if($Arguments.Token -is [SecureString]) { $Arguments.Token }
|
||||
else { ConvertTo-SecureString ([string]$Arguments.Token) -AsPlainText -Force }
|
||||
$mgArgs['AccessToken'] = $tokStr
|
||||
}
|
||||
elseif($Arguments.ContainsKey('ManagedIdentity') -and $Arguments.ManagedIdentity) {
|
||||
$mgArgs['Identity'] = $true
|
||||
# For user-assigned managed identity, the user can pass a specific client id.
|
||||
if($Arguments.ManagedIdentityClientId) { $mgArgs['ClientId'] = $Arguments.ManagedIdentityClientId }
|
||||
}
|
||||
elseif($Arguments.ContainsKey('DeviceCode') -and $Arguments.DeviceCode) {
|
||||
# Microsoft.Graph.Authentication 2.x exposes device code as the
|
||||
# -UseDeviceCode switch on Connect-MgGraph. Emits the code and
|
||||
# verification URL to the console and blocks until the user
|
||||
# completes auth in a browser on any device.
|
||||
$mgArgs['UseDeviceCode'] = $true
|
||||
if($Arguments.Scopes) { $mgArgs['Scopes'] = $Arguments.Scopes }
|
||||
}
|
||||
else {
|
||||
# Interactive. Default scopes match what the rest of the app uses; callers
|
||||
# can override via $Arguments.Scopes.
|
||||
if($Arguments.Scopes) { $mgArgs['Scopes'] = $Arguments.Scopes }
|
||||
}
|
||||
|
||||
$authModeLog = if($mgArgs.ContainsKey('ClientSecretCredential')) { 'client secret' }
|
||||
elseif($mgArgs.ContainsKey('Certificate')) { 'certificate' }
|
||||
elseif($mgArgs.ContainsKey('CertificateThumbprint')) { 'certificate (thumbprint)' }
|
||||
elseif($mgArgs.ContainsKey('AccessToken')) { 'BYO token' }
|
||||
elseif($mgArgs.ContainsKey('Identity')) { 'managed identity' }
|
||||
else { 'interactive (browser)' }
|
||||
Write-Log "Calling Connect-MgGraph (mode: $authModeLog)..."
|
||||
|
||||
try {
|
||||
# Wipe any stale cached token from a prior session before the new auth.
|
||||
[AuthenticationMgGraph]::ClearTokenCache()
|
||||
|
||||
Connect-MgGraph @mgArgs -ErrorAction Stop | Out-Null
|
||||
Write-Log "Connect-MgGraph completed successfully"
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Connect-MgGraph failed" $_.Exception
|
||||
return $null
|
||||
}
|
||||
|
||||
# Verify Get-MgContext returns a session. If it does, we're signed in —
|
||||
# even if we can't pull a raw bearer token out of the SDK. Invoke-MSGraphAPI
|
||||
# has an SDK-routed fallback for that case (uses Invoke-MgGraphRequest, which
|
||||
# the SDK auths internally with its own in-memory cache).
|
||||
$ctx = $null
|
||||
try { $ctx = Get-MgContext -ErrorAction SilentlyContinue } catch { }
|
||||
if(-not $ctx) {
|
||||
Write-Log "Connect-MgGraph completed but Get-MgContext returned nothing. Treating as failed auth." 3
|
||||
try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch { }
|
||||
[AuthenticationMgGraph]::ClearTokenCache()
|
||||
return $null
|
||||
}
|
||||
$verifyToken = $this.GetAccessToken(0, "https://$($cloudEntry.GraphHost)")
|
||||
if($verifyToken) {
|
||||
Write-LogDebug "MgGraph session verified - token extracted ($($verifyToken.Length) chars)"
|
||||
}
|
||||
else {
|
||||
# SDK v2 keeps tokens opaque by design. Invoke-MSGraphAPI has a routed
|
||||
# fallback that uses Invoke-MgGraphRequest (the SDK auths internally),
|
||||
# so this is normal — debug-level only.
|
||||
Write-LogDebug "MgGraph: session valid (Get-MgContext: tenant=$($ctx.TenantId)) but raw bearer not extractable. Graph calls route via Invoke-MgGraphRequest."
|
||||
}
|
||||
|
||||
# Realign the active auth provider so subsequent Invoke-MSGraphAPI calls route
|
||||
# here. Symmetric to the same logic in MSAL's Add-MSALTokenInfo.
|
||||
if(Get-Command Set-ActiveAuthProvider -ErrorAction SilentlyContinue) {
|
||||
$cur = Get-AuthProvider
|
||||
if($cur -and $cur.Id -ne $this.Id) {
|
||||
Write-Log "Active auth provider auto-switched from '$($cur.Id)' to '$($this.Id)' because MgGraph authentication succeeded"
|
||||
Set-ActiveAuthProvider -Id $this.Id
|
||||
}
|
||||
}
|
||||
|
||||
# Phase 3: persist per-tenant cloud memory. Prefer the Cloud arg the caller
|
||||
# asked for; fall back to mapping Get-MgContext.Environment back to a Cloud
|
||||
# value (the SDK's -Environment values match ours 1:1 via Clouds[].MgEnvironment).
|
||||
# Declared before the try so it's always in scope for Register-AuthToken below.
|
||||
$persistCloud = if($cloudValue) { $cloudValue } else { $null }
|
||||
try {
|
||||
if(-not $persistCloud -and $ctx -and $ctx.Environment) {
|
||||
$match = $script:Clouds | Where-Object MgEnvironment -eq $ctx.Environment | Select-Object -First 1
|
||||
if($match) { $persistCloud = $match.Value }
|
||||
}
|
||||
if(-not $persistCloud) { $persistCloud = Get-DefaultCloud }
|
||||
if($persistCloud -and $ctx.TenantId) {
|
||||
Save-TenantCloud -TenantId $ctx.TenantId -Cloud $persistCloud
|
||||
Save-SettingStoreValue "" "LastLoggedOnCloud" $persistCloud
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogDebug "Phase 3 MgGraph cloud memory write failed: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# Register with the central token registry (single-session invariant: drop
|
||||
# any prior entry first so repeated Connect never accumulates entries).
|
||||
# The registry fires AuthenticatedNewToken with the canonical [IMAuthToken]
|
||||
# - MgGraph now participates in the auth events for the first time.
|
||||
if($this.CurrentTokenId -gt 0) {
|
||||
Unregister-AuthToken -TokenId $this.CurrentTokenId
|
||||
}
|
||||
$this.CurrentTokenId = Get-NextAuthTokenId
|
||||
return (Register-AuthToken -Provider $this -TokenId $this.CurrentTokenId -Cloud $persistCloud)
|
||||
}
|
||||
|
||||
[bool] Disconnect([int]$TokenId) {
|
||||
try {
|
||||
Disconnect-MgGraph -ErrorAction Stop | Out-Null
|
||||
[AuthenticationMgGraph]::ClearTokenCache()
|
||||
if($this.CurrentTokenId -gt 0) {
|
||||
Unregister-AuthToken -TokenId $this.CurrentTokenId
|
||||
$this.CurrentTokenId = 0
|
||||
}
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Disconnect-MgGraph failed" $_.Exception
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# Re-trigger Connect-MgGraph against the current session's tenant. With a valid
|
||||
# refresh token in the cache the SDK does this silently; otherwise it prompts.
|
||||
[bool] Refresh([int]$TokenId) {
|
||||
try {
|
||||
$ctx = Get-MgContext -ErrorAction SilentlyContinue
|
||||
$mgArgs = @{ NoWelcome = $true }
|
||||
if($ctx -and $ctx.TenantId) { $mgArgs['TenantId'] = $ctx.TenantId }
|
||||
if($ctx -and $ctx.ClientId) { $mgArgs['ClientId'] = $ctx.ClientId }
|
||||
if($ctx -and $ctx.Scopes) { $mgArgs['Scopes'] = @($ctx.Scopes) }
|
||||
if($ctx -and $ctx.Environment -and $ctx.Environment -ne "Global") { $mgArgs['Environment'] = $ctx.Environment }
|
||||
Write-Log "MgGraph Refresh: re-running Connect-MgGraph (tenant: $($ctx.TenantId))"
|
||||
# Invalidate the cached bearer so the next GetAccessToken call re-extracts.
|
||||
[AuthenticationMgGraph]::ClearTokenCache()
|
||||
Connect-MgGraph @mgArgs -ErrorAction Stop | Out-Null
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-LogError "MgGraph Refresh failed" $_.Exception
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
static [void] ClearTokenCache() {
|
||||
[AuthenticationMgGraph]::CachedToken = $null
|
||||
[AuthenticationMgGraph]::CachedTokenExpiry = [DateTimeOffset]::MinValue
|
||||
[AuthenticationMgGraph]::CachedTokenTenantId = $null
|
||||
}
|
||||
|
||||
# Cached token + expiry to avoid hitting the SDK on every request.
|
||||
static [string]$CachedToken
|
||||
static [DateTimeOffset]$CachedTokenExpiry = [DateTimeOffset]::MinValue
|
||||
static [string]$CachedTokenTenantId
|
||||
|
||||
# The single global token id for this provider's one live session. MgGraph is
|
||||
# single-session by SDK design (one ambient Get-MgContext), so it holds exactly
|
||||
# one registry entry at a time. 0 = not registered.
|
||||
[int]$CurrentTokenId = 0
|
||||
|
||||
# Robust token extraction. Microsoft.Graph SDK v2 doesn't expose an access token
|
||||
# accessor — AuthContext.AccessToken stays null in the delegated flow. We use the
|
||||
# SDK's own HttpClient (which has its auth DelegatingHandler attached) to make a
|
||||
# cheap HEAD-style request; the handler mutates the request to add
|
||||
# "Authorization: Bearer <token>" before sending. We then read the header off the
|
||||
# request. Same mechanism the SDK itself uses internally on every Mg* cmdlet —
|
||||
# no reflection, no private APIs.
|
||||
[string] GetAccessToken([int]$TokenId, [string]$Resource) {
|
||||
try {
|
||||
# Return cached token if still valid (>5 min until expiry). The SDK refreshes
|
||||
# internally on every call, so caching at our layer avoids per-request
|
||||
# network round-trips just to "pull" a token.
|
||||
$ctxTenantId = $null
|
||||
try { $ctxTenantId = (Get-MgContext -ErrorAction SilentlyContinue).TenantId } catch { }
|
||||
|
||||
if([AuthenticationMgGraph]::CachedToken -and
|
||||
[AuthenticationMgGraph]::CachedTokenExpiry -gt [DateTimeOffset]::UtcNow.AddMinutes(5) -and
|
||||
[AuthenticationMgGraph]::CachedTokenTenantId -eq $ctxTenantId) {
|
||||
return [AuthenticationMgGraph]::CachedToken
|
||||
}
|
||||
|
||||
$sessionType = "Microsoft.Graph.PowerShell.Authentication.GraphSession" -as [type]
|
||||
if(-not $sessionType) {
|
||||
Write-LogDebug "MgGraph: GraphSession type not available; SDK not loaded?"
|
||||
return $null
|
||||
}
|
||||
$session = $sessionType::Instance
|
||||
if(-not $session) {
|
||||
Write-LogDebug "MgGraph: GraphSession.Instance is null"
|
||||
return $null
|
||||
}
|
||||
|
||||
# The SDK exposes GraphHttpClient: an HttpClient wired with auth handlers.
|
||||
$httpClient = $session.GraphHttpClient
|
||||
if(-not $httpClient) {
|
||||
Write-LogDebug "MgGraph: GraphHttpClient is null (Connect-MgGraph not run?)"
|
||||
return $null
|
||||
}
|
||||
|
||||
# Strategy A — direct AuthContext read (works on some SDK builds, fast-path).
|
||||
if($session.AuthContext -and $session.AuthContext.AccessToken) {
|
||||
$tok = [AuthenticationMgGraph]::UnprotectString($session.AuthContext.AccessToken)
|
||||
if($tok) {
|
||||
[AuthenticationMgGraph]::SaveTokenCache($tok, $ctxTenantId)
|
||||
Write-LogDebug "MgGraph: token from AuthContext.AccessToken (fast-path)"
|
||||
return $tok
|
||||
}
|
||||
}
|
||||
|
||||
# Strategy B — HttpClient sniff: make a trivial GET via the SDK's HttpClient,
|
||||
# then read the Authorization header that the DelegatingHandler attached.
|
||||
# This is the supported public contract of the SDK's auth pipeline.
|
||||
$graphResource = if($Resource) { $Resource.TrimEnd('/') } else { "https://$(Get-GraphDomain)" }
|
||||
$req = [System.Net.Http.HttpRequestMessage]::new(
|
||||
[System.Net.Http.HttpMethod]::Get,
|
||||
"$graphResource/v1.0/`$metadata")
|
||||
try {
|
||||
$task = $httpClient.SendAsync(
|
||||
$req,
|
||||
[System.Net.Http.HttpCompletionOption]::ResponseHeadersRead)
|
||||
# 30s timeout — interactive auth could be required if cache is cold.
|
||||
if(-not $task.Wait(30000)) {
|
||||
Write-LogDebug "MgGraph: HttpClient sniff timed out"
|
||||
return $null
|
||||
}
|
||||
# Drop the response (we only care about the request headers the handler
|
||||
# populated). Dispose to free the socket.
|
||||
try { $task.Result.Dispose() } catch { }
|
||||
}
|
||||
catch {
|
||||
Write-LogDebug "MgGraph: HttpClient sniff failed: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
if($req.Headers.Authorization -and
|
||||
$req.Headers.Authorization.Scheme -eq 'Bearer' -and
|
||||
$req.Headers.Authorization.Parameter) {
|
||||
$token = $req.Headers.Authorization.Parameter
|
||||
[AuthenticationMgGraph]::SaveTokenCache($token, $ctxTenantId)
|
||||
Write-LogDebug "MgGraph: token via HttpClient auth-handler sniff"
|
||||
return $token
|
||||
}
|
||||
|
||||
# Last resort — reflection probe.
|
||||
$token = [AuthenticationMgGraph]::FindTokenViaReflection($session)
|
||||
if($token) {
|
||||
[AuthenticationMgGraph]::SaveTokenCache($token, $ctxTenantId)
|
||||
Write-LogDebug "MgGraph: token via reflection"
|
||||
return $token
|
||||
}
|
||||
|
||||
$ctxJson = "<null>"
|
||||
if($session.AuthContext) {
|
||||
try {
|
||||
$ctxJson = ($session.AuthContext | Select-Object TenantId, ClientId, AppName, AuthType, @{n='AccessTokenPresent';e={[bool]$_.AccessToken}}, @{n='Scopes';e={($_.Scopes -join ', ')}} | ConvertTo-Json -Compress)
|
||||
}
|
||||
catch { $ctxJson = "<unserializable>" }
|
||||
}
|
||||
Write-Log "MgGraph: could not extract access token. AuthContext=$ctxJson" 2
|
||||
return $null
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to extract MgGraph access token" $_.Exception
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
# Persist the freshly-acquired token + expiry. Expiry parsed from the JWT exp claim;
|
||||
# fall back to "now + 50 minutes" if parsing fails (Entra tokens default to 60 min).
|
||||
static [void] SaveTokenCache([string]$Token, [string]$TenantId) {
|
||||
[AuthenticationMgGraph]::CachedToken = $Token
|
||||
[AuthenticationMgGraph]::CachedTokenTenantId = $TenantId
|
||||
$expiry = [DateTimeOffset]::UtcNow.AddMinutes(50)
|
||||
try {
|
||||
# Decode JWT exp claim
|
||||
$jwt = Get-JWTtoken $Token
|
||||
if($jwt -and $jwt.Payload -and $jwt.Payload.exp) {
|
||||
$expiry = [DateTimeOffset]::FromUnixTimeSeconds([long]$jwt.Payload.exp)
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
[AuthenticationMgGraph]::CachedTokenExpiry = $expiry
|
||||
}
|
||||
|
||||
# Unprotect a SecureString or pass a plain string through.
|
||||
static [string] UnprotectString($Value) {
|
||||
if($null -eq $Value) { return $null }
|
||||
if($Value -is [SecureString]) {
|
||||
return [System.Net.NetworkCredential]::new("", $Value).Password
|
||||
}
|
||||
return [string]$Value
|
||||
}
|
||||
|
||||
# Walks the session object graph looking for a property/field whose name suggests it
|
||||
# holds an access token. Limited to 2 levels deep to avoid infinite recursion.
|
||||
static [string] FindTokenViaReflection($obj) {
|
||||
if($null -eq $obj) { return $null }
|
||||
try {
|
||||
foreach($prop in $obj.PSObject.Properties) {
|
||||
if($prop.Name -match 'AccessToken|BearerToken|JWT|^Token$') {
|
||||
$val = $prop.Value
|
||||
if($val) {
|
||||
$unwrapped = [AuthenticationMgGraph]::UnprotectString($val)
|
||||
if($unwrapped -and $unwrapped.Length -gt 20) { return $unwrapped }
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach($prop in $obj.PSObject.Properties) {
|
||||
if($prop.Name -in 'AuthContext','InMemoryTokenCache','GraphOption','RequestContext') {
|
||||
$child = $prop.Value
|
||||
if($child) {
|
||||
foreach($childProp in $child.PSObject.Properties) {
|
||||
if($childProp.Name -match 'AccessToken|BearerToken|JWT|^Token$') {
|
||||
$val = $childProp.Value
|
||||
if($val) {
|
||||
$unwrapped = [AuthenticationMgGraph]::UnprotectString($val)
|
||||
if($unwrapped -and $unwrapped.Length -gt 20) { return $unwrapped }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return $null
|
||||
}
|
||||
|
||||
[datetime] GetAccessTokenExpiry([int]$TokenId, [string]$Resource) {
|
||||
# SDK does not expose the expiry to consumers. We return MaxValue and rely on
|
||||
# MgGraph's internal silent refresh (it owns the cache).
|
||||
return [datetime]::MaxValue
|
||||
}
|
||||
|
||||
# Cached tenant display name to avoid hitting /organization on every refresh.
|
||||
static [hashtable]$TenantNameCache = @{}
|
||||
|
||||
[PSCustomObject] GetUserInfo([int]$TokenId) {
|
||||
try {
|
||||
$ctx = Get-MgContext -ErrorAction SilentlyContinue
|
||||
if(-not $ctx) { return $null }
|
||||
|
||||
# SDK reports AuthType as Delegated / AppOnly. Map to our taxonomy.
|
||||
$authType = switch ($ctx.AuthType) {
|
||||
"AppOnly" { "ClientCredential" }
|
||||
"Delegated" { "Interactive" }
|
||||
default { "$($ctx.AuthType)" }
|
||||
}
|
||||
|
||||
# Get-MgContext doesn't surface tenant display name. Fetch it once per
|
||||
# tenant via Invoke-MgGraphRequest /organization (the SDK handles auth);
|
||||
# cache for the rest of the session.
|
||||
$tenantName = $null
|
||||
if($ctx.TenantId) {
|
||||
if([AuthenticationMgGraph]::TenantNameCache.ContainsKey($ctx.TenantId)) {
|
||||
$tenantName = [AuthenticationMgGraph]::TenantNameCache[$ctx.TenantId]
|
||||
}
|
||||
else {
|
||||
try {
|
||||
$org = Invoke-MgGraphRequest -Method GET -Uri "https://$(Get-GraphDomain)/v1.0/organization" -OutputType PSObject -ErrorAction Stop
|
||||
if($org -and $org.value -and $org.value.Count -gt 0 -and $org.value[0].displayName) {
|
||||
$tenantName = $org.value[0].displayName
|
||||
[AuthenticationMgGraph]::TenantNameCache[$ctx.TenantId] = $tenantName
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogDebug "MgGraph GetUserInfo: /organization fetch failed ($($_.Exception.Message)); tenant display name will be unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Expiry comes from the JWT exp claim we parsed into CachedTokenExpiry.
|
||||
# If no token has been minted yet this session, trigger one — cheap when
|
||||
# the SDK's in-memory cache is warm.
|
||||
$expiresOn = $null
|
||||
if([AuthenticationMgGraph]::CachedTokenExpiry -gt [DateTimeOffset]::MinValue) {
|
||||
$expiresOn = [AuthenticationMgGraph]::CachedTokenExpiry.LocalDateTime
|
||||
}
|
||||
else {
|
||||
try {
|
||||
[void]$this.GetAccessToken(0, "https://$(Get-GraphDomain)")
|
||||
if([AuthenticationMgGraph]::CachedTokenExpiry -gt [DateTimeOffset]::MinValue) {
|
||||
$expiresOn = [AuthenticationMgGraph]::CachedTokenExpiry.LocalDateTime
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return [PSCustomObject]@{
|
||||
Provider = $this.Id
|
||||
DisplayName = $ctx.Account
|
||||
UPN = $ctx.Account
|
||||
UserId = $null # Not surfaced by Get-MgContext
|
||||
TenantId = $ctx.TenantId
|
||||
TenantName = $tenantName
|
||||
AppId = $ctx.ClientId
|
||||
AppName = $ctx.AppName
|
||||
AuthType = $authType
|
||||
ExpiresOn = $expiresOn
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
[PSCustomObject[]] GetCachedAccounts() {
|
||||
# Delegate to a module function — PS class method bodies are parsed strictly
|
||||
# (type references like [Microsoft.Identity.Client.PublicClientApplicationBuilder]
|
||||
# have to resolve at PARSE time, before MSAL DLLs are loaded). Module
|
||||
# functions are late-bound and tolerate this.
|
||||
$rows = @()
|
||||
try {
|
||||
$rows = @(Get-MgGraphCachedMsalAccounts -ProviderId $this.Id)
|
||||
}
|
||||
catch {
|
||||
Write-LogDebug "MgGraph GetCachedAccounts failed: $($_.Exception.Message)"
|
||||
}
|
||||
return [PSCustomObject[]]$rows
|
||||
}
|
||||
|
||||
[PSCustomObject[]] GetAvailableTenants([int]$TokenId) {
|
||||
# SDK does not surface available tenants. Tenant switching means
|
||||
# Disconnect + Connect with -TenantId, which the UI can offer via a manual
|
||||
# tenant id entry (Phase 3 UI concern).
|
||||
return [PSCustomObject[]]@()
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
#ImportOrder 27
|
||||
|
||||
# Offline "mock tenant" provider.
|
||||
#
|
||||
# Signs in with no network and answers every Graph request from the JSON files
|
||||
# under IM_MOCK_DATA (see Internal/MockGraph.ps1). Registered only when that
|
||||
# variable points at a folder - a normal launch never sees it - and used for
|
||||
# screenshots, demos and UI work without a tenant.
|
||||
#
|
||||
# The access token is a real-looking but unsigned JWT: the access-marking code
|
||||
# reads scp / wids from it exactly as it would from Entra, so the menu renders
|
||||
# with full access and no role lookup. Every request then goes through
|
||||
# InvokeWebRequest (RoutesAllRequests) instead of HTTPS.
|
||||
|
||||
# What a mock 4xx throws. Invoke-MSGraphAPI reads the failure from
|
||||
# $_.Exception.Response - StatusCode, Headers, and the body through
|
||||
# GetResponseStream() - the same way it reads a WebException from
|
||||
# Invoke-WebRequest, so a mock 404 reports its status, code and message like a
|
||||
# real one. WebException.Response cannot be set from PowerShell, hence a class.
|
||||
class MockGraphResponseException : System.Exception {
|
||||
[object]$Response
|
||||
|
||||
MockGraphResponseException([string]$Message, [object]$Response) : base($Message) {
|
||||
$this.Response = $Response
|
||||
}
|
||||
}
|
||||
|
||||
class AuthenticationMock : AuthenticationProvider {
|
||||
|
||||
static [hashtable]$Tokens = @{}
|
||||
|
||||
[string]$DataFolder
|
||||
|
||||
AuthenticationMock() {
|
||||
$this.Id = "Mock"
|
||||
$this.DisplayName = "Mock tenant (offline demo data)"
|
||||
|
||||
$this.SupportsInteractive = $true
|
||||
$this.SupportsClientSecret = $false
|
||||
$this.SupportsCertificate = $false
|
||||
$this.SupportsIdentityProvider = $false
|
||||
$this.SupportsBYOToken = $false
|
||||
$this.SupportsClaimsChallenge = $false
|
||||
$this.SupportsMultiTenant = $false
|
||||
$this.SupportsRefresh = $true
|
||||
$this.SupportsForget = $false
|
||||
$this.SupportsCachedUsers = $false
|
||||
$this.RoutesAllRequests = $true
|
||||
}
|
||||
|
||||
[void] Initialize() {
|
||||
$this.DataFolder = [string]$env:IM_MOCK_DATA
|
||||
}
|
||||
|
||||
# Sign in at startup - there is nothing to prompt for.
|
||||
[bool] TryResumeSession() {
|
||||
if(-not $this.DataFolder) { return $false }
|
||||
$token = $this.Connect(@{})
|
||||
return [bool]$token
|
||||
}
|
||||
|
||||
[PSCustomObject] Connect([hashtable]$Arguments) {
|
||||
if(-not $this.DataFolder -or -not (Test-Path -LiteralPath $this.DataFolder -PathType Container)) {
|
||||
Write-Log "Mock provider: IM_MOCK_DATA does not point at a folder ('$($this.DataFolder)')" 3
|
||||
return $null
|
||||
}
|
||||
|
||||
$tenant = Get-MockTenantProfile -Root $this.DataFolder
|
||||
Initialize-MockGraphStore -Root $this.DataFolder | Out-Null
|
||||
|
||||
$tokenId = Get-NextAuthTokenId
|
||||
[AuthenticationMock]::Tokens[$tokenId] = @{
|
||||
Id = $tokenId
|
||||
Tenant = $tenant
|
||||
AccessToken = (New-MockAccessToken -Tenant $tenant)
|
||||
AcquiredAt = [DateTime]::UtcNow
|
||||
}
|
||||
Write-Log "Mock provider: signed in to '$($tenant.TenantName)' as $($tenant.UPN) (TokenId=$tokenId)"
|
||||
|
||||
try {
|
||||
$cur = Get-AuthProvider
|
||||
if($cur -and $cur.Id -ne $this.Id) { Set-ActiveAuthProvider -Id $this.Id }
|
||||
} catch { }
|
||||
|
||||
return (Register-AuthToken -Provider $this -TokenId $tokenId -Cloud (Get-DefaultCloud))
|
||||
}
|
||||
|
||||
[bool] Disconnect([int]$TokenId) {
|
||||
if(-not [AuthenticationMock]::Tokens.ContainsKey($TokenId)) { return $false }
|
||||
Unregister-AuthToken -TokenId $TokenId
|
||||
[AuthenticationMock]::Tokens.Remove($TokenId) | Out-Null
|
||||
return $true
|
||||
}
|
||||
|
||||
[bool] Refresh([int]$TokenId) {
|
||||
return [AuthenticationMock]::Tokens.ContainsKey($TokenId)
|
||||
}
|
||||
|
||||
[string] GetAccessToken([int]$TokenId, [string]$Resource) {
|
||||
$entry = $this.GetEntry($TokenId)
|
||||
if(-not $entry) { return $null }
|
||||
return [string]$entry.AccessToken
|
||||
}
|
||||
|
||||
[datetime] GetAccessTokenExpiry([int]$TokenId, [string]$Resource) {
|
||||
return [datetime]::MaxValue
|
||||
}
|
||||
|
||||
[object] InvokeWebRequest([string]$Url, [string]$Method, [object]$Body, [hashtable]$Headers) {
|
||||
return (Invoke-MockGraphRequest -Url $Url -Method $Method -Body $Body)
|
||||
}
|
||||
|
||||
[PSCustomObject] GetUserInfo([int]$TokenId) {
|
||||
$entry = $this.GetEntry($TokenId)
|
||||
if(-not $entry) { return $null }
|
||||
$tenant = $entry.Tenant
|
||||
return [PSCustomObject]@{
|
||||
Provider = $this.Id
|
||||
DisplayName = $tenant.DisplayName
|
||||
UPN = $tenant.UPN
|
||||
UserId = $tenant.UserId
|
||||
TenantId = $tenant.TenantId
|
||||
TenantName = $tenant.TenantName
|
||||
AppId = $tenant.AppId
|
||||
AppName = $tenant.AppName
|
||||
AuthType = "Interactive"
|
||||
ExpiresOn = [DateTime]::Now.AddYears(1)
|
||||
}
|
||||
}
|
||||
|
||||
[PSCustomObject[]] GetSessionInfoRows() {
|
||||
$rows = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
$rows.Add([PSCustomObject]@{ Name = "Provider"; Value = "Mock (offline)" })
|
||||
$rows.Add([PSCustomObject]@{ Name = "Data folder"; Value = $this.DataFolder })
|
||||
return $rows.ToArray()
|
||||
}
|
||||
|
||||
hidden [hashtable] GetEntry([int]$TokenId) {
|
||||
if($TokenId -le 0 -and [AuthenticationMock]::Tokens.Count -gt 0) {
|
||||
$TokenId = ([AuthenticationMock]::Tokens.Keys | Sort-Object -Descending | Select-Object -First 1)
|
||||
}
|
||||
if(-not [AuthenticationMock]::Tokens.ContainsKey($TokenId)) { return $null }
|
||||
return [AuthenticationMock]::Tokens[$TokenId]
|
||||
}
|
||||
}
|
||||
@@ -1,949 +0,0 @@
|
||||
#ImportOrder 27
|
||||
|
||||
# Pure-PowerShell implementation of AuthenticationProvider.
|
||||
#
|
||||
# No MSAL.NET DLL, no Microsoft.Graph.Authentication SDK. The class talks to
|
||||
# https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token directly via
|
||||
# Invoke-RestMethod. Designed for automation: scheduled tasks, CI runners,
|
||||
# AKS workload identity, Azure VMs, App Service / Functions.
|
||||
#
|
||||
# Supported flows (dispatched by Connect() based on which arg is set, in
|
||||
# this priority order):
|
||||
#
|
||||
# Token BYO bearer (no flow)
|
||||
# DeviceCode device code grant (RFC 8628) — v2
|
||||
# ManagedIdentity (no Federated*) IMDS / App Service / Functions
|
||||
# FederatedTokenFile / FederatedToken Workload Identity Federation
|
||||
# Certificate / CertificatePath client_credentials w/ private_key_jwt
|
||||
# Secret client_credentials w/ shared secret
|
||||
# Credential (PSCredential) ROPC (grant_type=password)
|
||||
#
|
||||
# All endpoint heavy lifting (HTTP, JWT signing, IMDS detection, error
|
||||
# surfacing) lives in Internal/AuthenticationOAuthHelpers.ps1 — module
|
||||
# functions are late-bound and tolerate types that don't resolve at parse
|
||||
# time, which keeps this class file portable.
|
||||
#
|
||||
# Per-tenant cloud memory is stamped via Save-TenantCloud at the end of every
|
||||
# successful Connect, matching what AuthenticationMSAL and AuthenticationMgGraph
|
||||
# do. Same for AppEvents (AuthenticatedNewToken / AuthenticationTokenRefresh /
|
||||
# AuthenticationUserDisconnected / AuthenticationFailed).
|
||||
|
||||
class AuthenticationOAuth : AuthenticationProvider {
|
||||
|
||||
# Token cache. Static so every reference to the provider sees the same
|
||||
# store within a session. Keys are integer TokenIds (consistent with the
|
||||
# other providers).
|
||||
static [hashtable]$Tokens = @{}
|
||||
static [int]$NextTokenId = 1
|
||||
|
||||
# Cached tenant display name per TenantId so GetUserInfo doesn't hit
|
||||
# /organization on every refresh event. Failed lookups (e.g. app-only token
|
||||
# without Organization.Read.All) cache $null so they aren't retried either.
|
||||
static [hashtable]$TenantNameCache = @{}
|
||||
|
||||
AuthenticationOAuth() {
|
||||
$this.Id = "OAuth"
|
||||
$this.DisplayName = "Direct OAuth (no SDK)"
|
||||
|
||||
# Required contract.
|
||||
$this.SupportsInteractive = $true # device code flow (RFC 8628)
|
||||
$this.SupportsClientSecret = $true
|
||||
$this.SupportsCertificate = $true
|
||||
|
||||
# Recommended.
|
||||
$this.SupportsIdentityProvider = $true # IMDS + workload federation
|
||||
$this.SupportsBYOToken = $true
|
||||
$this.SupportsClaimsChallenge = $true # re-mints via the /token body (see GetClaimsToken)
|
||||
|
||||
$this.SupportsMultiTenant = $true
|
||||
$this.SupportsRefresh = $true
|
||||
$this.SupportsForget = $true
|
||||
$this.SupportsCachedUsers = $false # No persistent on-disk cache
|
||||
}
|
||||
|
||||
[void] Initialize() {
|
||||
# Register the OAuth settings section (browser-login config). Guard on the
|
||||
# settings API being loaded (module-load order); Initialize() runs from
|
||||
# Register-AuthProvider, before the Settings dialog renders.
|
||||
if(-not (Get-Command -Name Add-SettingsSection -ErrorAction SilentlyContinue)) { return }
|
||||
if(-not (Get-Command -Name Add-SettingsObject -ErrorAction SilentlyContinue)) { return }
|
||||
try {
|
||||
# Order 9 = directly under the MSAL section (8). App (client) id and tenant id
|
||||
# are NOT registered here - MSAL and OAuth are two ways of authenticating with
|
||||
# the SAME app, so both resolve it from the common Entra settings in the
|
||||
# Authentication section (Get-EntraApp: dropdown -> custom app id -> default
|
||||
# Microsoft Graph PowerShell public client).
|
||||
Add-SettingsSection -Title "OAuth" -Id "OAuth" -Order 9
|
||||
|
||||
Add-SettingsObject -Title "OAuth browser prompt" -Key "OAuthPrompt" -Type "List" -DefaultValue "select_account" `
|
||||
-ItemsSource @(
|
||||
[PSCustomObject]@{ Name = "Select account"; Value = "select_account" },
|
||||
[PSCustomObject]@{ Name = "Force login"; Value = "login" },
|
||||
[PSCustomObject]@{ Name = "Consent"; Value = "consent" },
|
||||
[PSCustomObject]@{ Name = "None (silent)"; Value = "none" }
|
||||
) `
|
||||
-Description "OAuth /authorize prompt behaviour. 'Force login' re-authenticates even with an active browser session (equivalent to force-interactive); 'None' fails if interaction would be required." `
|
||||
-Section "OAuth"
|
||||
|
||||
Add-SettingsObject -Title "OAuth login hint (UPN)" -Key "OAuthLoginHint" -Type "String" -DefaultValue "" `
|
||||
-Description "Optional UPN to pre-fill on the sign-in page (login_hint)." `
|
||||
-Section "OAuth"
|
||||
|
||||
Add-SettingsObject -Title "OAuth redirect port" -Key "OAuthRedirectPort" -Type "Int" -DefaultValue 0 `
|
||||
-Description "Fixed loopback port for the browser redirect (http://localhost:<port>). 0 = pick a free port automatically. Set a fixed port only if your app registration requires a specific http://localhost:<port> redirect." `
|
||||
-Section "OAuth"
|
||||
|
||||
Add-SettingsObject -Title "Remember login (cache token)" -Key "OAuthCacheToken" -Type "Boolean" -DefaultValue $false `
|
||||
-Description "Persist the OAuth refresh token (DPAPI-encrypted, current user) so the app silently resumes the browser session after a restart. When off, you sign in again after each restart (usually a quick browser redirect via existing SSO)." `
|
||||
-Section "OAuth"
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to register OAuth settings section" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
# Silent cross-restart resume for the browser flow. When "Remember login" is on and
|
||||
# a DPAPI-cached refresh token exists, mint a fresh token via the refresh_token grant
|
||||
# (no browser) and register it as the default. Returns $true on success. Base is a
|
||||
# no-op; MSAL/other providers have their own resume paths.
|
||||
[bool] TryResumeSession() {
|
||||
try {
|
||||
if((Get-SettingValue "OAuthCacheToken") -ne $true) { return $false }
|
||||
$cache = Read-OAuthTokenCache
|
||||
if(-not $cache -or -not $cache.RefreshToken) { return $false }
|
||||
|
||||
$cloudValue = if($cache.Cloud) { [string]$cache.Cloud } else { Get-DefaultCloud }
|
||||
$authority = if($cache.Authority) { [string]$cache.Authority } else { (Get-CloudByValue $cloudValue).AADAuthority }
|
||||
$resource = if($cache.Resource) { [string]$cache.Resource } else { "https://$((Get-CloudByValue $cloudValue).GraphHost)" }
|
||||
$tenant = if($cache.TenantId) { [string]$cache.TenantId } else { 'organizations' }
|
||||
$scope = "$resource/.default offline_access"
|
||||
|
||||
$tokenResp = Invoke-OAuthTokenRequest -Authority $authority -TenantId $tenant -Body @{
|
||||
grant_type = 'refresh_token'
|
||||
client_id = $cache.ClientId
|
||||
refresh_token = $cache.RefreshToken
|
||||
scope = $scope
|
||||
}
|
||||
if(-not $tokenResp -or -not $tokenResp.access_token) { return $false }
|
||||
|
||||
$expiresAt = [DateTime]::UtcNow.AddMinutes(50)
|
||||
if($tokenResp.expires_in) { $expiresAt = [DateTime]::UtcNow.AddSeconds([int]$tokenResp.expires_in) }
|
||||
|
||||
# Recover the real tenant from the token when we resumed under 'organizations'.
|
||||
$tenantId = [string]$cache.TenantId
|
||||
try {
|
||||
$jwt = Get-JWTtoken $tokenResp.access_token
|
||||
if($jwt -and $jwt.Payload -and $jwt.Payload.tid) { $tenantId = [string]$jwt.Payload.tid }
|
||||
} catch { }
|
||||
|
||||
$cred = [ordered]@{
|
||||
AuthMethod = 'AuthCode'
|
||||
Cloud = $cloudValue
|
||||
Authority = $authority
|
||||
Resource = $resource
|
||||
TenantId = $tenantId
|
||||
ClientId = $cache.ClientId
|
||||
}
|
||||
$tokenId = Get-NextAuthTokenId
|
||||
$entry = [ordered]@{
|
||||
Id = $tokenId
|
||||
AccessToken = [string]$tokenResp.access_token
|
||||
RefreshToken = if($tokenResp.refresh_token) { [string]$tokenResp.refresh_token } else { [string]$cache.RefreshToken }
|
||||
ExpiresAt = $expiresAt
|
||||
TenantId = $tenantId
|
||||
ClientId = $cache.ClientId
|
||||
AuthMethod = 'AuthCode'
|
||||
Cloud = $cloudValue
|
||||
Resource = $resource
|
||||
CredentialState = $cred
|
||||
AcquiredAt = [DateTime]::UtcNow
|
||||
}
|
||||
[AuthenticationOAuth]::Tokens[$tokenId] = $entry
|
||||
|
||||
# A rotated refresh token must overwrite the cached one.
|
||||
if($tokenResp.refresh_token) {
|
||||
[void](Save-OAuthTokenCache -Data @{
|
||||
RefreshToken = [string]$tokenResp.refresh_token
|
||||
ClientId = $cache.ClientId
|
||||
TenantId = $tenantId
|
||||
Cloud = $cloudValue
|
||||
Authority = $authority
|
||||
Resource = $resource
|
||||
AuthMethod = 'AuthCode'
|
||||
})
|
||||
}
|
||||
|
||||
[void](Register-AuthToken -Provider $this -TokenId $tokenId -Cloud $cloudValue -Default)
|
||||
Write-Log "OAuth provider: resumed cached browser session (TokenId=$tokenId, tenant=$tenantId)"
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-LogError "OAuth provider: TryResumeSession failed" $_.Exception
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
Hidden [string] ResolvePublicClientId([string]$AppId) {
|
||||
if(-not [String]::IsNullOrWhiteSpace($AppId)) { return $AppId }
|
||||
|
||||
# Match MSAL's app selection: Settings -> Entra app dropdown, then custom
|
||||
# app id, then the default Microsoft Graph PowerShell public client.
|
||||
if(Get-Command Get-EntraApp -ErrorAction SilentlyContinue) {
|
||||
try {
|
||||
$entraApp = Get-EntraApp
|
||||
if($entraApp -and -not [String]::IsNullOrWhiteSpace([string]$entraApp.ClientId)) {
|
||||
return [string]$entraApp.ClientId
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
return "14d82eec-204b-4c2f-b7e8-296a70dab67e"
|
||||
}
|
||||
|
||||
Hidden [string] ResolveTenantId([string]$TenantId) {
|
||||
if(-not [String]::IsNullOrWhiteSpace($TenantId)) { return $TenantId }
|
||||
|
||||
# Shared identity config: the common Entra settings supply the tenant the same
|
||||
# way they supply the app id. Get-EntraApp surfaces EntraCustomTenantId on
|
||||
# custom-app rows; the built-in app rows have no TenantId property, which
|
||||
# safely yields $null here.
|
||||
if(Get-Command Get-EntraApp -ErrorAction SilentlyContinue) {
|
||||
try {
|
||||
$entraApp = Get-EntraApp
|
||||
if($entraApp -and -not [String]::IsNullOrWhiteSpace([string]$entraApp.TenantId)) {
|
||||
return [string]$entraApp.TenantId
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
# 'organizations' = any work/school account; the real tenant id is recovered
|
||||
# from the token's tid claim after sign-in.
|
||||
return 'organizations'
|
||||
}
|
||||
|
||||
# === Auth lifecycle ===
|
||||
|
||||
[PSCustomObject] Connect([hashtable]$Arguments) {
|
||||
Write-Log "AuthenticationOAuth.Connect starting"
|
||||
|
||||
$cloudValue = if($Arguments.Cloud) { [string]$Arguments.Cloud } else { Get-DefaultCloud }
|
||||
$cloudEntry = Get-CloudByValue $cloudValue
|
||||
$authority = $cloudEntry.AADAuthority
|
||||
$graphHost = $cloudEntry.GraphHost
|
||||
$defaultScope = "https://$graphHost/.default"
|
||||
$resource = "https://$graphHost"
|
||||
|
||||
$tenantId = [string]$Arguments.TenantId
|
||||
$clientId = [string]$Arguments.AppId
|
||||
|
||||
# Determine flow + run it. State stored in $cred is what Refresh() and
|
||||
# GetAccessToken() use to re-acquire when the access token expires.
|
||||
$cred = $null
|
||||
$tokenResp = $null
|
||||
$authMethod = $null
|
||||
|
||||
try {
|
||||
if($Arguments.ContainsKey('Token') -and $Arguments.Token) {
|
||||
$authMethod = 'BYO'
|
||||
$cred = [ordered]@{
|
||||
AuthMethod = $authMethod
|
||||
Cloud = $cloudValue
|
||||
Authority = $authority
|
||||
Resource = $resource
|
||||
TenantId = $tenantId
|
||||
ClientId = $clientId
|
||||
}
|
||||
$tokenResp = [PSCustomObject]@{
|
||||
access_token = [string]$Arguments.Token
|
||||
expires_in = $null # unknown; parsed from JWT exp below
|
||||
token_type = 'Bearer'
|
||||
}
|
||||
}
|
||||
elseif( ($Arguments.ContainsKey('Browser') -and $Arguments.Browser) -or
|
||||
($Arguments.ContainsKey('Interactive') -and $Arguments.Interactive -and
|
||||
-not ($Arguments.ContainsKey('DeviceCode') -and $Arguments.DeviceCode) -and
|
||||
((Get-CacheObject "ShowUI") -eq $true)) ) {
|
||||
# Browser (Authorization Code + PKCE, loopback redirect). Chosen for an
|
||||
# explicit -Browser, or for -Interactive when a GUI is present (ShowUI);
|
||||
# headless -Interactive keeps going to device code below.
|
||||
$authMethod = 'AuthCode'
|
||||
# Client id / tenant: explicit args win, else the common Entra settings
|
||||
# (same app selection as MSAL - dropdown, custom app id, then the
|
||||
# well-known Microsoft Graph PowerShell public client, which already
|
||||
# has http://localhost registered).
|
||||
$clientId = $this.ResolvePublicClientId($clientId)
|
||||
$acTenant = $this.ResolveTenantId($tenantId)
|
||||
$prompt = if($Arguments.Prompt) { [string]$Arguments.Prompt } else { [string](Get-SettingValue "OAuthPrompt") }
|
||||
$loginHint = if($Arguments.LoginHint) { [string]$Arguments.LoginHint } else { [string](Get-SettingValue "OAuthLoginHint") }
|
||||
$redirectPort = if($Arguments.RedirectPort) { [int]$Arguments.RedirectPort } else { [int](Get-SettingValue "OAuthRedirectPort") }
|
||||
$timeoutSec = 600
|
||||
try { $t = [int](Get-SettingValue "MSGraphInteractiveTimeoutSec"); if($t -gt 0) { $timeoutSec = $t } } catch { }
|
||||
|
||||
$cred = [ordered]@{
|
||||
AuthMethod = $authMethod
|
||||
Cloud = $cloudValue
|
||||
Authority = $authority
|
||||
Resource = $resource
|
||||
TenantId = $tenantId
|
||||
ClientId = $clientId
|
||||
}
|
||||
try {
|
||||
$tokenResp = Invoke-OAuthAuthCodeFlow -Authority $authority -TenantId $acTenant -ClientId $clientId `
|
||||
-Scope "$defaultScope offline_access" -RedirectPort $redirectPort -Prompt $prompt -LoginHint $loginHint -TimeoutSec $timeoutSec
|
||||
}
|
||||
catch [System.OperationCanceledException] {
|
||||
# The user clicked Cancel on the sign-in overlay. That is a decision,
|
||||
# not a broken browser, so do not start a second (device code) login
|
||||
# behind their back - let it out and leave the session signed out.
|
||||
Write-Log "OAuth provider: browser sign-in cancelled by the user" 2
|
||||
throw
|
||||
}
|
||||
catch {
|
||||
# Browser unavailable (headless -Browser, no default browser, or the
|
||||
# loopback port is blocked). Fall back to device code so sign-in can
|
||||
# still complete. Refresh works identically for both (refresh_token).
|
||||
Write-Log "OAuth provider: browser sign-in failed ($($_.Exception.Message)); falling back to device code" 2
|
||||
$tokenResp = Invoke-OAuthDeviceCodeFlow -Authority $authority -TenantId $acTenant -ClientId $clientId -Scope "$defaultScope offline_access"
|
||||
}
|
||||
}
|
||||
elseif(($Arguments.ContainsKey('DeviceCode') -and $Arguments.DeviceCode) -or
|
||||
($Arguments.ContainsKey('Interactive') -and $Arguments.Interactive)) {
|
||||
if($Arguments.Interactive -and -not $Arguments.DeviceCode) {
|
||||
# -Interactive reached here means no GUI was available for the
|
||||
# browser flow above (headless), so use RFC 8628 device code -
|
||||
# keeping the Connect-IntuneManagement -Interactive story working
|
||||
# on every provider.
|
||||
Write-Log "OAuth provider: -Interactive routed to device code flow (no GUI for browser login)"
|
||||
}
|
||||
$authMethod = 'DeviceCode'
|
||||
$clientId = $this.ResolvePublicClientId($clientId)
|
||||
if(-not $clientId) { throw "AppId is required for device code auth" }
|
||||
# Tenant from the common Entra settings when not explicit (same
|
||||
# resolution as the browser flow above).
|
||||
$dcTenant = $this.ResolveTenantId($tenantId)
|
||||
$cred = [ordered]@{
|
||||
AuthMethod = $authMethod
|
||||
Cloud = $cloudValue
|
||||
Authority = $authority
|
||||
Resource = $resource
|
||||
TenantId = $tenantId
|
||||
ClientId = $clientId
|
||||
}
|
||||
# offline_access so the response includes a refresh_token —
|
||||
# that's what AcquireFromState uses for silent renewal.
|
||||
$tokenResp = Invoke-OAuthDeviceCodeFlow -Authority $authority -TenantId $dcTenant -ClientId $clientId -Scope "$defaultScope offline_access"
|
||||
}
|
||||
elseif($Arguments.ContainsKey('ManagedIdentity') -and $Arguments.ManagedIdentity -and
|
||||
-not ($Arguments.FederatedTokenFile -or $Arguments.FederatedToken)) {
|
||||
$authMethod = 'IMDS'
|
||||
if(-not $clientId -and $Arguments.ManagedIdentityClientId) {
|
||||
$clientId = [string]$Arguments.ManagedIdentityClientId
|
||||
}
|
||||
$cred = [ordered]@{
|
||||
AuthMethod = $authMethod
|
||||
Cloud = $cloudValue
|
||||
Authority = $authority
|
||||
Resource = $resource
|
||||
TenantId = $tenantId
|
||||
ClientId = $clientId
|
||||
}
|
||||
$tokenResp = Invoke-OAuthIMDS -Resource $resource -ClientId $clientId
|
||||
# IMDS responds with token_type / access_token / expires_in (string seconds);
|
||||
# tenant_id is also included. Use the IMDS-reported tenant if our caller
|
||||
# didn't supply one.
|
||||
if(-not $tenantId -and $tokenResp.tenant_id) {
|
||||
$tenantId = $tokenResp.tenant_id
|
||||
$cred.TenantId = $tenantId
|
||||
}
|
||||
}
|
||||
elseif($Arguments.FederatedTokenFile -or $Arguments.FederatedToken) {
|
||||
$authMethod = 'Federated'
|
||||
if(-not $tenantId) { throw "TenantId is required for federated credential auth" }
|
||||
if(-not $clientId) { throw "AppId is required for federated credential auth" }
|
||||
$cred = [ordered]@{
|
||||
AuthMethod = $authMethod
|
||||
Cloud = $cloudValue
|
||||
Authority = $authority
|
||||
Resource = $resource
|
||||
TenantId = $tenantId
|
||||
ClientId = $clientId
|
||||
FederatedTokenFile = [string]$Arguments.FederatedTokenFile
|
||||
FederatedToken = [string]$Arguments.FederatedToken
|
||||
}
|
||||
$assertion = Get-OAuthFederatedAssertion -FederatedToken $cred.FederatedToken -FederatedTokenFile $cred.FederatedTokenFile
|
||||
$tokenResp = Invoke-OAuthTokenRequest -Authority $authority -TenantId $tenantId -Body @{
|
||||
grant_type = 'client_credentials'
|
||||
client_id = $clientId
|
||||
scope = $defaultScope
|
||||
client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
|
||||
client_assertion = $assertion
|
||||
}
|
||||
}
|
||||
elseif($Arguments.ContainsKey('Certificate') -and $Arguments.Certificate) {
|
||||
$authMethod = 'Certificate'
|
||||
if(-not $tenantId) { throw "TenantId is required for certificate auth" }
|
||||
if(-not $clientId) { throw "AppId is required for certificate auth" }
|
||||
$cert = $this.ResolveCertificate($Arguments.Certificate, $null, $null)
|
||||
$cred = [ordered]@{
|
||||
AuthMethod = $authMethod
|
||||
Cloud = $cloudValue
|
||||
Authority = $authority
|
||||
Resource = $resource
|
||||
TenantId = $tenantId
|
||||
ClientId = $clientId
|
||||
Certificate = $cert
|
||||
}
|
||||
$assertion = Get-OAuthClientAssertion -Certificate $cert -ClientId $clientId -Authority $authority -TenantId $tenantId
|
||||
$tokenResp = Invoke-OAuthTokenRequest -Authority $authority -TenantId $tenantId -Body @{
|
||||
grant_type = 'client_credentials'
|
||||
client_id = $clientId
|
||||
scope = $defaultScope
|
||||
client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
|
||||
client_assertion = $assertion
|
||||
}
|
||||
}
|
||||
elseif($Arguments.ContainsKey('CertificatePath') -and $Arguments.CertificatePath) {
|
||||
$authMethod = 'Certificate'
|
||||
if(-not $tenantId) { throw "TenantId is required for certificate auth" }
|
||||
if(-not $clientId) { throw "AppId is required for certificate auth" }
|
||||
$cert = $this.ResolveCertificate($null, [string]$Arguments.CertificatePath, $Arguments.CertificatePassword)
|
||||
$cred = [ordered]@{
|
||||
AuthMethod = $authMethod
|
||||
Cloud = $cloudValue
|
||||
Authority = $authority
|
||||
Resource = $resource
|
||||
TenantId = $tenantId
|
||||
ClientId = $clientId
|
||||
Certificate = $cert
|
||||
CertificatePath = [string]$Arguments.CertificatePath
|
||||
CertificatePassword = $Arguments.CertificatePassword
|
||||
}
|
||||
$assertion = Get-OAuthClientAssertion -Certificate $cert -ClientId $clientId -Authority $authority -TenantId $tenantId
|
||||
$tokenResp = Invoke-OAuthTokenRequest -Authority $authority -TenantId $tenantId -Body @{
|
||||
grant_type = 'client_credentials'
|
||||
client_id = $clientId
|
||||
scope = $defaultScope
|
||||
client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
|
||||
client_assertion = $assertion
|
||||
}
|
||||
}
|
||||
elseif($Arguments.ContainsKey('Secret') -and $Arguments.Secret) {
|
||||
$authMethod = 'ClientSecret'
|
||||
if(-not $tenantId) { throw "TenantId is required for client-secret auth" }
|
||||
if(-not $clientId) { throw "AppId is required for client-secret auth" }
|
||||
$secretPlain = if($Arguments.Secret -is [SecureString]) {
|
||||
[System.Net.NetworkCredential]::new("", $Arguments.Secret).Password
|
||||
} else {
|
||||
[string]$Arguments.Secret
|
||||
}
|
||||
$cred = [ordered]@{
|
||||
AuthMethod = $authMethod
|
||||
Cloud = $cloudValue
|
||||
Authority = $authority
|
||||
Resource = $resource
|
||||
TenantId = $tenantId
|
||||
ClientId = $clientId
|
||||
Secret = if($Arguments.Secret -is [SecureString]) { $Arguments.Secret } else { ConvertTo-SecureString $secretPlain -AsPlainText -Force }
|
||||
}
|
||||
$tokenResp = Invoke-OAuthTokenRequest -Authority $authority -TenantId $tenantId -Body @{
|
||||
grant_type = 'client_credentials'
|
||||
client_id = $clientId
|
||||
client_secret = $secretPlain
|
||||
scope = $defaultScope
|
||||
}
|
||||
}
|
||||
elseif($Arguments.ContainsKey('Credential') -and $Arguments.Credential) {
|
||||
$authMethod = 'Password'
|
||||
if(-not $tenantId) { throw "TenantId is required for password auth" }
|
||||
if(-not $clientId) { throw "AppId is required for password auth" }
|
||||
$pscred = [PSCredential]$Arguments.Credential
|
||||
$passwordPlain = $pscred.GetNetworkCredential().Password
|
||||
$cred = [ordered]@{
|
||||
AuthMethod = $authMethod
|
||||
Cloud = $cloudValue
|
||||
Authority = $authority
|
||||
Resource = $resource
|
||||
TenantId = $tenantId
|
||||
ClientId = $clientId
|
||||
Username = $pscred.UserName
|
||||
Password = $pscred.Password
|
||||
}
|
||||
$tokenResp = Invoke-OAuthTokenRequest -Authority $authority -TenantId $tenantId -Body @{
|
||||
grant_type = 'password'
|
||||
client_id = $clientId
|
||||
username = $pscred.UserName
|
||||
password = $passwordPlain
|
||||
scope = "$defaultScope offline_access"
|
||||
}
|
||||
}
|
||||
else {
|
||||
throw "AuthenticationOAuth.Connect: no supported credential argument was provided. Pass -Secret, -Certificate, -CertificatePath, -ManagedIdentity, -FederatedTokenFile/-FederatedToken, -Credential, -DeviceCode, or -Token."
|
||||
}
|
||||
}
|
||||
catch [System.OperationCanceledException] {
|
||||
# An interactive user explicitly abandoned the flow. Do not publish the
|
||||
# canonical AuthenticationFailed event: consumers use that event for real
|
||||
# authentication faults (and may tear down/redraw an existing session).
|
||||
Write-Log "OAuth provider Connect cancelled by the user (method: $authMethod)" 2
|
||||
return $null
|
||||
}
|
||||
catch {
|
||||
Write-LogError "OAuth provider Connect failed (method: $authMethod)" $_.Exception
|
||||
Invoke-AuthTokenFailed -Provider $this.Id -TenantId $tenantId -Message "OAuth Connect failed (method: $authMethod)" -Exception $_.Exception
|
||||
return $null
|
||||
}
|
||||
|
||||
if(-not $tokenResp -or -not $tokenResp.access_token) {
|
||||
Write-Log "OAuth provider: token request returned no access_token (method: $authMethod)" 3
|
||||
Invoke-AuthTokenFailed -Provider $this.Id -TenantId $tenantId -Message "OAuth token request returned no access_token (method: $authMethod)"
|
||||
return $null
|
||||
}
|
||||
|
||||
# Compute expiry. Prefer expires_in (relative seconds; reliable across all
|
||||
# flows). Fall back to JWT exp claim if expires_in is absent (BYO token).
|
||||
$expiresAt = [DateTime]::UtcNow.AddMinutes(50)
|
||||
if($tokenResp.expires_in) {
|
||||
$secs = [int]$tokenResp.expires_in
|
||||
$expiresAt = [DateTime]::UtcNow.AddSeconds($secs)
|
||||
}
|
||||
else {
|
||||
try {
|
||||
$jwt = Get-JWTtoken $tokenResp.access_token
|
||||
if($jwt -and $jwt.Payload -and $jwt.Payload.exp) {
|
||||
$expiresAt = [DateTimeOffset]::FromUnixTimeSeconds([long]$jwt.Payload.exp).UtcDateTime
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
# If TenantId wasn't supplied (BYO token, IMDS without tenant_id),
|
||||
# extract it from the access_token's `tid` claim so multi-tenant
|
||||
# routing (Get-GraphDomain, Save-TenantCloud) still works.
|
||||
if(-not $tenantId) {
|
||||
try {
|
||||
$jwt = Get-JWTtoken $tokenResp.access_token
|
||||
if($jwt -and $jwt.Payload -and $jwt.Payload.tid) {
|
||||
$tenantId = [string]$jwt.Payload.tid
|
||||
$cred.TenantId = $tenantId
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
# Allocate a TokenId from the central registry so ids are globally unique
|
||||
# across providers (MSAL/OAuth/MgGraph), not just within this provider.
|
||||
$tokenId = Get-NextAuthTokenId
|
||||
$entry = [ordered]@{
|
||||
Id = $tokenId
|
||||
AccessToken = [string]$tokenResp.access_token
|
||||
RefreshToken = if($tokenResp.refresh_token) { [string]$tokenResp.refresh_token } else { $null }
|
||||
ExpiresAt = $expiresAt
|
||||
TenantId = $tenantId
|
||||
ClientId = $clientId
|
||||
AuthMethod = $authMethod
|
||||
Cloud = $cloudValue
|
||||
Resource = $resource
|
||||
CredentialState = $cred
|
||||
AcquiredAt = [DateTime]::UtcNow
|
||||
}
|
||||
[AuthenticationOAuth]::Tokens[$tokenId] = $entry
|
||||
|
||||
# Opt-in cross-restart persistence for the browser flow: DPAPI-encrypt the
|
||||
# refresh token when "Remember login" (OAuthCacheToken) is on. Only for the
|
||||
# interactive browser method - service/BYO flows re-acquire from their own
|
||||
# credentials and shouldn't leave a refresh token on disk.
|
||||
if($authMethod -eq 'AuthCode' -and $entry.RefreshToken -and ((Get-SettingValue "OAuthCacheToken") -eq $true)) {
|
||||
[void](Save-OAuthTokenCache -Data @{
|
||||
RefreshToken = $entry.RefreshToken
|
||||
ClientId = $clientId
|
||||
TenantId = $tenantId
|
||||
Cloud = $cloudValue
|
||||
Authority = $authority
|
||||
Resource = $resource
|
||||
AuthMethod = $authMethod
|
||||
})
|
||||
}
|
||||
|
||||
Write-Log "OAuth provider: acquired token (TokenId=$tokenId, method=$authMethod, tenant=$tenantId, expires=$($expiresAt.ToLocalTime().ToString('s')))"
|
||||
|
||||
# Persist per-tenant cloud memory so subsequent calls land on the same authority.
|
||||
try {
|
||||
if($tenantId) {
|
||||
Save-TenantCloud -TenantId $tenantId -Cloud $cloudValue
|
||||
Save-SettingStoreValue "" "LastLoggedOnCloud" $cloudValue
|
||||
}
|
||||
} catch {
|
||||
Write-LogDebug "OAuth provider: Save-TenantCloud failed: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# Realign the active provider so subsequent Invoke-MSGraphAPI calls route here.
|
||||
try {
|
||||
$cur = Get-AuthProvider
|
||||
if($cur -and $cur.Id -ne $this.Id) {
|
||||
Write-Log "Active auth provider auto-switched from '$($cur.Id)' to '$($this.Id)' because OAuth authentication succeeded"
|
||||
Set-ActiveAuthProvider -Id $this.Id
|
||||
}
|
||||
} catch { }
|
||||
|
||||
# Register with the central token registry, which fires AuthenticatedNewToken
|
||||
# with the canonical [IMAuthToken] payload (no longer fired directly here).
|
||||
$token = Register-AuthToken -Provider $this -TokenId $tokenId -Cloud $cloudValue
|
||||
return $token
|
||||
}
|
||||
|
||||
[bool] Disconnect([int]$TokenId) {
|
||||
if(-not [AuthenticationOAuth]::Tokens.ContainsKey($TokenId)) { return $false }
|
||||
$entry = [AuthenticationOAuth]::Tokens[$TokenId]
|
||||
Write-Log "OAuth provider: disconnected TokenId=$TokenId (tenant=$($entry.TenantId))"
|
||||
# Unregister BEFORE dropping the backend entry so the disconnect snapshot can
|
||||
# still hydrate via GetUserInfo. The registry fires AuthenticationUserDisconnected
|
||||
# (and promotes a survivor default if needed).
|
||||
Unregister-AuthToken -TokenId $TokenId
|
||||
[AuthenticationOAuth]::Tokens.Remove($TokenId) | Out-Null
|
||||
# Sign-out of a browser session also drops the persisted refresh token so a
|
||||
# later launch doesn't silently resume the account the user just signed out of.
|
||||
if($entry.AuthMethod -eq 'AuthCode') { Clear-OAuthTokenCache }
|
||||
return $true
|
||||
}
|
||||
|
||||
[bool] Refresh([int]$TokenId) {
|
||||
if(-not [AuthenticationOAuth]::Tokens.ContainsKey($TokenId)) { return $false }
|
||||
try {
|
||||
$newToken = $this.AcquireFromState($TokenId)
|
||||
return [bool]$newToken
|
||||
}
|
||||
catch {
|
||||
Write-LogError "OAuth provider: refresh failed for TokenId=$TokenId" $_.Exception
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# Re-run whatever flow originally minted this entry, replacing the access
|
||||
# token (and refresh_token, where applicable) in place. Fires
|
||||
# AuthenticationTokenRefresh on success. Used both by the Refresh() public
|
||||
# method and by GetAccessToken's preflight expiry check.
|
||||
Hidden [string] AcquireFromState([int]$TokenId) {
|
||||
return $this.AcquireFromState($TokenId, $null)
|
||||
}
|
||||
|
||||
# $Claims carries a CAE claims challenge (from a Graph 401) into the /token
|
||||
# request so the re-minted token satisfies the tenant's policy change.
|
||||
Hidden [string] AcquireFromState([int]$TokenId, [string]$Claims) {
|
||||
if(-not [AuthenticationOAuth]::Tokens.ContainsKey($TokenId)) { return $null }
|
||||
$entry = [AuthenticationOAuth]::Tokens[$TokenId]
|
||||
$cred = $entry.CredentialState
|
||||
$defaultScope = "$($cred.Resource)/.default"
|
||||
|
||||
$tokenResp = $null
|
||||
switch($cred.AuthMethod) {
|
||||
'BYO' {
|
||||
# BYO bearer can't be silently refreshed — caller must Connect again.
|
||||
Write-Log "OAuth provider: BYO token (TokenId=$TokenId) cannot be refreshed automatically" 2
|
||||
return $null
|
||||
}
|
||||
'IMDS' {
|
||||
if($Claims) {
|
||||
# IMDS / App Service token endpoints don't accept a claims
|
||||
# parameter — a CAE challenge can't be satisfied here.
|
||||
Write-Log "OAuth provider: managed identity tokens cannot satisfy a CAE claims challenge (TokenId=$TokenId)" 2
|
||||
return $null
|
||||
}
|
||||
$tokenResp = Invoke-OAuthIMDS -Resource $cred.Resource -ClientId $cred.ClientId
|
||||
}
|
||||
'Federated' {
|
||||
$assertion = Get-OAuthFederatedAssertion -FederatedToken $cred.FederatedToken -FederatedTokenFile $cred.FederatedTokenFile
|
||||
$tokenResp = Invoke-OAuthTokenRequest -Authority $cred.Authority -TenantId $cred.TenantId -Claims $Claims -Body @{
|
||||
grant_type = 'client_credentials'
|
||||
client_id = $cred.ClientId
|
||||
scope = $defaultScope
|
||||
client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
|
||||
client_assertion = $assertion
|
||||
}
|
||||
}
|
||||
'Certificate' {
|
||||
$assertion = Get-OAuthClientAssertion -Certificate $cred.Certificate -ClientId $cred.ClientId -Authority $cred.Authority -TenantId $cred.TenantId
|
||||
$tokenResp = Invoke-OAuthTokenRequest -Authority $cred.Authority -TenantId $cred.TenantId -Claims $Claims -Body @{
|
||||
grant_type = 'client_credentials'
|
||||
client_id = $cred.ClientId
|
||||
scope = $defaultScope
|
||||
client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
|
||||
client_assertion = $assertion
|
||||
}
|
||||
}
|
||||
'ClientSecret' {
|
||||
$secretPlain = [System.Net.NetworkCredential]::new("", $cred.Secret).Password
|
||||
$tokenResp = Invoke-OAuthTokenRequest -Authority $cred.Authority -TenantId $cred.TenantId -Claims $Claims -Body @{
|
||||
grant_type = 'client_credentials'
|
||||
client_id = $cred.ClientId
|
||||
client_secret = $secretPlain
|
||||
scope = $defaultScope
|
||||
}
|
||||
}
|
||||
'DeviceCode' {
|
||||
# Silent-only here: never re-prompt with a new device code from a
|
||||
# refresh path. No refresh token → the 401/expiry surfaces and the
|
||||
# user re-runs Connect with -DeviceCode explicitly.
|
||||
if(-not $entry.RefreshToken) {
|
||||
Write-Log "OAuth provider: device-code token (TokenId=$TokenId) has no refresh token - run Connect-IntuneManagement -DeviceCode again" 2
|
||||
return $null
|
||||
}
|
||||
$dcTenant = if($cred.TenantId) { $cred.TenantId } else { 'organizations' }
|
||||
$tokenResp = Invoke-OAuthTokenRequest -Authority $cred.Authority -TenantId $dcTenant -Claims $Claims -Body @{
|
||||
grant_type = 'refresh_token'
|
||||
client_id = $cred.ClientId
|
||||
refresh_token = $entry.RefreshToken
|
||||
scope = "$defaultScope offline_access"
|
||||
}
|
||||
}
|
||||
'AuthCode' {
|
||||
# Browser (auth-code) refresh is silent - the refresh_token grant,
|
||||
# identical to DeviceCode. No browser/redirect needed. Missing refresh
|
||||
# token means the user must sign in again.
|
||||
if(-not $entry.RefreshToken) {
|
||||
Write-Log "OAuth provider: browser token (TokenId=$TokenId) has no refresh token - sign in again" 2
|
||||
return $null
|
||||
}
|
||||
$acTenant = if($cred.TenantId) { $cred.TenantId } else { 'organizations' }
|
||||
$tokenResp = Invoke-OAuthTokenRequest -Authority $cred.Authority -TenantId $acTenant -Claims $Claims -Body @{
|
||||
grant_type = 'refresh_token'
|
||||
client_id = $cred.ClientId
|
||||
refresh_token = $entry.RefreshToken
|
||||
scope = "$defaultScope offline_access"
|
||||
}
|
||||
}
|
||||
'Password' {
|
||||
# Prefer refresh_token grant if we got one; falls back to ROPC re-prompt.
|
||||
if($entry.RefreshToken) {
|
||||
$tokenResp = Invoke-OAuthTokenRequest -Authority $cred.Authority -TenantId $cred.TenantId -Claims $Claims -Body @{
|
||||
grant_type = 'refresh_token'
|
||||
client_id = $cred.ClientId
|
||||
refresh_token = $entry.RefreshToken
|
||||
scope = "$defaultScope offline_access"
|
||||
}
|
||||
}
|
||||
else {
|
||||
$passwordPlain = [System.Net.NetworkCredential]::new("", $cred.Password).Password
|
||||
$tokenResp = Invoke-OAuthTokenRequest -Authority $cred.Authority -TenantId $cred.TenantId -Claims $Claims -Body @{
|
||||
grant_type = 'password'
|
||||
client_id = $cred.ClientId
|
||||
username = $cred.Username
|
||||
password = $passwordPlain
|
||||
scope = "$defaultScope offline_access"
|
||||
}
|
||||
}
|
||||
}
|
||||
default {
|
||||
throw "OAuth provider: unknown AuthMethod '$($cred.AuthMethod)' on TokenId=$TokenId"
|
||||
}
|
||||
}
|
||||
|
||||
if(-not $tokenResp -or -not $tokenResp.access_token) {
|
||||
Write-Log "OAuth provider: refresh request returned no access_token for TokenId=$TokenId" 3
|
||||
return $null
|
||||
}
|
||||
|
||||
$expiresAt = [DateTime]::UtcNow.AddMinutes(50)
|
||||
if($tokenResp.expires_in) {
|
||||
$expiresAt = [DateTime]::UtcNow.AddSeconds([int]$tokenResp.expires_in)
|
||||
}
|
||||
$entry.AccessToken = [string]$tokenResp.access_token
|
||||
if($tokenResp.refresh_token) { $entry.RefreshToken = [string]$tokenResp.refresh_token }
|
||||
$entry.ExpiresAt = $expiresAt
|
||||
$entry.AcquiredAt = [DateTime]::UtcNow
|
||||
[AuthenticationOAuth]::Tokens[$TokenId] = $entry
|
||||
|
||||
Write-LogDebug "OAuth provider: refreshed TokenId=$TokenId (method=$($cred.AuthMethod), expires=$($expiresAt.ToLocalTime().ToString('s')))"
|
||||
|
||||
# Re-persist the rotated refresh token for the browser flow when caching is on.
|
||||
if($cred.AuthMethod -eq 'AuthCode' -and $entry.RefreshToken -and ((Get-SettingValue "OAuthCacheToken") -eq $true)) {
|
||||
[void](Save-OAuthTokenCache -Data @{
|
||||
RefreshToken = $entry.RefreshToken
|
||||
ClientId = $cred.ClientId
|
||||
TenantId = $entry.TenantId
|
||||
Cloud = $entry.Cloud
|
||||
Authority = $cred.Authority
|
||||
Resource = $cred.Resource
|
||||
AuthMethod = 'AuthCode'
|
||||
})
|
||||
}
|
||||
|
||||
Update-AuthToken -TokenId $TokenId
|
||||
return $entry.AccessToken
|
||||
}
|
||||
|
||||
# CAE entry point — Invoke-MSGraphAPI calls this when Graph answers 401 with
|
||||
# a WWW-Authenticate claims challenge. Re-mints the token with the challenge
|
||||
# attached; returns the new access token, or $null when the flow can't
|
||||
# satisfy claims (BYO, managed identity, no refresh token).
|
||||
[string] AcquireWithClaims([int]$TokenId, [string]$ClaimsChallenge) {
|
||||
return $this.AcquireFromState($TokenId, $ClaimsChallenge)
|
||||
}
|
||||
|
||||
# Satisfy a CAE claims challenge by re-running the credential flow with the claims
|
||||
# attached to the /token body. Returns $null when the flow can't satisfy the claims
|
||||
# (e.g. BYO / managed identity). OAuth has no interactive browser escalation, so
|
||||
# $AllowInteractive is not used.
|
||||
[string] GetClaimsToken([int]$TokenId, [string]$Resource, [string]$ClaimsChallenge, [bool]$AllowInteractive) {
|
||||
return $this.AcquireWithClaims($TokenId, $ClaimsChallenge)
|
||||
}
|
||||
|
||||
# === Token retrieval ===
|
||||
|
||||
[string] GetAccessToken([int]$TokenId, [string]$Resource) {
|
||||
if($TokenId -le 0) {
|
||||
# Caller didn't pin a TokenId; pick the most recently-acquired entry.
|
||||
if([AuthenticationOAuth]::Tokens.Count -eq 0) { return $null }
|
||||
$latest = [AuthenticationOAuth]::Tokens.Values | Sort-Object AcquiredAt -Descending | Select-Object -First 1
|
||||
$TokenId = $latest.Id
|
||||
}
|
||||
if(-not [AuthenticationOAuth]::Tokens.ContainsKey($TokenId)) { return $null }
|
||||
$entry = [AuthenticationOAuth]::Tokens[$TokenId]
|
||||
|
||||
# 5-minute slack so we don't hand out a token that expires mid-request.
|
||||
if($entry.ExpiresAt -le [DateTime]::UtcNow.AddMinutes(5)) {
|
||||
$refreshed = $this.AcquireFromState($TokenId)
|
||||
if($refreshed) { return $refreshed }
|
||||
# Refresh failed; surface the stale token rather than $null and let
|
||||
# Invoke-MSGraphAPI handle the inevitable 401 — same behavior as MSAL.
|
||||
}
|
||||
return [string]$entry.AccessToken
|
||||
}
|
||||
|
||||
[datetime] GetAccessTokenExpiry([int]$TokenId, [string]$Resource) {
|
||||
if(-not [AuthenticationOAuth]::Tokens.ContainsKey($TokenId)) {
|
||||
return [datetime]::MaxValue
|
||||
}
|
||||
# ExpiresAt is stored UTC; return LOCAL to match the provider contract (MSAL
|
||||
# returns ExpiresOn.LocalDateTime) and the local-time comparisons in
|
||||
# Invoke-MSGraphAPI / Test-DefaultTokenExpired. Returning raw UTC made callers
|
||||
# in ahead-of-UTC timezones see a just-issued token as already expired.
|
||||
return [AuthenticationOAuth]::Tokens[$TokenId].ExpiresAt.ToLocalTime()
|
||||
}
|
||||
|
||||
# === Display / picker data ===
|
||||
|
||||
[PSCustomObject] GetUserInfo([int]$TokenId) {
|
||||
if($TokenId -le 0 -and [AuthenticationOAuth]::Tokens.Count -gt 0) {
|
||||
$latest = [AuthenticationOAuth]::Tokens.Values | Sort-Object AcquiredAt -Descending | Select-Object -First 1
|
||||
$TokenId = $latest.Id
|
||||
}
|
||||
if(-not [AuthenticationOAuth]::Tokens.ContainsKey($TokenId)) { return $null }
|
||||
$entry = [AuthenticationOAuth]::Tokens[$TokenId]
|
||||
|
||||
$upn = $null; $userId = $null; $appName = $null
|
||||
try {
|
||||
$jwt = Get-JWTtoken $entry.AccessToken
|
||||
if($jwt -and $jwt.Payload) {
|
||||
$upn = if($jwt.Payload.upn) { [string]$jwt.Payload.upn }
|
||||
elseif($jwt.Payload.preferred_username) { [string]$jwt.Payload.preferred_username }
|
||||
elseif($jwt.Payload.unique_name) { [string]$jwt.Payload.unique_name }
|
||||
else { $null }
|
||||
$userId = if($jwt.Payload.oid) { [string]$jwt.Payload.oid } else { $null }
|
||||
$appName = if($jwt.Payload.app_displayname) { [string]$jwt.Payload.app_displayname } else { $null }
|
||||
}
|
||||
} catch { }
|
||||
|
||||
# Map AuthMethod to the cross-provider taxonomy (Interactive / ClientCredential / BYO / ...).
|
||||
$authType = switch ($entry.AuthMethod) {
|
||||
'BYO' { 'BYO' }
|
||||
'DeviceCode' { 'Interactive' }
|
||||
'AuthCode' { 'Interactive' }
|
||||
'IMDS' { 'ManagedIdentity' }
|
||||
'Federated' { 'WorkloadFederation' }
|
||||
'Certificate' { 'ClientCredential' }
|
||||
'ClientSecret' { 'ClientCredential' }
|
||||
'Password' { 'Password' }
|
||||
default { [string]$entry.AuthMethod }
|
||||
}
|
||||
|
||||
# Caller-friendly default for headless flows: when there's no UPN
|
||||
# (app-only token), display the app id.
|
||||
$displayName = if($upn) { $upn } else { $entry.ClientId }
|
||||
|
||||
# Tenant display name — one /organization GET per tenant per session,
|
||||
# mirroring AuthenticationMgGraph.TenantNameCache. App-only tokens
|
||||
# without Organization.Read.All fail the lookup; the $null result is
|
||||
# cached too so it isn't retried on every refresh event.
|
||||
$tenantName = $null
|
||||
if($entry.TenantId) {
|
||||
if([AuthenticationOAuth]::TenantNameCache.ContainsKey($entry.TenantId)) {
|
||||
$tenantName = [AuthenticationOAuth]::TenantNameCache[$entry.TenantId]
|
||||
}
|
||||
else {
|
||||
$tenantName = Get-OAuthTenantOrganizationName -Resource $entry.Resource -AccessToken $entry.AccessToken
|
||||
[AuthenticationOAuth]::TenantNameCache[$entry.TenantId] = $tenantName
|
||||
}
|
||||
}
|
||||
|
||||
return [PSCustomObject]@{
|
||||
Provider = $this.Id
|
||||
DisplayName = $displayName
|
||||
UPN = $upn
|
||||
UserId = $userId
|
||||
TenantId = $entry.TenantId
|
||||
TenantName = $tenantName
|
||||
AppId = $entry.ClientId
|
||||
AppName = $appName
|
||||
AuthType = $authType
|
||||
ExpiresOn = $entry.ExpiresAt.ToLocalTime()
|
||||
}
|
||||
}
|
||||
|
||||
[PSCustomObject[]] GetCachedAccounts() {
|
||||
# Pure-headless: surface the token cache as the account list. Each entry
|
||||
# represents one Connect() call within the session.
|
||||
$rows = @()
|
||||
foreach($entry in [AuthenticationOAuth]::Tokens.Values) {
|
||||
$upn = $null
|
||||
try {
|
||||
$jwt = Get-JWTtoken $entry.AccessToken
|
||||
if($jwt -and $jwt.Payload) {
|
||||
$upn = if($jwt.Payload.upn) { [string]$jwt.Payload.upn }
|
||||
elseif($jwt.Payload.preferred_username) { [string]$jwt.Payload.preferred_username }
|
||||
else { $null }
|
||||
}
|
||||
} catch { }
|
||||
$rows += [PSCustomObject]@{
|
||||
Provider = $this.Id
|
||||
Username = if($upn) { $upn } else { $entry.ClientId }
|
||||
UserId = $null
|
||||
TenantId = $entry.TenantId
|
||||
Native = $entry
|
||||
}
|
||||
}
|
||||
return [PSCustomObject[]]$rows
|
||||
}
|
||||
|
||||
[PSCustomObject[]] GetAvailableTenants([int]$TokenId) {
|
||||
# Tenant enumeration costs an extra Graph call (/tenants or /me/memberOf
|
||||
# /transitive). Headless callers usually know which tenant they want;
|
||||
# leave this as a v2 enhancement.
|
||||
return [PSCustomObject[]]@()
|
||||
}
|
||||
|
||||
# Resolve a -Certificate argument (thumbprint string OR X509Certificate2 OR
|
||||
# path-to-pfx) into a usable X509Certificate2. Reuses the MSAL provider's
|
||||
# resolver where available so behavior stays identical.
|
||||
Hidden [System.Security.Cryptography.X509Certificates.X509Certificate2] ResolveCertificate($CertObject, [string]$Path, $Password) {
|
||||
if($CertObject -is [System.Security.Cryptography.X509Certificates.X509Certificate2]) {
|
||||
return $CertObject
|
||||
}
|
||||
if($CertObject) {
|
||||
# Treat as thumbprint string — search Cert: stores the same way MSAL does.
|
||||
if(Get-Command Resolve-MSALCertificate -ErrorAction SilentlyContinue) {
|
||||
$cert = Resolve-MSALCertificate $CertObject
|
||||
if($cert) { return $cert }
|
||||
}
|
||||
# Fallback: walk Cert:\CurrentUser\My then Cert:\LocalMachine\My.
|
||||
$thumb = ([string]$CertObject).Replace(' ', '').ToUpperInvariant()
|
||||
foreach($store in @('Cert:\CurrentUser\My', 'Cert:\LocalMachine\My')) {
|
||||
$hit = Get-ChildItem $store -ErrorAction SilentlyContinue | Where-Object Thumbprint -EQ $thumb | Select-Object -First 1
|
||||
if($hit) { return $hit }
|
||||
}
|
||||
throw "Could not find certificate with thumbprint '$CertObject' in CurrentUser\My or LocalMachine\My"
|
||||
}
|
||||
if($Path) {
|
||||
if(-not (Test-Path $Path)) { throw "Certificate file not found: $Path" }
|
||||
return Get-PfxCertificate -FilePath $Path -Password $Password -ErrorAction Stop
|
||||
}
|
||||
throw "ResolveCertificate: neither object nor path was supplied"
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
#ImportOrder 25
|
||||
|
||||
# Base class for authentication providers.
|
||||
#
|
||||
# Naming convention: concrete providers are named Authentication<Backend>
|
||||
# (AuthenticationMSAL, AuthenticationMgGraph, AuthenticationAz). The base class
|
||||
# stays AuthenticationProvider — there's only one of those.
|
||||
#
|
||||
# Three concrete providers ship: AuthenticationMSAL (MSAL.NET), AuthenticationMgGraph
|
||||
# (Microsoft.Graph SDK) and AuthenticationOAuth (pure PowerShell for automation).
|
||||
#
|
||||
# Concrete providers override what they support. Methods that MUST be overridden
|
||||
# throw NotImplemented when called on the base; optional methods return safe defaults
|
||||
# so a provider that doesn't support a feature simply reports back $false / $null.
|
||||
#
|
||||
# Return shapes for the *Info / *Accounts / *Tenants methods are uniform across all
|
||||
# providers — that's the whole point of the abstraction. Consumers (Invoke-MSGraphAPI,
|
||||
# profile dialog, account picker) read these uniform shapes directly. Invoke-MSGraphAPI
|
||||
# resolves a call's OWNING provider by TokenId and gets the bearer from it (or lets the
|
||||
# provider run the request), so MSAL / MgGraph / OAuth tokens can all be live at once.
|
||||
#
|
||||
# === Required capabilities (contract) ===
|
||||
# Every concrete provider MUST support and prove out these auth modes:
|
||||
# * Interactive login (SupportsInteractive = $true)
|
||||
# * App with client secret (SupportsClientSecret = $true)
|
||||
# * App with certificate (SupportsCertificate = $true)
|
||||
# Setting any of these to $false on a concrete provider is a contract violation —
|
||||
# the abstraction assumes consumers can pick any of the three.
|
||||
#
|
||||
# === Recommended capabilities ===
|
||||
# Providers SHOULD also support, where the backend allows it:
|
||||
# * Identity Provider login (SupportsIdentityProvider = $true)
|
||||
# Covers managed identity, workload identity federation, and federated
|
||||
# credential assertions. Not required because Connect-IntuneManagement
|
||||
# doesn't yet expose those parameter sets, but expected for full coverage.
|
||||
class AuthenticationProvider {
|
||||
# Identity
|
||||
[string]$Id
|
||||
[string]$DisplayName
|
||||
|
||||
# Capability flags. The profile UI reads these to enable / disable buttons and the
|
||||
# connect facade routes only to providers that support the requested mode.
|
||||
[bool]$SupportsMultiTenant = $true
|
||||
[bool]$SupportsRefresh = $true
|
||||
[bool]$SupportsForget = $true
|
||||
[bool]$SupportsCachedUsers = $true
|
||||
|
||||
# Required by contract. See block comment above.
|
||||
[bool]$SupportsInteractive = $true
|
||||
[bool]$SupportsClientSecret = $true
|
||||
[bool]$SupportsCertificate = $true
|
||||
|
||||
# Recommended. Managed identity / workload identity federation / federated
|
||||
# credential. Off by default — concrete providers opt in when implemented.
|
||||
[bool]$SupportsIdentityProvider = $false
|
||||
|
||||
# Optional. Provider accepts an externally-issued bearer token (no auth flow).
|
||||
[bool]$SupportsBYOToken = $false
|
||||
|
||||
# Optional. Provider can satisfy a CAE (Continuous Access Evaluation) claims
|
||||
# challenge - a Graph 401 carrying a WWW-Authenticate claims="..." parameter - by
|
||||
# re-minting the token via GetClaimsToken(). Providers whose SDK handles CAE
|
||||
# internally, or that can't re-mint (pure BYO), leave this $false and the caller
|
||||
# surfaces the 401 unchanged.
|
||||
[bool]$SupportsClaimsChallenge = $false
|
||||
|
||||
# Optional. The provider is wired directly into the module's built-in
|
||||
# Connect-EntraEnvironment / Connect-IntuneManagement entry points (the original
|
||||
# pre-abstraction MSAL path). Callers that want that rich handling (sovereign-cloud
|
||||
# -Cloud selection, ForceRefresh by TokenId, ...) drive such a provider through those
|
||||
# functions instead of the generic Connect()/Refresh() hop, keeping behaviour and
|
||||
# stack traces identical. Providers whose logic lives entirely in their own Connect()
|
||||
# / Refresh() leave this $false.
|
||||
[bool]$UsesBuiltInConnectPath = $false
|
||||
|
||||
# Optional. Provider can launch an interactive admin/user consent prompt for the
|
||||
# app's delegated scopes (MSAL: Start-MSALConsentPrompt). The profile UI shows a
|
||||
# "Request consent" action only when this is $true.
|
||||
[bool]$SupportsConsentPrompt = $false
|
||||
|
||||
# The provider answers every Graph request itself through InvokeWebRequest,
|
||||
# even though GetAccessToken returned a bearer. Off for the real providers -
|
||||
# a bearer means "send it over HTTPS". On for a provider whose backend is not
|
||||
# Graph at all (the offline mock tenant serves canned data from disk).
|
||||
[bool]$RoutesAllRequests = $false
|
||||
|
||||
# Always Graph for now; -Resource is plumbed through so future phases can mint
|
||||
# tokens for other audiences (Key Vault, Storage, etc.) without API changes.
|
||||
[string]$DefaultResource = "https://graph.microsoft.com"
|
||||
|
||||
# Called once at module init for provider-specific setup (DLL loads, settings).
|
||||
# Default is a no-op.
|
||||
[void] Initialize() { }
|
||||
|
||||
# === Auth lifecycle ===
|
||||
# Must override:
|
||||
[PSCustomObject] Connect([hashtable]$Arguments) {
|
||||
throw "Provider '$($this.Id)' does not implement Connect"
|
||||
}
|
||||
|
||||
# Optional (return $false if not supported):
|
||||
[bool] Disconnect([int]$TokenId) { return $false }
|
||||
[bool] ForgetAccount([string]$AccountIdentifier) { return $false }
|
||||
[bool] Refresh([int]$TokenId) { return $false }
|
||||
|
||||
# List the tenants the signed-in account can reach, for a tenant picker or a
|
||||
# pre-flight check before Connect -TenantId. Microsoft Graph has no API for
|
||||
# this: findTenantInformationByTenantId resolves ONE tenant you already know,
|
||||
# and the multi-tenant-organization APIs only cover a configured MTO. The only
|
||||
# general source is Azure Resource Manager's /tenants, which needs a token for
|
||||
# a different audience - so a provider can implement this only if it can mint
|
||||
# one for the same account.
|
||||
#
|
||||
# Return $null when the provider cannot enumerate (the default). Otherwise
|
||||
# return @{ Tenants = <rows>; ConsentMissing = <bool>; Message = <string> } so
|
||||
# the caller can tell "no tenants" from "the app lacks the permission".
|
||||
[PSCustomObject] GetAccessibleTenants([int]$TokenId) { return $null }
|
||||
[PSCustomObject] SwitchTenant([int]$TokenId, [string]$NewTenantId) { return $null }
|
||||
|
||||
# Called once at app startup (AppInitialized event) for the active provider only.
|
||||
# Implementations should attempt a SILENT (non-interactive) sign-in if their backend
|
||||
# has persisted credentials on disk. Return $true if the user is now signed in,
|
||||
# $false otherwise. Default is no-op — MSAL has its own cross-session refresh path
|
||||
# via $script:MSALDefaultToken on startup, so it doesn't need this hook.
|
||||
[bool] TryResumeSession() { return $false }
|
||||
|
||||
# Optional. Cheap SILENT ambient-session refresh, invoked on view activation to keep
|
||||
# the default token fresh without ever prompting. Providers that have no such
|
||||
# mechanism - or where a silent auth here could hijack the active session - leave the
|
||||
# base no-op. (MSAL refreshes via Connect-EntraEnvironment -ForceSilent.)
|
||||
[void] RefreshAmbientSession() { }
|
||||
|
||||
# === Token retrieval ===
|
||||
# Must override. -Resource is informational for providers that mint per-audience
|
||||
# tokens; today only Graph is required.
|
||||
[string] GetAccessToken([int]$TokenId, [string]$Resource) {
|
||||
throw "Provider '$($this.Id)' does not implement GetAccessToken"
|
||||
}
|
||||
|
||||
# Optional. Returns DateTime.MaxValue when expiry is unknown (callers should treat
|
||||
# MaxValue as "no preflight refresh needed").
|
||||
[datetime] GetAccessTokenExpiry([int]$TokenId, [string]$Resource) {
|
||||
return [datetime]::MaxValue
|
||||
}
|
||||
|
||||
# Optional. Re-mint an access token to satisfy a CAE claims challenge returned by
|
||||
# the resource (Graph 401 -> WWW-Authenticate claims="..."). Implemented only by
|
||||
# providers with SupportsClaimsChallenge = $true; the base returns $null so a
|
||||
# provider that can't satisfy the challenge simply lets the 401 surface.
|
||||
# $Resource - target audience (informational; Graph today)
|
||||
# $ClaimsChallenge - the claims value parsed from the 401 challenge
|
||||
# $AllowInteractive - caller context: $true when a UI is present and this is NOT a
|
||||
# nested auth-flow call, so the provider MAY prompt if it can't
|
||||
# satisfy the challenge silently; $false forces silent-only.
|
||||
# Returns the new access token, or $null if the challenge couldn't be satisfied.
|
||||
[string] GetClaimsToken([int]$TokenId, [string]$Resource, [string]$ClaimsChallenge, [bool]$AllowInteractive) {
|
||||
return $null
|
||||
}
|
||||
|
||||
# Optional. Providers whose backend SDK owns the HTTP pipeline and won't hand out a
|
||||
# raw bearer token override this to run the request themselves and return a response
|
||||
# object shaped like Invoke-WebRequest's (StatusCode / Headers / Content /
|
||||
# RawContentLength). Return $null to signal "I don't route requests - use the raw
|
||||
# access token from GetAccessToken via Invoke-WebRequest". Base default: $null.
|
||||
[object] InvokeWebRequest([string]$Url, [string]$Method, [object]$Body, [hashtable]$Headers) {
|
||||
return $null
|
||||
}
|
||||
|
||||
# === Display / picker data (uniform shapes for UI consumption) ===
|
||||
|
||||
# Returns a PSCustomObject with these properties (or $null if unknown):
|
||||
# Provider - this.Id
|
||||
# DisplayName - user's display name
|
||||
# UPN - user principal name (login id)
|
||||
# UserId - tenant-scoped object id
|
||||
# TenantId - GUID
|
||||
# TenantName - organization display name
|
||||
# AppId - Entra app client id
|
||||
# AppName - Entra app display name
|
||||
# AuthType - "Interactive" | "ClientCredential" | "BYO" | "DeviceCode" | ...
|
||||
# ExpiresOn - DateTime (local) or $null
|
||||
[PSCustomObject] GetUserInfo([int]$TokenId) { return $null }
|
||||
|
||||
# Returns PSCustomObject[] with these per-row properties:
|
||||
# Provider, Username, UserId, TenantId, Native (provider-opaque)
|
||||
[PSCustomObject[]] GetCachedAccounts() { return [PSCustomObject[]]@() }
|
||||
|
||||
# Returns PSCustomObject[] with these per-row properties:
|
||||
# Provider, TenantId, TenantName, Native
|
||||
[PSCustomObject[]] GetAvailableTenants([int]$TokenId) { return [PSCustomObject[]]@() }
|
||||
|
||||
# Returns Name/Value rows describing the provider's native session/context for the
|
||||
# profile "Session Info" inspector (MSAL: AuthenticationResult fields; MgGraph:
|
||||
# Get-MgContext properties). Base default: no rows.
|
||||
[PSCustomObject[]] GetSessionInfoRows() { return [PSCustomObject[]]@() }
|
||||
|
||||
# Decoded id-token JWT ({Header, Payload}) for the profile popup's "Id Token"
|
||||
# inspector, or $null when the provider doesn't surface an id token. The UI shows
|
||||
# the Id Token button only when this returns non-null, so non-MSAL providers hide
|
||||
# it automatically. Keeps raw-JWT knowledge inside the owning provider.
|
||||
[object] GetIdTokenJwt([int]$TokenId) { return $null }
|
||||
|
||||
# === Provider-specific UI hook ===
|
||||
# MSAL adds "MSAL Token / Access Token / ID Token" inspector buttons here, for
|
||||
# example. Returns a WPF element to splice into the profile popup, or $null.
|
||||
[object] BuildLoginMenu([object]$Window) { return $null }
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
#ImportOrder 30
|
||||
|
||||
class CompareProviderBase
|
||||
{
|
||||
[string] $Name
|
||||
[string] $Value
|
||||
[string] $OptionsXaml
|
||||
[string[]] $RemoveProperties = @()
|
||||
[bool] $IgnoreGroups = $false
|
||||
# Skip objects that exist on only one side. Source = the reverse pass
|
||||
# (objects only in the counterpart set); Destination = the forward pass
|
||||
# (objects whose looked-up counterpart is missing). Same semantics as the
|
||||
# original project's Skip Missing Source/Destination Policies checkboxes.
|
||||
[bool] $SkipMissingSourcePolicies = $false
|
||||
[bool] $SkipMissingDestinationPolicies = $false
|
||||
|
||||
CompareProviderBase([string]$name, [string]$value, [string]$optionsXaml)
|
||||
{
|
||||
$this.Name = $name
|
||||
$this.Value = $value
|
||||
$this.OptionsXaml = $optionsXaml
|
||||
}
|
||||
|
||||
[object[]] GetComparePairs([object[]]$groups) { return @() }
|
||||
[bool] Validate() { return $true }
|
||||
[void] SaveSettings() {}
|
||||
[string] ToString() { return $this.Name }
|
||||
}
|
||||
|
||||
class CompareExportFilesProvider : CompareProviderBase
|
||||
{
|
||||
[string] $ExportPath
|
||||
[string] $NameFilter
|
||||
|
||||
CompareExportFilesProvider() : base(
|
||||
"Exported Files with Intune Objects (Id)",
|
||||
"export",
|
||||
"CompareExportOptions"
|
||||
) {}
|
||||
|
||||
[object[]] GetComparePairs([object[]]$groups)
|
||||
{
|
||||
$pairs = @()
|
||||
|
||||
foreach($group in $groups)
|
||||
{
|
||||
foreach($policyType in $group.PolicyTypes)
|
||||
{
|
||||
$folder = [IO.Path]::Combine($this.ExportPath, $policyType.Folder)
|
||||
if(-not [IO.Directory]::Exists($folder))
|
||||
{
|
||||
Write-Log "Folder '$folder' not found. Skipping." 2
|
||||
continue
|
||||
}
|
||||
|
||||
Save-SettingStoreValue "" "LastUsedFullPath" $folder
|
||||
Write-Status "Compare $($policyType.Title)" -Force -SkipLog
|
||||
|
||||
$fileObjs = @(Get-PoliciesFromFolder -Path $folder -PolicyTypes @($policyType))
|
||||
$intuneObjs = @(Get-GraphPolicies -PolicyGroup $group.ID | Where-Object { $_.PolicyType.ID -eq $policyType.ID })
|
||||
|
||||
$pairedFileIds = @()
|
||||
foreach($fileObj in $fileObjs)
|
||||
{
|
||||
$objName = $fileObj.Name
|
||||
if($this.NameFilter -and $objName -notmatch [RegEx]::Escape($this.NameFilter)) { continue }
|
||||
if(-not $fileObj.ID) { Write-Log "File '$objName' has no Id. Skipping." 2; continue }
|
||||
|
||||
$intuneObj = $intuneObjs | Where-Object { $_.ID -eq $fileObj.ID }
|
||||
if($intuneObj) { $policyType.GetFullObject($intuneObj) | Out-Null }
|
||||
elseif($this.SkipMissingDestinationPolicies) { continue }
|
||||
else { Write-Log "Object '$objName' with id $($fileObj.ID) not found in Intune. Deleted?" 2 }
|
||||
|
||||
$pairedFileIds += [string]$fileObj.ID
|
||||
$pairs += [PSCustomObject]@{
|
||||
Name = $objName
|
||||
Id = $fileObj.ID
|
||||
PolicyType = $policyType
|
||||
SaveFolder = $folder
|
||||
Policy1 = $intuneObj
|
||||
Policy2 = $fileObj
|
||||
}
|
||||
}
|
||||
|
||||
if(-not $this.SkipMissingSourcePolicies)
|
||||
{
|
||||
# Objects that exist in Intune but were never exported.
|
||||
foreach($intuneObj in $intuneObjs)
|
||||
{
|
||||
if([string]$intuneObj.ID -in $pairedFileIds) { continue }
|
||||
$objName = $intuneObj.Name
|
||||
if($this.NameFilter -and $objName -notmatch [RegEx]::Escape($this.NameFilter)) { continue }
|
||||
|
||||
Write-Log "Object '$objName' with id $($intuneObj.ID) exists in Intune but has no exported file. New object?" 2
|
||||
$pairs += [PSCustomObject]@{
|
||||
Name = $objName
|
||||
Id = $intuneObj.ID
|
||||
PolicyType = $policyType
|
||||
SaveFolder = $folder
|
||||
Policy1 = $intuneObj
|
||||
Policy2 = $null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $pairs
|
||||
}
|
||||
|
||||
[void] SaveSettings()
|
||||
{
|
||||
# No persisted options: the "Compare\ExportPath" write that used to live here
|
||||
# was never read back anywhere (dead since the port).
|
||||
}
|
||||
}
|
||||
|
||||
class CompareIntuneWithExportProvider : CompareProviderBase
|
||||
{
|
||||
[string] $ExportPath
|
||||
[string] $NameFilter
|
||||
|
||||
CompareIntuneWithExportProvider() : base(
|
||||
"Intune Objects with Exported Files (Name)",
|
||||
"IntuneWithExport",
|
||||
"CompareExportOptions"
|
||||
) {}
|
||||
|
||||
[object[]] GetComparePairs([object[]]$groups)
|
||||
{
|
||||
$pairs = @()
|
||||
|
||||
foreach($group in $groups)
|
||||
{
|
||||
foreach($policyType in $group.PolicyTypes)
|
||||
{
|
||||
$folder = [IO.Path]::Combine($this.ExportPath, $policyType.Folder)
|
||||
if(-not [IO.Directory]::Exists($folder))
|
||||
{
|
||||
Write-Log "Folder '$folder' not found. Skipping." 2
|
||||
continue
|
||||
}
|
||||
|
||||
Save-SettingStoreValue "" "LastUsedFullPath" $folder
|
||||
Write-Status "Compare $($policyType.Title)" -Force -SkipLog
|
||||
|
||||
$fileObjs = @(Get-PoliciesFromFolder -Path $folder -PolicyTypes @($policyType))
|
||||
$intuneObjs = @(Get-GraphPolicies -PolicyGroup $group.ID | Where-Object { $_.PolicyType.ID -eq $policyType.ID })
|
||||
|
||||
$pairedFileNames = @()
|
||||
foreach($intuneObj in $intuneObjs)
|
||||
{
|
||||
$objName = $intuneObj.Name
|
||||
if($this.NameFilter -and $objName -notmatch [RegEx]::Escape($this.NameFilter)) { continue }
|
||||
|
||||
$fileObj = $fileObjs | Where-Object { $_.Name -eq $objName }
|
||||
if(($fileObj | Measure-Object).Count -gt 1)
|
||||
{
|
||||
Write-Log "Multiple file objects with name '$objName'. Skipping." 2
|
||||
continue
|
||||
}
|
||||
|
||||
if($fileObj) {
|
||||
$policyType.GetFullObject($intuneObj) | Out-Null
|
||||
$pairedFileNames += [string]$objName
|
||||
}
|
||||
elseif($this.SkipMissingDestinationPolicies) { continue }
|
||||
else { Write-Log "Object '$objName' with id $($intuneObj.ID) not found in exported folder. New object?" 2 }
|
||||
|
||||
$pairs += [PSCustomObject]@{
|
||||
Name = $objName
|
||||
Id = $intuneObj.ID
|
||||
PolicyType = $policyType
|
||||
SaveFolder = $folder
|
||||
Policy1 = $intuneObj
|
||||
Policy2 = $fileObj
|
||||
}
|
||||
}
|
||||
|
||||
if(-not $this.SkipMissingSourcePolicies)
|
||||
{
|
||||
# Exported files with no matching Intune object.
|
||||
foreach($fileObj in $fileObjs)
|
||||
{
|
||||
$objName = $fileObj.Name
|
||||
if([string]$objName -in $pairedFileNames) { continue }
|
||||
if($this.NameFilter -and $objName -notmatch [RegEx]::Escape($this.NameFilter)) { continue }
|
||||
|
||||
Write-Log "File object '$objName' has no matching Intune object. Deleted?" 2
|
||||
$pairs += [PSCustomObject]@{
|
||||
Name = $objName
|
||||
Id = $fileObj.ID
|
||||
PolicyType = $policyType
|
||||
SaveFolder = $folder
|
||||
Policy1 = $null
|
||||
Policy2 = $fileObj
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $pairs
|
||||
}
|
||||
|
||||
[void] SaveSettings()
|
||||
{
|
||||
# No persisted options: the "Compare\ExportPath" write that used to live here
|
||||
# was never read back anywhere (dead since the port).
|
||||
}
|
||||
}
|
||||
|
||||
class CompareNamedObjectsProvider : CompareProviderBase
|
||||
{
|
||||
[string] $SourcePattern
|
||||
[string] $ComparePattern
|
||||
[string] $SavePath
|
||||
[string[]] $RemoveProperties = @("Id")
|
||||
|
||||
CompareNamedObjectsProvider() : base(
|
||||
"Named Objects in Intune",
|
||||
"name",
|
||||
"CompareNamedOptions"
|
||||
)
|
||||
{
|
||||
$this.RemoveProperties = @("Id")
|
||||
}
|
||||
|
||||
[object[]] GetComparePairs([object[]]$groups)
|
||||
{
|
||||
if(-not $this.SourcePattern -or -not $this.ComparePattern)
|
||||
{
|
||||
throw "Both source and compare name patterns must be specified"
|
||||
}
|
||||
|
||||
$outputFolder = $this.SavePath
|
||||
if(-not $outputFolder) { $outputFolder = [Environment]::GetFolderPath("MyDocuments") }
|
||||
|
||||
$pairs = @()
|
||||
|
||||
foreach($group in $groups)
|
||||
{
|
||||
Write-Status "Compare $($group.Title) objects" -Force -SkipLog
|
||||
$intuneObjs = @(Get-GraphPolicies -PolicyGroup $group.ID)
|
||||
|
||||
foreach($intuneObj in ($intuneObjs | Where-Object { $_.Name -imatch [regex]::Escape($this.SourcePattern) }))
|
||||
{
|
||||
$sourceName = $intuneObj.Name
|
||||
$compareName = $sourceName -ireplace [regex]::Escape($this.SourcePattern), $this.ComparePattern
|
||||
|
||||
$compareObj = $intuneObjs | Where-Object { $_.Name -eq $compareName -and $_.Object.'@OData.Type' -eq $intuneObj.Object.'@OData.Type' }
|
||||
if(($compareObj | Measure-Object).Count -gt 1)
|
||||
{
|
||||
Write-Log "Multiple objects named '$compareName'. Skipping." 2
|
||||
continue
|
||||
}
|
||||
|
||||
if($compareObj)
|
||||
{
|
||||
$intuneObj.PolicyType.GetFullObject($intuneObj) | Out-Null
|
||||
$compareObj.PolicyType.GetFullObject($compareObj) | Out-Null
|
||||
}
|
||||
|
||||
$pairs += [PSCustomObject]@{
|
||||
Name = $sourceName
|
||||
Id = $intuneObj.ID
|
||||
PolicyType = $intuneObj.PolicyType
|
||||
SaveFolder = $outputFolder
|
||||
Policy1 = $intuneObj
|
||||
Policy2 = $compareObj
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $pairs
|
||||
}
|
||||
|
||||
[void] SaveSettings()
|
||||
{
|
||||
# No persisted options: the CompareSource / CompareWith / SavePath writes that
|
||||
# used to live here were never read back anywhere (dead since the port).
|
||||
}
|
||||
}
|
||||
|
||||
class CompareExportedFoldersProvider : CompareProviderBase
|
||||
{
|
||||
[string] $SourcePath
|
||||
[string] $ComparePath
|
||||
[string] $NameFilter
|
||||
|
||||
CompareExportedFoldersProvider() : base(
|
||||
"Files in Exported Folders",
|
||||
"exportedFolders",
|
||||
"CompareExportedFilesOptions"
|
||||
) {}
|
||||
|
||||
[object[]] GetComparePairs([object[]]$groups)
|
||||
{
|
||||
if(-not $this.SourcePath -or -not $this.ComparePath)
|
||||
{
|
||||
throw "Both source and compare folders must be specified"
|
||||
}
|
||||
if(-not [IO.Directory]::Exists($this.SourcePath))
|
||||
{
|
||||
throw "Source folder '$($this.SourcePath)' does not exist"
|
||||
}
|
||||
if(-not [IO.Directory]::Exists($this.ComparePath))
|
||||
{
|
||||
throw "Compare folder '$($this.ComparePath)' does not exist"
|
||||
}
|
||||
|
||||
$pairs = @()
|
||||
|
||||
foreach($group in $groups)
|
||||
{
|
||||
foreach($policyType in $group.PolicyTypes)
|
||||
{
|
||||
$folderSrc = [IO.Path]::Combine($this.SourcePath, $policyType.Folder)
|
||||
$folderCmp = [IO.Path]::Combine($this.ComparePath, $policyType.Folder)
|
||||
|
||||
if(-not [IO.Directory]::Exists($folderSrc)) { Write-Log "Source folder '$folderSrc' not found. Skipping." 2; continue }
|
||||
|
||||
Save-SettingStoreValue "" "LastUsedFullPath" $folderSrc
|
||||
Write-Status "Compare $($policyType.Title)" -Force -SkipLog
|
||||
|
||||
$srcObjs = @(Get-PoliciesFromFolder -Path $folderSrc -PolicyTypes @($policyType))
|
||||
$cmpObjs = @()
|
||||
if([IO.Directory]::Exists($folderCmp))
|
||||
{
|
||||
$cmpObjs = @(Get-PoliciesFromFolder -Path $folderCmp -PolicyTypes @($policyType))
|
||||
}
|
||||
|
||||
$addedIds = @()
|
||||
|
||||
foreach($srcObj in $srcObjs)
|
||||
{
|
||||
$objName = $srcObj.Name
|
||||
if($this.NameFilter -and $objName -notmatch [RegEx]::Escape($this.NameFilter)) { continue }
|
||||
if(-not $srcObj.ID) { Write-Log "File '$objName' has no Id. Skipping." 2; continue }
|
||||
|
||||
$cmpObj = $cmpObjs | Where-Object { $_.ID -eq $srcObj.ID }
|
||||
$addedIds += $srcObj.ID
|
||||
if(-not $cmpObj -and $this.SkipMissingDestinationPolicies) { continue }
|
||||
|
||||
$pairs += [PSCustomObject]@{
|
||||
Name = $objName
|
||||
Id = $srcObj.ID
|
||||
PolicyType = $policyType
|
||||
SaveFolder = $folderSrc
|
||||
Policy1 = $srcObj
|
||||
Policy2 = $cmpObj
|
||||
}
|
||||
}
|
||||
|
||||
foreach($cmpObj in $cmpObjs)
|
||||
{
|
||||
if($this.SkipMissingSourcePolicies) { continue }
|
||||
if($cmpObj.ID -in $addedIds) { continue }
|
||||
$objName = $cmpObj.Name
|
||||
if($this.NameFilter -and $objName -notmatch [RegEx]::Escape($this.NameFilter)) { continue }
|
||||
|
||||
$pairs += [PSCustomObject]@{
|
||||
Name = $objName
|
||||
Id = $cmpObj.ID
|
||||
PolicyType = $policyType
|
||||
SaveFolder = $folderSrc
|
||||
Policy1 = $null
|
||||
Policy2 = $cmpObj
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $pairs
|
||||
}
|
||||
|
||||
[void] SaveSettings()
|
||||
{
|
||||
# No persisted options: the SourcePath / ComparePath writes that used to live
|
||||
# here were never read back anywhere (dead since the port).
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
#ImportOrder 31
|
||||
|
||||
class CompareOutputProviderBase
|
||||
{
|
||||
[string] $Name
|
||||
[string] $Value
|
||||
[string] $Extension
|
||||
|
||||
CompareOutputProviderBase([string]$name, [string]$value, [string]$extension)
|
||||
{
|
||||
$this.Name = $name
|
||||
$this.Value = $value
|
||||
$this.Extension = $extension
|
||||
}
|
||||
|
||||
[string] FormatRows([object[]]$rows, [string[]]$props) { return "" }
|
||||
[string] ToString() { return $this.Name }
|
||||
}
|
||||
|
||||
class CompareCSVOutputProvider : CompareOutputProviderBase
|
||||
{
|
||||
[string] $Delimiter = ";"
|
||||
|
||||
CompareCSVOutputProvider() : base("CSV", "csv", "csv")
|
||||
{
|
||||
$this.Delimiter = ";"
|
||||
}
|
||||
|
||||
[string] FormatRows([object[]]$rows, [string[]]$props)
|
||||
{
|
||||
$selected = @($rows | Select-Object -Property $props)
|
||||
if($this.Delimiter -and $this.Delimiter.Length -eq 1)
|
||||
{
|
||||
return ($selected | ConvertTo-Csv -NoTypeInformation -Delimiter ([char]$this.Delimiter)) -join [System.Environment]::NewLine
|
||||
}
|
||||
return ($selected | ConvertTo-Csv -NoTypeInformation) -join [System.Environment]::NewLine
|
||||
}
|
||||
}
|
||||
|
||||
class CompareJsonOutputProvider : CompareOutputProviderBase
|
||||
{
|
||||
CompareJsonOutputProvider() : base("JSON", "json", "json") {}
|
||||
|
||||
[string] FormatRows([object[]]$rows, [string[]]$props)
|
||||
{
|
||||
return $rows | Select-Object -Property $props | ConvertTo-Json -Depth 20
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
#ImportOrder 29
|
||||
|
||||
# Self-registration registry for the compare subsystem, mirroring
|
||||
# [DocumentationRegistry]. Replaces the hardcoded $script:compare* arrays that
|
||||
# Initialize-CompareModule used to build - which only the WPF AppInitialized
|
||||
# handler ever ran, so in Avalonia mode the compare combos came up empty.
|
||||
# Providers, output providers, and comparison types now register once at module
|
||||
# load (Internal/Compare.ps1) so BOTH UI backends see the same catalog, and the
|
||||
# documentation subsystem self-registers its own "doc" comparison type instead
|
||||
# of Compare.ps1 reaching across with a Get-Command probe.
|
||||
#
|
||||
# Loaded at #ImportOrder 29 - before CompareClasses.ps1 (30) and
|
||||
# CompareOutputClasses.ps1 (31) - so the provider/output classes it stores are
|
||||
# already defined by the time Internal/Compare.ps1 registers instances.
|
||||
#
|
||||
# Dedupe is by .Value (a provider/output/type key), matching the
|
||||
# DocumentationRegistry replace-by-identity semantics so Import-Module -Force
|
||||
# re-registration never accumulates duplicates.
|
||||
class CompareRegistry {
|
||||
static [System.Collections.Generic.List[object]] $Providers = [System.Collections.Generic.List[object]]::new()
|
||||
static [System.Collections.Generic.List[object]] $OutputProviders = [System.Collections.Generic.List[object]]::new()
|
||||
static [System.Collections.Generic.List[PSCustomObject]] $ComparisonTypes = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
|
||||
# ---- Compare providers (CompareProviderBase subclasses) ----
|
||||
static [void] RegisterProvider([object]$Provider) {
|
||||
if (-not $Provider.Value) { throw "Compare provider must have a Value" }
|
||||
$existing = [CompareRegistry]::Providers | Where-Object { $_.Value -eq $Provider.Value }
|
||||
if ($existing) { [CompareRegistry]::Providers.Remove($existing) | Out-Null }
|
||||
[CompareRegistry]::Providers.Add($Provider)
|
||||
}
|
||||
|
||||
static [object] FindProvider([string]$Value) {
|
||||
return [CompareRegistry]::Providers | Where-Object { $_.Value -eq $Value } | Select-Object -First 1
|
||||
}
|
||||
|
||||
# ---- Output providers (CompareOutputProviderBase subclasses) ----
|
||||
static [void] RegisterOutputProvider([object]$Provider) {
|
||||
if (-not $Provider.Value) { throw "Compare output provider must have a Value" }
|
||||
$existing = [CompareRegistry]::OutputProviders | Where-Object { $_.Value -eq $Provider.Value }
|
||||
if ($existing) { [CompareRegistry]::OutputProviders.Remove($existing) | Out-Null }
|
||||
[CompareRegistry]::OutputProviders.Add($Provider)
|
||||
}
|
||||
|
||||
static [object] FindOutputProvider([string]$Value) {
|
||||
return [CompareRegistry]::OutputProviders | Where-Object { $_.Value -eq $Value } | Select-Object -First 1
|
||||
}
|
||||
|
||||
static [object] FindOutputProviderByExtension([string]$Extension) {
|
||||
return [CompareRegistry]::OutputProviders | Where-Object { $_.Extension -eq $Extension } | Select-Object -First 1
|
||||
}
|
||||
|
||||
# ---- Comparison types (PSCustomObject: Name, Value, [Compare], [RemoveProperties]) ----
|
||||
static [void] RegisterComparisonType([PSCustomObject]$Type) {
|
||||
if (-not $Type.Value) { throw "Comparison type must have a Value" }
|
||||
$existing = [CompareRegistry]::ComparisonTypes | Where-Object { $_.Value -eq $Type.Value }
|
||||
if ($existing) { [CompareRegistry]::ComparisonTypes.Remove($existing) | Out-Null }
|
||||
[CompareRegistry]::ComparisonTypes.Add($Type)
|
||||
}
|
||||
|
||||
static [PSCustomObject] FindComparisonType([string]$Value) {
|
||||
return [CompareRegistry]::ComparisonTypes | Where-Object { $_.Value -eq $Value } | Select-Object -First 1
|
||||
}
|
||||
|
||||
# Clear every collection. Called once at the top of the module-load
|
||||
# registration in Internal/Compare.ps1 so Import-Module -Force starts clean
|
||||
# (the static initializers above only run on first type load; the type is
|
||||
# cached across -Force reimports).
|
||||
static [void] Reset() {
|
||||
[CompareRegistry]::Providers.Clear()
|
||||
[CompareRegistry]::OutputProviders.Clear()
|
||||
[CompareRegistry]::ComparisonTypes.Clear()
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
#ImportOrder 1
|
||||
class ViewObjectBase
|
||||
{
|
||||
Hidden [String]$_ID = $null
|
||||
Hidden [String]$_Title = $null
|
||||
Hidden [String]$_Description = $null
|
||||
Hidden [String]$_AuthenticaionObject = $null
|
||||
Hidden [Boolean]$_HideMenu = $false
|
||||
Hidden [Object]$_ViewPanel = $null
|
||||
Hidden [Guid]$_SessionId = [Guid]::Empty
|
||||
Hidden [Boolean]$_AddToMenu = $true
|
||||
# When true, the top-bar Views menu surfaces this view's items as a
|
||||
# submenu beneath the view entry — clicking a child both activates the
|
||||
# parent view and selects that item in the left nav. Default off because
|
||||
# most views (IntuneManagement with ~50 policy types) would balloon the
|
||||
# Views menu past usability.
|
||||
Hidden [Boolean]$_ExpandInViewsMenu = $false
|
||||
|
||||
ViewObjectBase()
|
||||
{
|
||||
if($this.GetType().Name -eq "ViewObjectBase") {
|
||||
throw "Abstract class. Object cannot be created"
|
||||
}
|
||||
elseif($script:SingletonObjects.ContainsKey($this.GetType().Name) -eq $true) {
|
||||
throw "Only one $($this.GetType().Name) object can be created"
|
||||
}
|
||||
|
||||
Add-SingletonObject $this.GetType().Name $this
|
||||
|
||||
([ViewObjectBase]$this).Init()
|
||||
}
|
||||
|
||||
# Hidden Functions
|
||||
Hidden Init()
|
||||
{
|
||||
$this._SessionId = $script:SessionID
|
||||
Add-ObjectProperty $this "ID" { $this._ID }
|
||||
Add-ObjectProperty $this "Title" { $this._Title }
|
||||
Add-ObjectProperty $this "Description" { $this._Description }
|
||||
Add-ObjectProperty $this "Authentication" { $this._AuthenticaionObject }
|
||||
Add-ObjectProperty $this "HideMenu" { $this._HideMenu }
|
||||
Add-ObjectProperty $this "ViewPanel" { $this.GetViewPanel() }
|
||||
Add-ObjectProperty $this "AddToMenu" { $this._AddToMenu }
|
||||
Add-ObjectProperty $this "ExpandInViewsMenu" { $this._ExpandInViewsMenu }
|
||||
}
|
||||
|
||||
[Object[]]GetViewItems()
|
||||
{
|
||||
return $null
|
||||
}
|
||||
|
||||
[Object]GetViewPanel()
|
||||
{
|
||||
return $null
|
||||
}
|
||||
|
||||
Authenticate()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
[Object[]]OnItemChanged($SelectedItem)
|
||||
{
|
||||
return $null
|
||||
}
|
||||
|
||||
OnDeactivating($NewActiveView)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
OnActivating($PreviousActiveView)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
OnActivated()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
class ViewItemBase
|
||||
{
|
||||
[String]$Id = ""
|
||||
[String]$Name = ""
|
||||
[String]$Icon = $null
|
||||
|
||||
ViewItemBase()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
#ImportOrder 10
|
||||
function Add-NativeClass
|
||||
{
|
||||
param(
|
||||
[string]
|
||||
$ClassName,
|
||||
[string]
|
||||
$ClassDefinition,
|
||||
[String[]]
|
||||
$AssembliesPartialName
|
||||
)
|
||||
# `-as [type]` yields a Type object or $null - never $true. Comparing a Type to
|
||||
# $true coerces the right side to Type, which never matches, so this guard used
|
||||
# to be dead: Add-Type ran on every re-import, the CLR rejected the
|
||||
# already-loaded type, and the catch below logged "Failed to add type" every
|
||||
# time. Harmless but it made a clean re-import look broken.
|
||||
if ($ClassName -as [type]) { return }
|
||||
|
||||
[Reflection.Assembly]::LoadWithPartialName("System.ComponentModel") | Out-Null
|
||||
foreach($Assembly in $AssembliesPartialName) {
|
||||
[Reflection.Assembly]::LoadWithPartialName($Assembly) | Out-Null
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Log "Add class $ClassName"
|
||||
Add-Type -TypeDefinition $ClassDefinition -IgnoreWarnings -ErrorAction Stop #-ReferencedAssemblies @('System.ComponentModel')
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to add type $($ClassName)" $_.Exception
|
||||
Write-LogDebug "Definition:`n$ClassDefinition"
|
||||
}
|
||||
}
|
||||
|
||||
$classDef = @"
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
|
||||
public class ObjectColumnInfo : System.ComponentModel.INotifyPropertyChanged
|
||||
{
|
||||
public string Property { get { return _property; } set { _property = value; NotifyPropertyChanged("Property"); } }
|
||||
private string _property = null;
|
||||
|
||||
public string Header { get { return _header; } set { _header = value; NotifyPropertyChanged("Header"); } }
|
||||
private string _header = null;
|
||||
|
||||
public ObjectColumnInfo(string Property, string Header)
|
||||
{
|
||||
_property = Property;
|
||||
_header = Header;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
if(!String.IsNullOrEmpty(_header)) { return _header; }
|
||||
return _property ?? String.Empty;
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler PropertyChanged;
|
||||
|
||||
// This method is called by the Set accessor of each property.
|
||||
// The CallerMemberName attribute that is applied to the optional propertyName
|
||||
// parameter causes the property name of the caller to be substituted as an argument.
|
||||
private void NotifyPropertyChanged(string propertyName = "")
|
||||
{
|
||||
if(PropertyChanged != null) { PropertyChanged.Invoke(this, new PropertyChangedEventArgs(propertyName)); }
|
||||
}
|
||||
}
|
||||
|
||||
"@
|
||||
|
||||
Add-NativeClass -ClassName "ObjectColumnInfo" -ClassDefinition $classDef
|
||||
|
||||
$classDef = @"
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
|
||||
public class NameValueObject : System.ComponentModel.INotifyPropertyChanged
|
||||
{
|
||||
public string Name { get { return _name; } set { _name = value; NotifyPropertyChanged("Name"); } }
|
||||
private string _name = null;
|
||||
|
||||
public string Value { get { return _value; } set { _value = value; NotifyPropertyChanged("Value"); } }
|
||||
private string _value = null;
|
||||
|
||||
public NameValueObject(string Name, string Value)
|
||||
{
|
||||
_name = Name;
|
||||
_value = Value;
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler PropertyChanged;
|
||||
|
||||
// This method is called by the Set accessor of each property.
|
||||
// The CallerMemberName attribute that is applied to the optional propertyName
|
||||
// parameter causes the property name of the caller to be substituted as an argument.
|
||||
private void NotifyPropertyChanged(string propertyName = "")
|
||||
{
|
||||
if(PropertyChanged != null) { PropertyChanged.Invoke(this, new PropertyChangedEventArgs(propertyName)); }
|
||||
}
|
||||
}
|
||||
|
||||
"@
|
||||
|
||||
Add-NativeClass -ClassName "NameValueObject" -ClassDefinition $classDef -AssembliesPartialName "System.ComponentModel"
|
||||
@@ -1,220 +0,0 @@
|
||||
#ImportOrder 220
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Entra Enrollment Group
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||
class EntraGroup : IntunePolicyGroupBase
|
||||
{
|
||||
EntraGroup() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._ID = "Entra"
|
||||
$this._Name = "Entra"
|
||||
$this._Icon = "Entra"
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Entra Branding
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
# region Entra Branding
|
||||
class EntraBrandingType : IntunePolicyTypeBase
|
||||
{
|
||||
EntraBrandingType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "EntraGroup")
|
||||
$this._PolicyName = "Entra Branding"
|
||||
$this._ID = "AzureBranding"
|
||||
$this._HasPlatform = $false
|
||||
$this._HasModified = $false
|
||||
# Endpoint rejects a name $filter (verified live 2026-08-27) - searches filter client-side.
|
||||
$this._SupportsNameFilter = $false
|
||||
$this._API = "organization/%OrganizationId%/branding/localizations"
|
||||
$this._Permissions = @("Organization.ReadWrite.All")
|
||||
$this._NameProperty = "Id"
|
||||
$this._Icon = "Branding"
|
||||
#$this._ShowButtons = @("Export","View")
|
||||
$this._ExpandAssignments = $false
|
||||
$this._ExpandAssignmentsList = $false
|
||||
$this._SupportsAssignments = $false
|
||||
$this._SkipAddIDOnFileName = $true
|
||||
$this._TopItems = 0
|
||||
# Graph rejects `?$top=...` on /organization/<tid>/branding/localizations
|
||||
# with HTTP 400. Mirrors `SupportsPageSize=$false` in the OLD project's
|
||||
# AzureBranding type definition; without it, bulk export's default of
|
||||
# `$top=1000` makes the listing call fail and the type silently produces
|
||||
# zero files.
|
||||
$this._HasPageSizeSupport = $false
|
||||
$this._ObjectClass = "EntraBrandingObject"
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
|
||||
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
$ret = @{}
|
||||
|
||||
# ToDo: Verify functionallity for import
|
||||
|
||||
Remove-Property $PolicyObject.JsonObject "@odata.Type"
|
||||
|
||||
if($PolicyObject.JsonObject.Id -eq "0")
|
||||
{
|
||||
$ret.Add("Method","PATCH") # Default profile always exists so update it
|
||||
$ret.Add("API", "organization/%OrganizationId%/branding")
|
||||
}
|
||||
# This is NOT what the documentation says
|
||||
# Documentation says to use Content-Language
|
||||
# Only place the documentation states to use Accept-Language is for Get operation
|
||||
# https://docs.microsoft.com/en-us/graph/api/organizationalbrandingproperties-get?view=graph-rest-beta&tabs=http#request-headers
|
||||
$ret.Add("AdditionalHeaders", @{ "Accept-Language" = $PolicyObject.JsonObject.Id })
|
||||
|
||||
return $ret
|
||||
}
|
||||
}
|
||||
|
||||
Class EntraBrandingObject : IntunePolicyBase
|
||||
{
|
||||
|
||||
Hidden [String]$_Language = $null
|
||||
|
||||
EntraBrandingObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
EntraBrandingObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
if($this.Object.id -eq "0")
|
||||
{
|
||||
$this._Language = "Default"
|
||||
}
|
||||
elseif($this.Object.id)
|
||||
{
|
||||
$this._Language = ([cultureinfo]::GetCultureInfo($this.Object.id)).DisplayName
|
||||
}
|
||||
Add-ObjectProperty $this "Language" { $this._Language }
|
||||
|
||||
$this._PolicyType = (Get-SingletonObject "EntraBrandingType")
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Terms and Condition
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
# region Terms and Condition
|
||||
class TermsAndConditionType : IntunePolicyTypeBase
|
||||
{
|
||||
TermsAndConditionType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "EntraGroup")
|
||||
$this._APITitle = "Terms and Conditions"
|
||||
$this._PolicyName = "Terms and Condition"
|
||||
$this._ID = "TermsAndConditions"
|
||||
$this._HasPlatform = $false
|
||||
$this._API = "deviceManagement/termsAndConditions"
|
||||
$this._Permissions = @("DeviceManagementServiceConfig.ReadWrite.All")
|
||||
$this._ExpandAssignments = $false
|
||||
$this._ExpandAssignmentsList = $false
|
||||
$this._ObjectClass = "TermsAndConditionObject"
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
|
||||
[Hashtable]PreImportAssignmentsCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
|
||||
{
|
||||
return (Add-GraphAssignmentsToObject $PolicyObject $SourceObject)
|
||||
}
|
||||
|
||||
PostExportCommand([PSCustomObject]$PolicyObject, [String]$PathToFile)
|
||||
{
|
||||
# Hydration already fanned out /assignments via the
|
||||
# _HasSubResourceBatch contract on TermsAndConditionObject — skip
|
||||
# the per-policy round-trip the helper would otherwise make.
|
||||
if($script:_skipDirectGet -eq $true) { return }
|
||||
Add-GraphAssignmentsToExportFile $PolicyObject $PathToFile
|
||||
}
|
||||
}
|
||||
|
||||
Class TermsAndConditionObject : IntunePolicyBase
|
||||
{
|
||||
TermsAndConditionObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
TermsAndConditionObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PolicyType = (Get-SingletonObject "TermsAndConditionType")
|
||||
|
||||
# Opt into the bulk-export sub-resource batching contract so the
|
||||
# /assignments side-channel gets fanned out via $batch instead of
|
||||
# one synchronous round-trip per policy in PostExportCommand.
|
||||
$this._HasSubResourceBatch = $true
|
||||
}
|
||||
|
||||
[PSCustomObject[]] GetSubResourceBatchRequests([int]$Phase)
|
||||
{
|
||||
if($Phase -ne 1) { return @() }
|
||||
return @([PSCustomObject]@{
|
||||
Key = 'assignments'
|
||||
Url = "$($this._PolicyType.API)/$($this.Id)/assignments"
|
||||
})
|
||||
}
|
||||
|
||||
[PSCustomObject[]] ApplySubResourceBatchResult([int]$Phase, [string]$Key, $Body)
|
||||
{
|
||||
if($Phase -eq 1 -and $Key -eq 'assignments') {
|
||||
# Comma-prefix forces an array reference through the if-expression —
|
||||
# without it, PowerShell unwraps a single-element @() on assignment
|
||||
# and ConvertTo-Json then emits a bare object instead of [{...}].
|
||||
$assignments = if($Body -and $Body.value) { ,@($Body.value) } else { ,@() }
|
||||
Add-Member -InputObject $this.JsonObject -MemberType NoteProperty -Name 'assignments' -Value $assignments -Force
|
||||
}
|
||||
return @()
|
||||
}
|
||||
}
|
||||
|
||||
# AppleEnrollmentTypeObject lives in IntuneAppleClasses.ps1 — was duplicated here,
|
||||
# which both confused PowerShell's parser ("The member 'AppleEnrollmentTypeObject'
|
||||
# is already defined" when the class files are concatenated for static analysis)
|
||||
# and risked a non-deterministic resolution depending on which file's definition
|
||||
# the runtime committed last. Single source of truth in IntuneAppleClasses.ps1
|
||||
# (loaded via the same ImportOrder = 220) is sufficient.
|
||||
|
||||
#endregion
|
||||
@@ -1,40 +0,0 @@
|
||||
#ImportOrder 24
|
||||
|
||||
# Canonical, provider-agnostic authentication token descriptor.
|
||||
#
|
||||
# One typed shape used everywhere a token/identity is surfaced: the auth-event
|
||||
# payloads (AuthenticatedNewToken / AuthenticationTokenRefresh /
|
||||
# AuthenticationUserDisconnected), every provider's GetUserInfo() return value,
|
||||
# the central token registry in Internal/AuthenticationCore.ps1, and the public
|
||||
# Get-IMAuthToken function.
|
||||
#
|
||||
# Providers populate everything they can see from the token itself (Provider,
|
||||
# TenantId, TenantName, Cloud, Account/UPN/UserId, AppId/AppName, AuthType,
|
||||
# ExpiresOn). The registry overlays the two fields a provider cannot know on its
|
||||
# own: the GLOBAL TokenId (registry-allocated, unique across providers) and
|
||||
# IsDefault (derived from the registry's single default-id).
|
||||
#
|
||||
# ImportOrder 24: must parse before AuthenticationProvider.ps1 (#ImportOrder 25),
|
||||
# whose GetUserInfo signature returns [IMAuthToken], and the concrete providers
|
||||
# (26/27). Core primitives load at 1/10, so 24 is free and safely after them.
|
||||
|
||||
class IMAuthToken {
|
||||
[int] $TokenId # GLOBAL id (registry-allocated); 0 = unassigned
|
||||
[string] $Provider # owning provider Id: "MSAL" | "OAuth" | "MgGraph"
|
||||
[string] $TenantId
|
||||
[string] $TenantName
|
||||
[string] $Cloud # "Public" | "USGov" | "USGovDOD" | "China"
|
||||
[string] $Account # display name (UPN, or app name for app-only)
|
||||
[string] $UPN
|
||||
[string] $UserId
|
||||
[string] $AppId
|
||||
[string] $AppName
|
||||
[string] $AuthType # Interactive | ClientCredential | BYO | ManagedIdentity | WorkloadFederation | Password
|
||||
[bool] $IsDefault
|
||||
[Nullable[datetime]] $ExpiresOn
|
||||
|
||||
[string] ToString() {
|
||||
$tenant = if ($this.TenantName) { $this.TenantName } elseif ($this.TenantId) { $this.TenantId } else { '?' }
|
||||
return "$($this.Provider)/$tenant ($($this.TokenId))"
|
||||
}
|
||||
}
|
||||
@@ -1,455 +0,0 @@
|
||||
#ImportOrder 220
|
||||
|
||||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||
class AppConfigurationGroup : IntunePolicyGroupBase
|
||||
{
|
||||
AppConfigurationGroup() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._ID = "AppConfiguration"
|
||||
$this._Name = "App configuration policies"
|
||||
$this._Icon = "AppConfiguration"
|
||||
$this._ExtraColumns = @("EnrolmentType=Enrolment type")
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# App Configuration (App)
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
# region App Configuration (App)
|
||||
class AppConfigurationManagedAppType : IntunePolicyTypeBase
|
||||
{
|
||||
AppConfigurationManagedAppType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "AppConfigurationGroup")
|
||||
$this._PolicyName = "App configuration (App)"
|
||||
$this._ID = "AppConfigurationManagedApp"
|
||||
# Endpoint rejects a name $filter (verified live 2026-08-27) - searches filter client-side.
|
||||
$this._SupportsNameFilter = $false
|
||||
$this._API = "deviceAppManagement/targetedManagedAppConfigurations"
|
||||
$this._Permissions = @("DeviceManagementApps.ReadWrite.All")
|
||||
$this._Dependencies = @("Applications")
|
||||
$this._ObjectClass = "AppConfigurationManagedAppObject"
|
||||
$this._ExpandAssignmentsList = $false
|
||||
# CheckPolicy authoritatively matches this type by @odata.type (plus the base
|
||||
# @odata.id fallback), so a rejection is real - skip the folder-trust fallback.
|
||||
$this._StrictODataTypeCheck = $true
|
||||
$this._Icon = "AppConfiguration"
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
|
||||
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
# targetedManagedAppConfiguration is surfaced by the polymorphic
|
||||
# managedAppPolicies collection alongside App Protection variants, so match it
|
||||
# explicitly by @odata.type. This also lets a file object (which carries only
|
||||
# @odata.type, no top-level @odata.id) resolve to this type; the base
|
||||
# @odata.id-based CheckPolicy would reject it.
|
||||
if($PolicyObject.'@odata.type' -eq '#microsoft.graph.targetedManagedAppConfiguration')
|
||||
{
|
||||
return $true
|
||||
}
|
||||
|
||||
# Fall back to the base @odata.id matcher (deviceAppManagement/targetedManagedAppConfigurations)
|
||||
# for objects that carry an id but no top-level @odata.type.
|
||||
return ([IntunePolicyTypeBase]$this).CheckPolicy($PolicyObject)
|
||||
}
|
||||
|
||||
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
# apps is a navigation property set after create via targetApps
|
||||
# (PostImportCommand); strip it, then POST to the type API
|
||||
# (deviceAppManagement/targetedManagedAppConfigurations).
|
||||
Remove-Property $PolicyObject.JsonObject "apps"
|
||||
Remove-Property $PolicyObject.JsonObject "apps@odata.context"
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
PostImportCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
|
||||
{
|
||||
if($SourceObject.Object.Apps) {
|
||||
# No "@odata.type" on the created object so reload new object
|
||||
#$newObject = (Invoke-MSGraphAPI "$($objectType.API)?`$filter=id eq '$($obj.Id)'").Value
|
||||
$newObject = Invoke-MSGraphAPI -Url "$($this.API)/$($PolicyObject.Id)" -TokenId $PolicyObject._TokenID
|
||||
if($newObject)
|
||||
{
|
||||
try
|
||||
{
|
||||
$apps = [PSCustomObject]@{
|
||||
appGroupType = $PolicyObject.Object.appGroupType
|
||||
apps = @($SourceObject.Object.Apps)
|
||||
}
|
||||
$json = $apps | ConvertTo-Json -Depth 20
|
||||
|
||||
Invoke-MSGraphAPI -Url "$($this.API)/$($PolicyObject.Id)/targetApps" -Content $json -HttpMethod POST -TokenId $PolicyObject._TokenID | Out-Null
|
||||
}
|
||||
catch {
|
||||
# The policy was created; only the targetApps association failed. Keep
|
||||
# going but make the partial import visible instead of swallowing it.
|
||||
Write-LogError "Failed to assign target apps to imported App configuration policy '$($PolicyObject.displayName)' ($($PolicyObject.Id)) via $($this.API)/$($PolicyObject.Id)/targetApps" $_.Exception
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Hashtable]PreUpdateCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$ExistingObject)
|
||||
{
|
||||
# apps is a navigation property set via targetApps, not inline. Re-post
|
||||
# apps to the type API, strip them from the PATCH body, then PATCH the
|
||||
# type API (deviceAppManagement/targetedManagedAppConfigurations).
|
||||
if($PolicyObject.JsonObject.apps)
|
||||
{
|
||||
try
|
||||
{
|
||||
$apps = [PSCustomObject]@{
|
||||
appGroupType = $PolicyObject.JsonObject.appGroupType
|
||||
apps = @($PolicyObject.JsonObject.apps)
|
||||
}
|
||||
$json = $apps | ConvertTo-Json -Depth 20
|
||||
Invoke-MSGraphAPI -Url "$($this.API)/$($ExistingObject.Id)/targetApps" -Content $json -HttpMethod POST -TokenId $ExistingObject._TokenID | Out-Null
|
||||
}
|
||||
catch {
|
||||
# The policy PATCH still proceeds; surface the failed targetApps update.
|
||||
Write-LogError "Failed to update target apps for App configuration policy '$($ExistingObject.displayName)' ($($ExistingObject.Id)) via $($this.API)/$($ExistingObject.Id)/targetApps" $_.Exception
|
||||
}
|
||||
}
|
||||
Remove-Property $PolicyObject.JsonObject "apps"
|
||||
Remove-Property $PolicyObject.JsonObject "apps@odata.context"
|
||||
# assignments is a navigation property (managed via the /assign action),
|
||||
# not inline-PATCHable.
|
||||
Remove-Property $PolicyObject.JsonObject "assignments"
|
||||
Remove-Property $PolicyObject.JsonObject "assignments@odata.context"
|
||||
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
Class AppConfigurationManagedAppObject : IntunePolicyBase
|
||||
{
|
||||
Hidden [string]$_objectClass = $null
|
||||
|
||||
AppConfigurationManagedAppObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
AppConfigurationManagedAppObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PolicyType = (Get-SingletonObject "AppConfigurationManagedAppType")
|
||||
|
||||
Add-ObjectProperty $this "EnrolmentType" { "Managed apps" }
|
||||
|
||||
Get-AppConfigurationClass $this
|
||||
|
||||
# Targeted-app resolution runs through the sub-resource contract; see the
|
||||
# Get-/Add-/Build-AppConfigTargetApp* helpers below.
|
||||
$this._HasSubResourceBatch = $true
|
||||
}
|
||||
|
||||
[PSCustomObject[]] GetSubResourceBatchRequests([int]$Phase)
|
||||
{
|
||||
if($Phase -ne 1) { return @() }
|
||||
return (Get-AppConfigTargetAppRequests $this)
|
||||
}
|
||||
|
||||
[PSCustomObject[]] ApplySubResourceBatchResult([int]$Phase, [string]$Key, $Body)
|
||||
{
|
||||
if($Phase -eq 1) { Add-AppConfigTargetAppResult $Key $Body }
|
||||
return @()
|
||||
}
|
||||
|
||||
[void] FinalizeSubResources()
|
||||
{
|
||||
Build-AppConfigTargetAppRefs $this
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# App Configuration (Device)
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
# region App Configuration (Device)
|
||||
class AppConfigurationManagedDeviceType : IntunePolicyTypeBase
|
||||
{
|
||||
AppConfigurationManagedDeviceType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "AppConfigurationGroup")
|
||||
$this._PolicyName = "App configuration (Device)"
|
||||
$this._ID = "AppConfigurationManagedDevice"
|
||||
$this._API = "deviceAppManagement/mobileAppConfigurations"
|
||||
$this._QueryList = "?`$filter=microsoft.graph.androidManagedStoreAppConfiguration/appSupportsOemConfig%20eq%20false%20or%20isof(%27microsoft.graph.androidManagedStoreAppConfiguration%27)%20eq%20false"
|
||||
$this._Permissions = @("DeviceManagementApps.ReadWrite.All")
|
||||
$this._Dependencies = @("Applications")
|
||||
$this._ObjectClass = "AppConfigurationManagedDeviceObject"
|
||||
$this._ExpandAssignmentsList = $false
|
||||
$this._Icon = "AppConfiguration"
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
|
||||
[Hashtable]PreImportAssignmentsCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
|
||||
{
|
||||
return (@{"API"="deviceAppManagement/mobileAppConfigurations/$($PolicyObject.Id)/microsoft.graph.managedDeviceMobileAppConfiguration/assign"})
|
||||
}
|
||||
|
||||
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
Import-AppConfigurationTargetedApps $PolicyObject
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
Class AppConfigurationManagedDeviceObject : IntunePolicyBase
|
||||
{
|
||||
AppConfigurationManagedDeviceObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
AppConfigurationManagedDeviceObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PolicyType = (Get-SingletonObject "AppConfigurationManagedDeviceType")
|
||||
|
||||
Add-ObjectProperty $this "EnrolmentType" { "Managed devices" }
|
||||
|
||||
# Targeted-app resolution runs through the sub-resource contract; see the
|
||||
# Get-/Add-/Build-AppConfigTargetApp* helpers.
|
||||
$this._HasSubResourceBatch = $true
|
||||
}
|
||||
|
||||
[PSCustomObject[]] GetSubResourceBatchRequests([int]$Phase)
|
||||
{
|
||||
if($Phase -ne 1) { return @() }
|
||||
return (Get-AppConfigTargetAppRequests $this)
|
||||
}
|
||||
|
||||
[PSCustomObject[]] ApplySubResourceBatchResult([int]$Phase, [string]$Key, $Body)
|
||||
{
|
||||
if($Phase -eq 1) { Add-AppConfigTargetAppResult $Key $Body }
|
||||
return @()
|
||||
}
|
||||
|
||||
[void] FinalizeSubResources()
|
||||
{
|
||||
Build-AppConfigTargetAppRefs $this
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Generic Functions
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
function Get-AppConfigurationClass
|
||||
{
|
||||
param($Policy)
|
||||
|
||||
try {
|
||||
$tmp = $Policy.Object."@odata.type".Split('.')[-1]
|
||||
$Policy._objectClass = Get-GraphObjectClassName $tmp
|
||||
}
|
||||
catch { }
|
||||
|
||||
if($null -eq $Policy._objectClass) {
|
||||
Write-Log "Could not get class name for $($Policy.Name) ($($Policy.Object."@odata.type"))" 3
|
||||
}
|
||||
}
|
||||
|
||||
function Get-AppConfigurationFullObject
|
||||
{
|
||||
param($Policy)
|
||||
|
||||
if(-not $Policy.Object."@odata.type" -or -not $Policy._objectClass) { return $false }
|
||||
|
||||
$expand = $null
|
||||
if($Policy._objectClass -eq "windowsInformationProtectionPolicies")
|
||||
{
|
||||
$expand = "?`$expand=protectedAppLockerFiles,exemptAppLockerFiles"
|
||||
}
|
||||
else {
|
||||
$url = $Policy.GetObjectURL()
|
||||
|
||||
$tmpArr = $url.Split("?")
|
||||
if($tmpArr.Length -gt 1) {
|
||||
$expand = "?" + $tmpArr[1]
|
||||
}
|
||||
}
|
||||
|
||||
$fullObject = (Invoke-MSGraphAPI -Url "deviceAppManagement/$($Policy._objectClass)/$($Policy.Id)$expand" -TokenId $Policy._TokenId)
|
||||
if($fullObject)
|
||||
{
|
||||
$Policy.JsonObject = $fullObject
|
||||
$Policy._IsFullObject = $true
|
||||
|
||||
return $true
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
# Targeted-app resolution for AppConfiguration policies.
|
||||
#
|
||||
# The policy body lists app IDs in targetedMobileApps; cross-tenant export needs
|
||||
# each app's displayName + @odata.type to re-map them on import into another
|
||||
# tenant (#CustomRefTargetedApps). The mobileApps/<id> lookup is owned ONLY by
|
||||
# Get-AppConfigTargetAppRequests below — the sub-resource contract on the
|
||||
# AppConfiguration*Object classes drives the fetch (coalesced across policies by
|
||||
# Invoke-PolicySubresourceFetch's URL de-dup), caches results, then builds the
|
||||
# ref string. Previously this was duplicated in Sync-BulkExportAppConfigurationTargetApps
|
||||
# (Internal/PolicyHydrateExtras.ps1).
|
||||
|
||||
# Process-wide cache: appId -> app body (or $null for a 404/miss). Shared across
|
||||
# every AppConfig policy in a hydrate run so the same app is fetched once.
|
||||
function Get-AppConfigTargetAppCache
|
||||
{
|
||||
if($null -eq $script:_appConfigTargetAppCache) { $script:_appConfigTargetAppCache = @{} }
|
||||
return $script:_appConfigTargetAppCache
|
||||
}
|
||||
|
||||
# Only these polymorphic AppConfig @odata.types carry targetedMobileApps that
|
||||
# need tenant-specific remapping. (androidManagedAppProtection is listed for
|
||||
# parity with the legacy filter; App Protection policies use `apps`, not
|
||||
# `targetedMobileApps`, so they never actually match.)
|
||||
function Test-AppConfigHasTargetApps
|
||||
{
|
||||
param($Policy)
|
||||
return ($Policy.JsonObject.'@OData.Type' -in @(
|
||||
'#microsoft.graph.androidManagedAppProtection',
|
||||
'#microsoft.graph.androidForWorkMobileAppConfiguration',
|
||||
'#microsoft.graph.androidManagedStoreAppConfiguration',
|
||||
'#microsoft.graph.iosMobileAppConfiguration'
|
||||
) -and @($Policy.JsonObject.targetedMobileApps).Count -gt 0)
|
||||
}
|
||||
|
||||
# Sub-resource requests for every targeted app not already cached. The
|
||||
# deviceAppManagement/mobileApps/<id> URL lives ONLY here.
|
||||
function Get-AppConfigTargetAppRequests
|
||||
{
|
||||
param($Policy)
|
||||
if(-not (Test-AppConfigHasTargetApps $Policy)) { return @() }
|
||||
$cache = Get-AppConfigTargetAppCache
|
||||
$reqs = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
foreach($appId in @($Policy.JsonObject.targetedMobileApps)) {
|
||||
if(-not $appId -or $cache.ContainsKey($appId)) { continue }
|
||||
[void]$reqs.Add([PSCustomObject]@{
|
||||
Key = "targetApp_$appId"
|
||||
Url = "deviceAppManagement/mobileApps/$appId"
|
||||
Headers = @{ Accept = 'application/json;odata.metadata=minimal' }
|
||||
})
|
||||
}
|
||||
return $reqs.ToArray()
|
||||
}
|
||||
|
||||
# Cache one targeted-app sub-resource response. Stores $null for misses so 404s
|
||||
# aren't re-requested by a later policy in the same run.
|
||||
function Add-AppConfigTargetAppResult
|
||||
{
|
||||
param([string]$Key, $Body)
|
||||
if($Key -notlike 'targetApp_*') { return }
|
||||
$appId = $Key.Substring('targetApp_'.Length)
|
||||
(Get-AppConfigTargetAppCache)[$appId] = $Body
|
||||
}
|
||||
|
||||
# Build #CustomRefTargetedApps from the cached app bodies (finalize step).
|
||||
function Build-AppConfigTargetAppRefs
|
||||
{
|
||||
param($Policy)
|
||||
if(-not (Test-AppConfigHasTargetApps $Policy)) { return }
|
||||
$cache = Get-AppConfigTargetAppCache
|
||||
$targetedApps = @()
|
||||
foreach($appId in @($Policy.JsonObject.targetedMobileApps)) {
|
||||
$appObj = $cache[$appId]
|
||||
if($appObj) {
|
||||
Write-Log "Add target app info $($appObj.displayName) ($($appObj.Id)) of type $($appObj.'@OData.Type')"
|
||||
$targetedApps += $appObj.displayName + '|!|' + $appObj.Id + '|!|' + $appObj.'@OData.Type'
|
||||
}
|
||||
else {
|
||||
Write-Log "No app found with id $appId" 2
|
||||
}
|
||||
}
|
||||
if($targetedApps.Count -gt 0) {
|
||||
Add-Member -InputObject $Policy.JsonObject -MemberType NoteProperty -Name '#CustomRefTargetedApps' -Value ($targetedApps -join '|*|') -Force
|
||||
}
|
||||
}
|
||||
|
||||
function Import-AppConfigurationTargetedApps
|
||||
{
|
||||
param($Policy)
|
||||
|
||||
if($Policy.JsonObject."#CustomRefTargetedApps" -and $Policy.JsonObject.targetedMobileApps)
|
||||
{
|
||||
Write-Log "Adding app targets for $($Policy.JsonObject.displayName)"
|
||||
|
||||
$targetedAppsInfo = $Policy.JsonObject."#CustomRefTargetedApps"
|
||||
|
||||
$translatedTargetedApps = @()
|
||||
|
||||
if($targetedAppsInfo)
|
||||
{
|
||||
foreach($targetedApp in ($targetedAppsInfo -split "[|][*][|]"))
|
||||
{
|
||||
$appName, $appId, $appType = $targetedApp -split "[|][!][|]"
|
||||
if(-not $appName -or -not $appId)
|
||||
{
|
||||
Write-Log "App Name and Id is missing in string: $appApp" 2
|
||||
continue
|
||||
}
|
||||
$tmpApps = (Invoke-MSGraphAPI -Url "/deviceAppManagement/mobileApps?`$filter=displayName eq '$appName'" -TokenId $Policy._TokenId).value
|
||||
if(-not $tmpApps)
|
||||
{
|
||||
Write-Log "No application found with name $appName. $appId will not be translated and added to target list" 2
|
||||
continue
|
||||
}
|
||||
$tmpApp = $tmpApps | Where-Object '@OData.Type' -eq $appType
|
||||
if(-not $tmpApp)
|
||||
{
|
||||
Write-Log "No $appName application found of type $appType. $appId will not be translated and added to target list" 2
|
||||
}
|
||||
elseif(($tmpApp | Measure-Object).Count -gt 1) {
|
||||
Write-Log "$(($tmpApp | Measure-Object).Count) applications found with name '$appName' of type $appType. $appId will not be translated and added to target list" 2
|
||||
}
|
||||
else {
|
||||
Write-Log "Found '$appName' with id $($tmpApp.Id) ($appType)"
|
||||
$translatedTargetedApps += $tmpApp.Id
|
||||
}
|
||||
}
|
||||
|
||||
if($translatedTargetedApps.Count -gt 0) {
|
||||
Write-Log "Updating translated targeted apps"
|
||||
$Policy.JsonObject.targetedMobileApps = $translatedTargetedApps
|
||||
}
|
||||
else {
|
||||
Write-Log "Could not find targeted apps in the evnironment. Verify that they are added. Policy import might fail" 3
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
#ImportOrder 220
|
||||
|
||||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||
class AppProtectionGroup : IntunePolicyGroupBase
|
||||
{
|
||||
AppProtectionGroup() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._ID = "AppProtection"
|
||||
$this._Name = "App protection policies"
|
||||
$this._Icon = "AppProtection"
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# App Protection
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
# region App Protection
|
||||
class AppProtectionType : IntunePolicyTypeBase
|
||||
{
|
||||
AppProtectionType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "AppProtectionGroup")
|
||||
$this._PolicyName = "App protection policy"
|
||||
$this._ID = "AppProtection"
|
||||
$this._SubTypeColumn = "ManagementType=Management type"
|
||||
# Endpoint rejects a name $filter (verified live 2026-08-27) - searches filter client-side.
|
||||
$this._SupportsNameFilter = $false
|
||||
$this._API = "deviceAppManagement/managedAppPolicies"
|
||||
$this._Permissions = @("DeviceManagementApps.ReadWrite.All")
|
||||
$this._Dependencies = @("Applications")
|
||||
$this._ObjectClass = "AppProtectionPolicyObject"
|
||||
$this._ExpandAssignmentsList = $false
|
||||
# Assignments are fetched per platform collection - see
|
||||
# GetAssignmentsBaseURL below. _AssignmentsViaExpand stays $false:
|
||||
# once the URL targets a concrete subtype the plain navigation GET
|
||||
# works, and it returns just the assignments instead of the whole policy.
|
||||
$this._PropertiesToRemove = @('exemptAppLockerFiles')
|
||||
$this._PropertiesToRemoveForUpdate = @("protectedAppLockerFiles","version") # ToDo: !!! Add support for protectedAppLockerFiles?
|
||||
$this._VerifyObject = $true
|
||||
# CheckPolicy is a complete @odata.type matcher (the managedAppPolicies allowlist),
|
||||
# so a rejection is authoritative - do not let the folder-trust fallback rescue a
|
||||
# foreign object misplaced in this type's export folder.
|
||||
$this._StrictODataTypeCheck = $true
|
||||
$this._Icon = "AppConfiguration"
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
|
||||
# managedAppPolicies is Collection(managedAppPolicy), and managedAppPolicy
|
||||
# declares no navigation properties - so both {API}/{id}/assignments and
|
||||
# {API}/{id}?$expand=assignments return 400 ("Could not find a property named
|
||||
# 'assignments' on type 'microsoft.graph.managedAppPolicy'"). Every concrete
|
||||
# subtype inherits `assignments`, so route through the per-platform collection
|
||||
# (_objectClass, set in the object's constructor via Get-AppConfigurationClass).
|
||||
# defaultManagedAppProtection is the exception - it has no assignments at all.
|
||||
[String]GetAssignmentsBaseURL([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
if(-not $PolicyObject._objectClass) { return $this._API }
|
||||
if($PolicyObject._objectClass -eq "defaultManagedAppProtections") { return $null }
|
||||
|
||||
return "deviceAppManagement/$($PolicyObject._objectClass)"
|
||||
}
|
||||
|
||||
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
# apps is a navigation property set later via targetApps (PostImportCommand),
|
||||
# not an inline body property - strip it from the POST body.
|
||||
Remove-Property $PolicyObject.JsonObject "apps"
|
||||
Remove-Property $PolicyObject.JsonObject "apps@odata.context"
|
||||
|
||||
# The polymorphic managedAppPolicies collection (used for listing) rejects
|
||||
# POST, so import must target the per-platform collection. _objectClass is
|
||||
# the metadata-derived endpoint segment (e.g. iosManagedAppProtections),
|
||||
# set on the object at construction via Get-AppConfigurationClass.
|
||||
if($PolicyObject._objectClass)
|
||||
{
|
||||
return @{"API"="deviceAppManagement/$($PolicyObject._objectClass)"}
|
||||
}
|
||||
|
||||
return (@{})
|
||||
}
|
||||
|
||||
PostImportCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
|
||||
{
|
||||
if($SourceObject.Object.Apps) {
|
||||
# No "@odata.type" on the created object so reload new object
|
||||
#$newObject = (Invoke-MSGraphAPI "$($objectType.API)?`$filter=id eq '$($obj.Id)'").Value
|
||||
$newObject = Invoke-MSGraphAPI -Url "$($this.API)/$($PolicyObject.Id)" -TokenId $PolicyObject._TokenID
|
||||
if($newObject)
|
||||
{
|
||||
try
|
||||
{
|
||||
$apps = [PSCustomObject]@{
|
||||
appGroupType = $PolicyObject.Object.appGroupType
|
||||
apps = @($SourceObject.Object.Apps)
|
||||
}
|
||||
$json = $apps | ConvertTo-Json -Depth 20
|
||||
|
||||
# Created object carries no @odata.type; use the source object's
|
||||
# metadata-derived endpoint segment (_objectClass).
|
||||
if($SourceObject._objectClass)
|
||||
{
|
||||
Invoke-MSGraphAPI -Url "deviceAppManagement/$($SourceObject._objectClass)/$($PolicyObject.Id)/targetApps" -Content $json -HttpMethod POST -TokenId $PolicyObject._TokenID | Out-Null
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# The policy was created; only the targetApps association failed. Keep
|
||||
# going but make the partial import visible instead of swallowing it.
|
||||
Write-LogError "Failed to assign target apps to imported App protection policy '$($PolicyObject.displayName)' ($($PolicyObject.Id)) via deviceAppManagement/$($SourceObject._objectClass)/$($PolicyObject.Id)/targetApps" $_.Exception
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Hashtable]PreImportAssignmentsCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
|
||||
{
|
||||
# /assign is bound to the concrete platform subtype; the polymorphic
|
||||
# managedAppPolicies collection returns 400 for the assign action.
|
||||
if($SourceObject._objectClass)
|
||||
{
|
||||
return @{"API"="deviceAppManagement/$($SourceObject._objectClass)/$($PolicyObject.Id)/assign"}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
[Hashtable]PreUpdateCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$ExistingObject)
|
||||
{
|
||||
# managedAppPolicies rejects PATCH, and apps is a navigation property set
|
||||
# via targetApps rather than inline. Re-post apps to the per-platform
|
||||
# endpoint, strip them from the PATCH body, then PATCH that endpoint.
|
||||
#
|
||||
# Routing note: an imported object's POST response carries no
|
||||
# @odata.type, so ITS _objectClass can be null - the update object came
|
||||
# from a file that always has the type, so prefer that one.
|
||||
if(-not $ExistingObject._objectClass -and $PolicyObject._objectClass)
|
||||
{
|
||||
$ExistingObject._objectClass = $PolicyObject._objectClass
|
||||
}
|
||||
|
||||
if($PolicyObject.JsonObject.apps -and $ExistingObject._objectClass)
|
||||
{
|
||||
try
|
||||
{
|
||||
$apps = [PSCustomObject]@{
|
||||
appGroupType = $PolicyObject.JsonObject.appGroupType
|
||||
apps = @($PolicyObject.JsonObject.apps)
|
||||
}
|
||||
$json = $apps | ConvertTo-Json -Depth 20
|
||||
Invoke-MSGraphAPI -Url "deviceAppManagement/$($ExistingObject._objectClass)/$($ExistingObject.Id)/targetApps" -Content $json -HttpMethod POST -TokenId $ExistingObject._TokenID | Out-Null
|
||||
}
|
||||
catch {
|
||||
# The policy PATCH still proceeds; surface the failed targetApps update.
|
||||
Write-LogError "Failed to update target apps for App protection policy '$($ExistingObject.displayName)' ($($ExistingObject.Id)) via deviceAppManagement/$($ExistingObject._objectClass)/$($ExistingObject.Id)/targetApps" $_.Exception
|
||||
}
|
||||
}
|
||||
Remove-Property $PolicyObject.JsonObject "apps"
|
||||
Remove-Property $PolicyObject.JsonObject "apps@odata.context"
|
||||
# assignments is a navigation property (managed via the /assign action),
|
||||
# not inline-PATCHable - PATCHing it 400s on the platform entity type.
|
||||
Remove-Property $PolicyObject.JsonObject "assignments"
|
||||
Remove-Property $PolicyObject.JsonObject "assignments@odata.context"
|
||||
|
||||
if($ExistingObject._objectClass)
|
||||
{
|
||||
return @{"API"="deviceAppManagement/$($ExistingObject._objectClass)/$($ExistingObject.Id)"}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
# App Protection owns the polymorphic deviceAppManagement/managedAppPolicies
|
||||
# collection: the per-platform *ManagedAppProtection variants (ios / android /
|
||||
# windows / default) plus the two Windows Information Protection policy types.
|
||||
# An explicit allowlist is required: file objects carry only @odata.type (no
|
||||
# top-level @odata.id), so this runs as the file->type discriminator. A previous
|
||||
# "accept everything except targetedManagedAppConfiguration" greedily claimed
|
||||
# unrelated policy types (Compliance, CA, etc.) when resolving from an export
|
||||
# folder. targetedManagedAppConfiguration is App Config, not App Protection
|
||||
# (see AppConfigurationManagedAppType.CheckPolicy).
|
||||
$odata = [string]$PolicyObject.'@odata.type'
|
||||
if($odata -match 'ManagedAppProtection$' -or
|
||||
$odata -eq '#microsoft.graph.mdmWindowsInformationProtectionPolicy' -or
|
||||
$odata -eq '#microsoft.graph.windowsInformationProtectionPolicy')
|
||||
{
|
||||
return $true
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
Class AppProtectionPolicyObject : IntunePolicyBase
|
||||
{
|
||||
Hidden [string]$_objectClass = $null
|
||||
Hidden [string]$_managemntType = $null
|
||||
|
||||
AppProtectionPolicyObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
AppProtectionPolicyObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PolicyType = (Get-SingletonObject "AppProtectionType")
|
||||
|
||||
if($this.Object."@odata.type" -eq "#microsoft.graph.mdmWindowsInformationProtectionPolicy") {
|
||||
$this._managemntType = "With enrollment"
|
||||
}
|
||||
elseif($this.Object."@odata.type" -eq "#microsoft.graph.windowsInformationProtectionPolicy") {
|
||||
$this._managemntType = "Without enrollment"
|
||||
}
|
||||
else {
|
||||
$this._managemntType = "All app types"
|
||||
}
|
||||
|
||||
Add-ObjectProperty $this "ManagementType" { $this._managemntType }
|
||||
|
||||
Get-AppConfigurationClass $this
|
||||
|
||||
if($this.JsonObject."@odata.type" -eq "#microsoft.graph.iosManagedAppProtection") {
|
||||
$platformName = Get-LanguageString "AppProtection.iOSPlatformLabel"
|
||||
if($platformName) {
|
||||
#$this._PlatformName = $platformName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
#ImportOrder 220
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Apple Enrollment Group
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||
class AppleEnrollmentGroup : IntunePolicyGroupBase
|
||||
{
|
||||
AppleEnrollmentGroup() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._ID = "AppleEnrollment"
|
||||
$this._Name = "Apple Enrollment"
|
||||
$this._Icon = "AppleEnrollmentTypes"
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Apple Enrollment Types
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
# region Apple Enrollment Types
|
||||
class AppleEnrollmentType : IntunePolicyTypeBase
|
||||
{
|
||||
AppleEnrollmentType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
# _PolicyGroup must be AppleEnrollmentGroup (the group declared at the top of
|
||||
# this file). The original wiring pointed at AppleUpdateGroup, which is the
|
||||
# software-update group — wrong taxonomy.
|
||||
# _ObjectClass was never set, so IntunePolicyTypeBase.GetObject took the
|
||||
# "Object class is missing" branch and dropped every returned row, surfacing
|
||||
# as 'no Apple Enrollment Types objects matched' even though the API returned
|
||||
# data. Wire it up to AppleEnrollmentTypeObject (defined in this same file).
|
||||
$this._PolicyGroup = (Get-SingletonObject "AppleEnrollmentGroup")
|
||||
$this._APITitle = "Apple Enrollment Types"
|
||||
$this._PolicyName = "Apple Enrollment Type"
|
||||
$this._ID = "AppleEnrollmentTypes"
|
||||
$this._API = "deviceManagement/appleUserInitiatedEnrollmentProfiles"
|
||||
$this._Permissions = @("DeviceManagementServiceConfig.ReadWrite.All")
|
||||
$this._ObjectClass = "AppleEnrollmentTypeObject"
|
||||
$this._PropertiesToRemoveForUpdate = @('platform')
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Class AppleEnrollmentTypeObject : IntunePolicyBase
|
||||
{
|
||||
AppleEnrollmentTypeObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
AppleEnrollmentTypeObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PolicyType = (Get-SingletonObject "AppleEnrollmentType")
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -1,137 +0,0 @@
|
||||
#ImportOrder 220
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Apple Update Group
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||
class AppleUpdateGroup : IntunePolicyGroupBase
|
||||
{
|
||||
AppleUpdateGroup() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._ID = "AppleUpdates"
|
||||
$this._Name = "Apple updates"
|
||||
$this._Icon = "AppleUpdates"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# iOS/iPadOS Updates
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
# region iOS/iPadOS Updates
|
||||
class iOSiPadOSPolicyType : IntunePolicyTypeBase
|
||||
{
|
||||
iOSiPadOSPolicyType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "AppleUpdateGroup")
|
||||
$this._PolicyName = "iOS/iPadOS update policies"
|
||||
$this._ID = "iOSiPadOSUpdatePolicies"
|
||||
$this._API = "deviceManagement/deviceConfigurations"
|
||||
$this._QueryList = "?`$filter=isof(%27microsoft.graph.iosUpdateConfiguration%27)"
|
||||
$this._Permissions = @("DeviceManagementConfiguration.ReadWrite.All")
|
||||
$this._ObjectClass = "iOSiPadOSPolicyObject"
|
||||
$this._Icon = "iOSUpdates"
|
||||
$this._ExpandAssignmentsList = $false
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
|
||||
$this._PolicyTypeOrder = 90
|
||||
}
|
||||
|
||||
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
if($PolicyObject.'@odata.type' -ne "#microsoft.graph.iosUpdateConfiguration") { return $false }
|
||||
|
||||
return $true
|
||||
}
|
||||
}
|
||||
|
||||
Class iOSiPadOSPolicyObject : IntunePolicyBase
|
||||
{
|
||||
iOSiPadOSPolicyObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
iOSiPadOSPolicyObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PolicyType = (Get-SingletonObject "iOSiPadOSPolicyType")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# MacOS Updates
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
# region MacOS Updates
|
||||
class macOSPolicyType : IntunePolicyTypeBase
|
||||
{
|
||||
macOSPolicyType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "AppleUpdateGroup")
|
||||
$this._PolicyName = "macOS update policies"
|
||||
$this._ID = "macOSUpdatePolicies"
|
||||
$this._API = "deviceManagement/deviceConfigurations"
|
||||
$this._QueryList = "?`$filter=isof(%27microsoft.graph.macOSSoftwareUpdateConfiguration%27)"
|
||||
$this._Permissions = @("DeviceManagementConfiguration.ReadWrite.All")
|
||||
$this._ObjectClass = "macOSPolicyObject"
|
||||
$this._Icon = "MacOSUpdates"
|
||||
$this._ExpandAssignmentsList = $false
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
|
||||
$this._PolicyTypeOrder = 90
|
||||
}
|
||||
|
||||
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
if($PolicyObject.'@odata.type' -ne "#microsoft.graph.macOSSoftwareUpdateConfiguration") { return $false }
|
||||
|
||||
return $true
|
||||
}
|
||||
}
|
||||
|
||||
Class macOSPolicyObject : IntunePolicyBase
|
||||
{
|
||||
macOSPolicyObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
macOSPolicyObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PolicyType = (Get-SingletonObject "macOSPolicyType")
|
||||
}
|
||||
}
|
||||
@@ -1,767 +0,0 @@
|
||||
#ImportOrder 220
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Applications Group
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||
class ApplicationsGroup : IntunePolicyGroupBase
|
||||
{
|
||||
ApplicationsGroup() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._ID = "Applications"
|
||||
$this._Name = "Applications"
|
||||
$this._Icon = "Applications"
|
||||
# Type (Win32, iOS store, ...) is the discriminator here; Policy type would read
|
||||
# "Application" on every row but the iOS provisioning profiles.
|
||||
$this._ExtraColumns = @("ApplicationType=Type")
|
||||
$this._ShowPolicyTypeColumn = $false
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Application Type
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
# region Application Type
|
||||
class ApplicationType : IntunePolicyTypeBase
|
||||
{
|
||||
ApplicationType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "ApplicationsGroup")
|
||||
$this._APITitle = "Applications"
|
||||
$this._PolicyName = "Applications"
|
||||
$this._ID = "Applications"
|
||||
$this._API = "deviceAppManagement/mobileApps"
|
||||
$this._QueryList = "?`$filter=(microsoft.graph.managedApp/appAvailability eq null or microsoft.graph.managedApp/appAvailability eq 'lineOfBusiness' or isAssigned eq true)&`$orderby=displayName"
|
||||
$this._QuerySearch = $true
|
||||
$this._Expand = "categories,assignments" # ODataMetadata is set to minimal so assignments can't be autodetected
|
||||
$this._ODataMetadata = "minimal" # categories property not supported with ODataMetadata full
|
||||
$this._Permissions = @("DeviceManagementApps.ReadWrite.All")
|
||||
$this._PropertiesToRemove = @('uploadState','publishingState','isAssigned','dependentAppCount','supersedingAppCount','supersededAppCount','committedContentVersion','isFeatured','size','categories') #,'minimumSupportedWindowsRelease'
|
||||
$this._AssignmentsType = "mobileAppAssignments"
|
||||
$this._AssignmentPropertiesToKeep = @("@odata.type","target","settings","intent")
|
||||
$this._AssignmentTargetPropertiesToKeep = @("@odata.type","groupId","deviceAndAppManagementAssignmentFilterId","deviceAndAppManagementAssignmentFilterType")
|
||||
$this._ScopeTagsReturnedInList = $false
|
||||
$this._ExpandAssignmentsList = $false
|
||||
$this._ImportOrder = 60
|
||||
$this._ObjectClass = "ApplicationObject"
|
||||
$this._SubTypeColumn = "ApplicationType=Type"
|
||||
$this._ExtraColumns = @("ApplicationTypeGroup=App type")
|
||||
|
||||
# appUrl: Graph rejects a PATCH that carries it ("The property 'AppUrl'
|
||||
# cannot be patched") - a web app's URL is fixed at creation, as in the
|
||||
# portal. Only webApp has the property, so stripping it is safe for all.
|
||||
$this._PropertiesToRemoveForUpdate = @('platform', 'appUrl')
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
|
||||
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
if($PolicyObject.JsonObject.'@OData.Type' -in @('#microsoft.graph.microsoftStoreForBusinessApp','#microsoft.graph.androidStoreApp'))
|
||||
{
|
||||
Write-Log "App type '$($PolicyObject.JsonObject.'@OData.Type')' not supported for import" 2
|
||||
return @{ "Import" = $false }
|
||||
}
|
||||
|
||||
if($PolicyObject.JsonObject.'@OData.Type' -eq '#microsoft.graph.officeSuiteApp')
|
||||
{
|
||||
if($PolicyObject.JsonObject.officeSuiteAppDefaultFileFormat -eq "notConfigured")
|
||||
{
|
||||
$PolicyObject.JsonObject.officeSuiteAppDefaultFileFormat = "officeOpenXMLFormat"
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
PostImportCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
|
||||
{
|
||||
$tmpFilName = $null
|
||||
|
||||
if($SourceObject.IsFromFile) {
|
||||
if(-not ($PolicyObject.JsonObject.PSObject.Properties | Where-Object Name -eq '@odata.type'))
|
||||
{
|
||||
# Add @odata.type property if it is missing. Required by app package import
|
||||
$PolicyObject.JsonObject | Add-Member -MemberType NoteProperty -Name '@odata.type' -Value $SourceObject.JsonObject.'@odata.type'
|
||||
}
|
||||
|
||||
$fi = $SourceObject.FileInfo
|
||||
$tmpFilName = [IO.Path]::Combine($fi.DirectoryName, [string]$SourceObject.JsonObject.FileName)
|
||||
|
||||
if([IO.File]::Exists($tmpFilName) -eq $false)
|
||||
{
|
||||
Write-LogDebug "App content file not found in Json folder: '$tmpFilName'"
|
||||
$tmpFilName = $null
|
||||
}
|
||||
}
|
||||
else {
|
||||
}
|
||||
|
||||
Start-ApplicationImportFile $PolicyObject $tmpFilName
|
||||
Start-ApplicationAddInstallScripts $PolicyObject $SourceObject
|
||||
}
|
||||
|
||||
PostExportCommand([PSCustomObject]$PolicyObject, [String]$PathToFile)
|
||||
{
|
||||
$fi = [IO.FileInfo]"$PathToFile"
|
||||
|
||||
if((Get-CacheObject "ExportScripts") -eq $true) {
|
||||
try
|
||||
{
|
||||
foreach($rule in ($PolicyObject.JsonObject.detectionRules | Where-Object '@OData.Type' -eq "#microsoft.graph.win32LobAppPowerShellScriptDetection"))
|
||||
{
|
||||
if($rule.ScriptContent)
|
||||
{
|
||||
[IO.File]::WriteAllBytes(([IO.Path]::Combine($fi.DirectoryName, "$($fi.BaseName)_DetectionScript.ps1")), ([System.Convert]::FromBase64String($rule.ScriptContent)))
|
||||
}
|
||||
}
|
||||
|
||||
foreach($rule in $PolicyObject.JsonObject.requirementRules)
|
||||
{
|
||||
if($rule.'@OData.Type' -eq "#microsoft.graph.win32LobAppPowerShellScriptRequirement")
|
||||
{
|
||||
if($rule.ScriptContent)
|
||||
{
|
||||
[IO.File]::WriteAllBytes(([IO.Path]::Combine($fi.DirectoryName, "$($fi.BaseName)_RequirementScript.ps1")), ([System.Convert]::FromBase64String($rule.ScriptContent)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if($PolicyObject.JsonObject.activeInstallScript.'#ScriptInfo'.displayName)
|
||||
{
|
||||
[IO.File]::WriteAllBytes(([IO.Path]::Combine($fi.DirectoryName, "$($fi.BaseName)_$($PolicyObject.JsonObject.activeInstallScript.'#ScriptInfo'.displayName)")), ([System.Convert]::FromBase64String($PolicyObject.JsonObject.activeInstallScript.'#ScriptInfo'.content)))
|
||||
}
|
||||
|
||||
if($PolicyObject.JsonObject.activeUninstallScript.'#ScriptInfo'.displayName)
|
||||
{
|
||||
[IO.File]::WriteAllBytes(([IO.Path]::Combine($fi.DirectoryName, "$($fi.BaseName)_$($PolicyObject.JsonObject.activeUninstallScript.'#ScriptInfo'.displayName)")), ([System.Convert]::FromBase64String($PolicyObject.JsonObject.activeUninstallScript.'#ScriptInfo'.content)))
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-LogError "Failed to export application scripts" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
Save-SettingStoreValue "Intune" "ExportAppFile" (Get-CacheObject "ExportAppContent")
|
||||
if((Get-CacheObject "ExportAppContent") -eq $true) {
|
||||
if($script:_skipDirectGet -eq $true) {
|
||||
Write-Log "Bulk export: app content download is skipped because direct Graph GET calls are disabled" 2
|
||||
return
|
||||
}
|
||||
$encryptionSource = Get-SettingValue "IntuneAppDownloadFolder" (Get-SettingValue "IntuneAppPackagesFolder")
|
||||
$pkgPath = $fi.DirectoryName
|
||||
|
||||
if($pkgPath)
|
||||
{
|
||||
Write-Log "Download file $($PolicyObject.JsonObject.FileName)"
|
||||
|
||||
$exportFile = [IO.Path]::Combine($pkgPath, "$($PolicyObject.JsonObject.FileName).encrypted")
|
||||
$contentFileObj = Start-DownloadAppContent $PolicyObject $exportFile -GetContentFileInfoOnly
|
||||
$encryptionFile = Find-AppEncryptionFile $PolicyObject $contentFileObj $encryptionSource
|
||||
if($encryptionFile -and [IO.File]::Exists($encryptionFile))
|
||||
{
|
||||
Start-DownloadFile $contentFileObj.azureStorageUri $exportFile
|
||||
|
||||
if([IO.File]::Exists($exportFile))
|
||||
{
|
||||
Write-Log "Decrypt file"
|
||||
$encryptionInfo = ConvertFrom-Json ([IO.File]::ReadAllText($encryptionFile))
|
||||
if($encryptionInfo.fileEncryptionInfo)
|
||||
{
|
||||
$encryptionInfo = $encryptionInfo.fileEncryptionInfo
|
||||
}
|
||||
$destination = $pkgPath + ("\$($PolicyObject.JsonObject.FileName)" -replace 'intunewin$', 'zip')
|
||||
Start-DecryptFile $exportFile $destination $encryptionInfo.encryptionKey $encryptionInfo.initializationVector
|
||||
}
|
||||
|
||||
try { [IO.File]::Delete($exportFile) }
|
||||
catch {
|
||||
Write-LogError "Failed to delete exported encrypted file" $_.Exception
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Log "Could not find encryption file"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Hashtable]PreImportAssignmentsCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
|
||||
{
|
||||
if($PolicyObject.JsonObject.'@odata.type' -eq "#microsoft.graph.windowsMicrosoftEdgeApp")
|
||||
{
|
||||
$assignments = $SourceObject.JsonObject.assignments
|
||||
foreach($assignment in $assignments)
|
||||
{
|
||||
Remove-Property $assignment.target "deviceAndAppManagementAssignmentFilterId"
|
||||
Remove-Property $assignment.target "deviceAndAppManagementAssignmentFilterType"
|
||||
}
|
||||
return (@{"Assignments" = $assignments})
|
||||
}
|
||||
elseif($PolicyObject.JsonObject.'@odata.type' -eq "#microsoft.graph.winGetApp")
|
||||
{
|
||||
Write-LogDebug "Wait for '$($PolicyObject.Name)' to be published"
|
||||
$i = 2
|
||||
Start-Sleep -s ($i)
|
||||
$x = 0
|
||||
while($x -lt 10)
|
||||
{
|
||||
$appInfo = Invoke-MSGraphAPI -Url "$($PolicyObject.PolicyType.API)/$($PolicyObject.id)" -ODataMetadata "skip" -TokenId $PolicyObject.TokenId
|
||||
if($appInfo.publishingState -eq "Published")
|
||||
{
|
||||
Write-LogDebug "Application '$($PolicyObject.Name)' is published"
|
||||
return $null
|
||||
}
|
||||
Start-Sleep -s ($i)
|
||||
$x++
|
||||
if($x -ge 5) { $i++ }
|
||||
}
|
||||
|
||||
Write-Log "Application '$($PolicyObject.Name)' is not published. Skipping assignments" 2
|
||||
return (@{"Import" = $false})
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
PostBulkImportCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
|
||||
{
|
||||
Add-ApplicationReferences $PolicyObject $SourceObject
|
||||
}
|
||||
|
||||
[Hashtable]PreUpdateCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
|
||||
{
|
||||
if($PolicyObject.JsonObject.'@OData.type' -eq "#microsoft.graph.windowsMobileMSI")
|
||||
{
|
||||
Remove-Property $PolicyObject.JsonObject "useDeviceContext"
|
||||
}
|
||||
elseif($PolicyObject.JsonObject.'@OData.type' -eq "#microsoft.graph.officeSuiteApp")
|
||||
{
|
||||
Remove-Property $PolicyObject.JsonObject "officeConfigurationXml"
|
||||
Remove-Property $PolicyObject.JsonObject "officePlatformArchitecture"
|
||||
Remove-Property $PolicyObject.JsonObject "developer"
|
||||
Remove-Property $PolicyObject.JsonObject "owner"
|
||||
Remove-Property $PolicyObject.JsonObject "publisher"
|
||||
}
|
||||
elseif($PolicyObject.JsonObject.'@OData.type' -eq "#microsoft.graph.winGetApp")
|
||||
{
|
||||
# Immutable after creation - Graph: "The property
|
||||
# 'InstallExperience' cannot be patched."
|
||||
Remove-Property $PolicyObject.JsonObject "installExperience"
|
||||
Remove-Property $PolicyObject.JsonObject "packageIdentifier"
|
||||
Remove-Property $PolicyObject.JsonObject "manifestHash"
|
||||
}
|
||||
|
||||
Remove-Property $PolicyObject.JsonObject "appStoreUrl"
|
||||
|
||||
# assignments is a navigation property (managed via /assign), not
|
||||
# inline-PATCHable: "Cannot apply PATCH to navigation property
|
||||
# 'assignments' on entity type mobileApp".
|
||||
Remove-Property $PolicyObject.JsonObject "assignments"
|
||||
Remove-Property $PolicyObject.JsonObject "assignments@odata.context"
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
# Exported app json carries a top-level @odata.id (and, when assignments
|
||||
# were expanded, assignments@odata.context) - both identify the
|
||||
# mobileApps entity set, which only hosts apps.
|
||||
if($PolicyObject.'@odata.id' -like '*deviceAppManagement/mobileApps(*') {
|
||||
return $true
|
||||
}
|
||||
|
||||
if($PolicyObject.'assignments@odata.context' -like '*#deviceAppManagement/mobileApps(*') {
|
||||
return $true
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
Class ApplicationObject : IntunePolicyBase
|
||||
{
|
||||
Hidden [String]$_AppTypeName = $null
|
||||
Hidden [String]$_AppTypeGroup = $null
|
||||
Hidden [String]$_InstallerType = $null
|
||||
# Carries phase-1 → phase-2 routing state (script id → { Script; Target })
|
||||
# so the orchestrator's phase-2 ApplyResult can find which install/uninstall
|
||||
# target object to attach #ScriptInfo to without re-walking the script list.
|
||||
Hidden [Hashtable]$_SubResourceState = $null
|
||||
|
||||
ApplicationObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
ApplicationObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PolicyType = (Get-SingletonObject "ApplicationType")
|
||||
$this._PlatformName = Get-GraphApplicationPlatform $this
|
||||
$this._AppTypeGroup = Get-GraphApplicationTypeGroup $this
|
||||
$this._AppTypeName = (Get-GraphApplicationName $this)
|
||||
$this._HasSubResourceBatch = $true
|
||||
|
||||
if($this.JsonObject."@OData.Type" -eq "#microsoft.graph.winGetApp") {
|
||||
if($this.JsonObject.packageIdentifier -like "9*")
|
||||
{
|
||||
$this._InstallerType = "UWP"
|
||||
}
|
||||
elseif($this.JsonObject.packageIdentifier -like "X*")
|
||||
{
|
||||
$this._InstallerType = "Win32"
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Log "Unknown package identifier for app $($this.Name): $($this.JsonObject.packageIdentifier)" 2
|
||||
$this._InstallerType = "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
Add-ObjectProperty $this "ApplicationType" { $this._AppTypeName }
|
||||
Add-ObjectProperty $this "ApplicationTypeGroup" { $this._AppTypeGroup }
|
||||
Add-ObjectProperty $this "InstallerType" { $this._InstallerType }
|
||||
|
||||
}
|
||||
|
||||
# Phase 1: relationships (when there are dependencies/supersedences) and the
|
||||
# win32 script list (when an active install/uninstall script is referenced).
|
||||
# Phase 2 follow-ups are produced by the phase-1 ApplyResult below.
|
||||
[PSCustomObject[]] GetSubResourceBatchRequests([int]$Phase)
|
||||
{
|
||||
if($Phase -ne 1) { return @() }
|
||||
|
||||
$reqs = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
|
||||
if(([int64]($this.Object.dependentAppCount) -gt 0) -or ([int64]($this.Object.supersededAppCount) -gt 0)) {
|
||||
[void]$reqs.Add([PSCustomObject]@{
|
||||
Key = 'rel'
|
||||
Url = "deviceAppManagement/mobileApps/$($this.Id)/relationships?`$filter=targetType%20eq%20microsoft.graph.mobileAppRelationshipType%27child%27"
|
||||
})
|
||||
}
|
||||
|
||||
if($this.Object.'@odata.type' -eq '#microsoft.graph.win32LobApp' -and
|
||||
($this.Object.activeInstallScript.targetId -or $this.Object.activeUninstallScript.targetId)) {
|
||||
[void]$reqs.Add([PSCustomObject]@{
|
||||
Key = 'scriptlist'
|
||||
Url = "deviceAppManagement/mobileApps/$($this.Id)/microsoft.graph.win32LobApp/contentVersions/$($this.Object.committedContentVersion)/scripts/"
|
||||
})
|
||||
}
|
||||
|
||||
return $reqs.ToArray()
|
||||
}
|
||||
|
||||
[PSCustomObject[]] ApplySubResourceBatchResult([int]$Phase, [string]$Key, $Body)
|
||||
{
|
||||
if($Phase -eq 1 -and $Key -eq 'rel') {
|
||||
$deps = @()
|
||||
$sups = @()
|
||||
foreach($rel in @($Body.value)) {
|
||||
if($rel.'@odata.type' -eq '#microsoft.graph.mobileAppDependency') {
|
||||
$deps += "$($rel.targetDisplayName)|!|$($rel.targetDisplayVersion)|!|$($rel.targetId)|!|$($rel.dependencyType)"
|
||||
}
|
||||
elseif($rel.'@odata.type' -eq '#microsoft.graph.mobileAppSupersedence') {
|
||||
$sups += "$($rel.targetDisplayName)|!|$($rel.targetDisplayVersion)|!|$($rel.targetId)|!|$($rel.supersedenceType)"
|
||||
}
|
||||
}
|
||||
if($deps.Count -gt 0) {
|
||||
$this.Object | Add-Member -MemberType NoteProperty -Name '#CustomRefDependency' -Value ($deps -join '|*|') -Force
|
||||
}
|
||||
if($sups.Count -gt 0) {
|
||||
$this.Object | Add-Member -MemberType NoteProperty -Name '#CustomRefSupersedence' -Value ($sups -join '|*|') -Force
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
if($Phase -eq 1 -and $Key -eq 'scriptlist') {
|
||||
if($null -eq $this._SubResourceState) { $this._SubResourceState = @{} }
|
||||
$followups = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
foreach($script in @($Body.value)) {
|
||||
$target = $null
|
||||
if($this.Object.activeInstallScript.targetId -eq $script.id) {
|
||||
$target = $this.Object.activeInstallScript
|
||||
}
|
||||
elseif($this.Object.activeUninstallScript.targetId -eq $script.id) {
|
||||
$target = $this.Object.activeUninstallScript
|
||||
}
|
||||
else {
|
||||
Write-Log "Script with id $($script.id) is not referenced by active install or uninstall script. Skipping." 2
|
||||
continue
|
||||
}
|
||||
$stateKey = "scriptcontent_$($script.id)"
|
||||
$this._SubResourceState[$stateKey] = [PSCustomObject]@{ Script = $script; Target = $target }
|
||||
[void]$followups.Add([PSCustomObject]@{
|
||||
Key = $stateKey
|
||||
Url = "deviceAppManagement/mobileApps/$($this.Id)/microsoft.graph.win32LobApp/contentVersions/$($this.Object.committedContentVersion)/scripts/$($script.id)?`$select=Id,Content"
|
||||
})
|
||||
}
|
||||
return $followups.ToArray()
|
||||
}
|
||||
|
||||
if($Phase -eq 2 -and $Key -like 'scriptcontent_*' -and $null -ne $this._SubResourceState) {
|
||||
$state = $this._SubResourceState[$Key]
|
||||
if($state) {
|
||||
if($Body -and $Body.Content) {
|
||||
$state.Script | Add-Member -MemberType NoteProperty -Name 'content' -Value $Body.Content -Force
|
||||
}
|
||||
$state.Target | Add-Member -MemberType NoteProperty -Name '#ScriptInfo' -Value $state.Script -Force
|
||||
$this._SubResourceState.Remove($Key) | Out-Null
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
return @()
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
function Start-ApplicationImportFile
|
||||
{
|
||||
param($PolicyObject, $PackageFile = $null)
|
||||
|
||||
if(-not $PolicyObject.JsonObject.'@odata.type') { return }
|
||||
|
||||
if($null -eq $PackageFile)
|
||||
{
|
||||
$pkgPath = Get-SettingValue "IntuneAppPackagesFolder"
|
||||
|
||||
if(-not $pkgPath -or [IO.Directory]::Exists($pkgPath) -eq $false)
|
||||
{
|
||||
Write-LogDebug "Package source directory in Settings is not specified" 2
|
||||
return
|
||||
}
|
||||
elseif([IO.Directory]::Exists($pkgPath) -eq $false)
|
||||
{
|
||||
Write-LogDebug "Package source directory '$($pkgPath)' does not exist" 2
|
||||
return
|
||||
}
|
||||
|
||||
$PackageFile = [IO.Path]::Combine($pkgPath, "$($PolicyObject.JsonObject.fileName)")
|
||||
$packageFile2 = [IO.Path]::Combine($pkgPath, $PolicyObject.Name, "$($PolicyObject.JsonObject.fileName)")
|
||||
if([IO.File]::Exists($PackageFile) -eq $false -and [IO.File]::Exists($packageFile2)) {
|
||||
$PackageFile = $packageFile2
|
||||
}
|
||||
}
|
||||
$fi = [IO.FileInfo]$PackageFile
|
||||
|
||||
if($fi.Exists -eq $false)
|
||||
{
|
||||
Write-LogDebug "Package source file $($fi.FullName) not found" 2
|
||||
return
|
||||
}
|
||||
|
||||
Write-Status "Import application package file $($fi.FullName)"
|
||||
Write-Log "Import application file '$($($fi.FullName))' for $($PolicyObject.Name)"
|
||||
|
||||
$appType = $PolicyObject.JsonObject.'@odata.type'.Trim('#')
|
||||
|
||||
if($appType -eq "microsoft.graph.win32LobApp")
|
||||
{
|
||||
$fileEncryptionInfo = Copy-Win32LOBPackage $PackageFile $PolicyObject
|
||||
}
|
||||
elseif($appType -eq "microsoft.graph.windowsMobileMSI")
|
||||
{
|
||||
$fileEncryptionInfo = Copy-MSILOB $PackageFile $PolicyObject
|
||||
}
|
||||
elseif($appType -eq "microsoft.graph.windowsUniversalAppX")
|
||||
{
|
||||
$fileEncryptionInfo = Copy-MSIXLOB $PackageFile $PolicyObject
|
||||
}
|
||||
elseif($appType -eq "microsoft.graph.iosLOBApp")
|
||||
{
|
||||
$fileEncryptionInfo = Copy-iOSLOB $PackageFile $PolicyObject
|
||||
}
|
||||
elseif($appType -eq "microsoft.graph.androidLOBApp")
|
||||
{
|
||||
$fileEncryptionInfo = Copy-AndroidLOB $PackageFile $PolicyObject
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Log "Unsupported application type $appType. File will not be uploaded" 2
|
||||
}
|
||||
|
||||
if((Get-SettingValue "IntuneSaveEncryptionFile") -eq $true)
|
||||
{
|
||||
if($fileEncryptionInfo)
|
||||
{
|
||||
$jsonEncryptionInfo = $fileEncryptionInfo | ConvertTo-Json -Depth 10
|
||||
|
||||
$pkgPath = Get-SettingValue "IntuneAppDownloadFolder" (Get-SettingValue "IntuneAppPackagesFolder")
|
||||
if($pkgPath -and [IO.Directory]::Exists($pkgPath))
|
||||
{
|
||||
$obj = Invoke-MSGraphAPI -Url "deviceAppManagement/mobileApps/$($PolicyObject.id)" -ODataMetadata "Minimal" -TokenId $PolicyObject._TokenID
|
||||
$fullPath = [IO.Path]::Combine($pkgPath, "$($obj.displayName)_$($obj.id)_$($obj.committedContentVersion).json")
|
||||
$jsonEncryptionInfo | Out-File -FilePath $fullPath -Force -Encoding utf8
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Start-ApplicationAddInstallScripts
|
||||
{
|
||||
param($PolicyObject, $FromAppObj)
|
||||
|
||||
if($FromAppObj -and ($FromAppObj.activeInstallScript."#ScriptInfo" -or $FromAppObj.activeUninstallScript."#ScriptInfo"))
|
||||
{
|
||||
Write-Log "Importing scripts for $($PolicyObject.displayName)"
|
||||
|
||||
$scriptsAdded = $false
|
||||
$jsonData = @{}
|
||||
$jsonData."@odata.type" = "#microsoft.graph.win32LobApp"
|
||||
$jsonData."committedContentVersion" = "1"
|
||||
|
||||
foreach ($scriptType in @('activeInstallScript','activeUninstallScript')) {
|
||||
$scriptInfo = $FromAppObj.$scriptType.'#ScriptInfo'
|
||||
if (-not $scriptInfo) { continue }
|
||||
|
||||
Write-Log "Add $($scriptType -replace '^active','') script: $($scriptInfo.displayName)"
|
||||
|
||||
$json = [ordered]@{
|
||||
'@odata.type' = $scriptInfo.'@odata.type'
|
||||
displayName = $scriptInfo.displayName
|
||||
enforceSignatureCheck = $scriptInfo.enforceSignatureCheck
|
||||
runAs32Bit = $scriptInfo.runAs32Bit
|
||||
content = $scriptInfo.content
|
||||
} | ConvertTo-Json -Depth 10 -Compress
|
||||
|
||||
$scriptObject = Invoke-MSGraphAPI -Url "deviceAppManagement/mobileApps/$($PolicyObject.id)/microsoft.graph.win32LobApp/contentVersions/1/scripts" -Method POST -Content $json -TokenId $PolicyObject._TokenID
|
||||
|
||||
if ($scriptObject) {
|
||||
$jsonData.$scriptType = @{ targetId = $scriptObject.Id }
|
||||
$scriptsAdded = $true
|
||||
}
|
||||
}
|
||||
|
||||
$i = 0
|
||||
while($true)
|
||||
{
|
||||
$scripts = Invoke-MSGraphAPI -Url "deviceAppManagement/mobileApps/$($PolicyObject.id)/microsoft.graph.win32LobApp/contentVersions/1/scripts" -TokenId $PolicyObject._TokenID
|
||||
if(-not $scripts)
|
||||
{
|
||||
Write-Log "Failed to retrieve scripts for app after adding. Skipping Install/Uninstall script config." 2
|
||||
return
|
||||
}
|
||||
|
||||
if(($scripts.value.state | Select -Unique) -eq "commitSuccess")
|
||||
{
|
||||
Write-Log "Scripts added successfully"
|
||||
break
|
||||
}
|
||||
if($i -ge 12)
|
||||
{
|
||||
Write-Log "Install/Uninstall scripts are still not in pending state after waiting for 1 minute." 3
|
||||
return
|
||||
}
|
||||
|
||||
Write-Log "Waiting for scripts to be added..."
|
||||
Start-Sleep -Seconds 5
|
||||
$i++
|
||||
}
|
||||
|
||||
if($scriptsAdded)
|
||||
{
|
||||
Write-Log "Add script info to app"
|
||||
$json = ConvertTo-Json $jsonData -Depth 10
|
||||
$status = Invoke-MSGraphAPI -Url "deviceAppManagement/mobileApps/$($PolicyObject.id)" -Method PATCH -Body $json -TokenId $PolicyObject._TokenID -FullResponseObject
|
||||
if($status.Success)
|
||||
{
|
||||
Write-Log "Install/Uninstall script info updated successfully"
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Log "Failed to update Install/Uninstall script info" 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Add-ApplicationReferences
|
||||
{
|
||||
param($PolicyObject, $SourceObject)
|
||||
|
||||
if($SourceObject.JsonObject."#CustomRefDependency" -or $SourceObject.JsonObject."#CustomRefSupersedence")
|
||||
{
|
||||
Write-Log "Adding app references for $($PolicyObject.displayName)"
|
||||
|
||||
$depAppsInfo = $SourceObject.JsonObject."#CustomRefDependency"
|
||||
$supAppsInfo = $SourceObject.JsonObject."#CustomRefSupersedence"
|
||||
|
||||
$releationShips = [PSCustomObject]@{
|
||||
relationships = @()
|
||||
}
|
||||
|
||||
if($depAppsInfo)
|
||||
{
|
||||
foreach($depApp in ($depAppsInfo -split "[|][*][|]"))
|
||||
{
|
||||
$appName, $appVer, $appId, $appType = $depApp -split "[|][!][|]"
|
||||
if(-not $appName -or -not $appVer)
|
||||
{
|
||||
Write-Log "Could not get Name and Version from string: $($PolicyObject.displayName)" 2
|
||||
continue
|
||||
}
|
||||
$tmpApps = (Invoke-MSGraphAPI -Url "deviceAppManagement/mobileApps?`$filter=displayName eq '$appName'" -TokenId $PolicyObject.TokenId).value
|
||||
if(-not $tmpApps)
|
||||
{
|
||||
Write-Log "No application found with name $appName" 2
|
||||
continue
|
||||
}
|
||||
$tmpApp = $tmpApps | Where-Object displayVersion -eq $appVer
|
||||
if(-not $tmpApp)
|
||||
{
|
||||
Write-Log "No $appName application found with version $appVer" 2
|
||||
continue
|
||||
}
|
||||
elseif(($tmpApp | Measure-Object).Count -gt 1)
|
||||
{
|
||||
Write-Log "Multiple $appName application found with version $appVer" 2
|
||||
continue
|
||||
}
|
||||
Write-Log "Add $appName ($appVer) to Dependency list"
|
||||
$releationShips.relationships += [PSCustomObject]@{
|
||||
"@odata.type" = "#microsoft.graph.mobileAppDependency"
|
||||
targetId = $tmpApp.Id
|
||||
dependencyType = $appType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if($supAppsInfo)
|
||||
{
|
||||
foreach($suppApp in ($supAppsInfo -split "[|][*][|]"))
|
||||
{
|
||||
$appName, $appVer, $appId, $appType = $suppApp -split "[|][!][|]"
|
||||
if(-not $appName -or -not $appVer)
|
||||
{
|
||||
Write-Log "Could not get Name and Version from string: $($PolicyObject.displayName)" 2
|
||||
continue
|
||||
}
|
||||
$tmpApps = (Invoke-MSGraphAPI -Url "deviceAppManagement/mobileApps?`$filter=displayName eq '$appName'" -TokenId $PolicyObject.TokenId).value
|
||||
if(-not $tmpApps)
|
||||
{
|
||||
Write-Log "No application found with name $appName" 2
|
||||
continue
|
||||
}
|
||||
$tmpApp = $tmpApps | Where-Object displayVersion -eq $appVer
|
||||
if(-not $tmpApp)
|
||||
{
|
||||
Write-Log "No $appName application found with version $appVer" 2
|
||||
continue
|
||||
}
|
||||
elseif(($tmpApp | Measure-Object).Count -gt 1)
|
||||
{
|
||||
Write-Log "Multiple $appName application found with version $appVer" 2
|
||||
continue
|
||||
}
|
||||
Write-Log "Add $appName ($appVer) to Supersedence list"
|
||||
$releationShips.relationships += [PSCustomObject]@{
|
||||
"@odata.type" = "#microsoft.graph.mobileAppSupersedence"
|
||||
targetId = $tmpApp.Id
|
||||
supersedenceType = $appType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if($releationShips.relationships.Count -gt 0)
|
||||
{
|
||||
$json = Update-JsonForEnvironment (ConvertTo-Json $releationShips -Depth 20) $PolicyObject $PolicyObject.TokenId
|
||||
|
||||
Write-Log "Update app references"
|
||||
Invoke-MSGraphAPI -Url "deviceAppManagement/mobileApps/$($PolicyObject.Id)/updateRelationships" -Method "POST" -Body $json
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# iOS LOB App Provisioning Configurations
|
||||
#
|
||||
#########################################################################################
|
||||
#
|
||||
# Apple-issued provisioning profiles (.mobileprovision files) that travel
|
||||
# alongside iOS LOB apps. Without these, signed LOB apps stop launching when
|
||||
# the embedded profile expires. Endpoint at
|
||||
# /deviceAppManagement/iosLobAppProvisioningConfigurations.
|
||||
#
|
||||
# `payload` (Edm.Binary) carries the base64-encoded .mobileprovision file;
|
||||
# it round-trips through JSON export/import as the payload string.
|
||||
|
||||
# region IosLobAppProvisioningConfigurationsType
|
||||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||
class IosLobAppProvisioningConfigurationsType : IntunePolicyTypeBase
|
||||
{
|
||||
IosLobAppProvisioningConfigurationsType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "ApplicationsGroup")
|
||||
$this._PolicyName = "iOS app provisioning profiles"
|
||||
$this._ID = "IosLobAppProvisioningConfigurations"
|
||||
# Platform default: the endpoint serves exactly one platform and the
|
||||
# objects carry no platforms/platformType field, so the column would
|
||||
# otherwise be blank (iosLobAppProvisioningConfigurations is iOS-only).
|
||||
$this._PlatformName = Get-LanguageString "Platform.iOS" -IgnoreMissing
|
||||
$this._API = "deviceAppManagement/iosLobAppProvisioningConfigurations"
|
||||
# No dedicated icon yet — fall back to Applications. Tracked in TODO
|
||||
# under the icons-for-new-APIs entry.
|
||||
$this._Icon = "Applications"
|
||||
$this._Permissions = @("DeviceManagementApps.ReadWrite.All")
|
||||
# version + expirationDateTime are derived from the embedded
|
||||
# .mobileprovision; createdDateTime / lastModifiedDateTime are
|
||||
# server-set. Strip on POST/PATCH.
|
||||
$this._PropertiesToRemove = @('version','expirationDateTime')
|
||||
$this._PropertiesToRemoveForUpdate = @('version','expirationDateTime','payload','payloadFileName')
|
||||
# Default `assignments` shape with simple {target} — no overrides
|
||||
# needed beyond the inherited defaults.
|
||||
$this._ImportOrder = 90
|
||||
$this._ObjectClass = "IosLobAppProvisioningConfigurationObject"
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||
class IosLobAppProvisioningConfigurationObject : IntunePolicyBase
|
||||
{
|
||||
IosLobAppProvisioningConfigurationObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
IosLobAppProvisioningConfigurationObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PolicyType = (Get-SingletonObject "IosLobAppProvisioningConfigurationsType")
|
||||
$this._PlatformName = Get-LanguageString "Platform.iOS" -IgnoreMissing
|
||||
if(-not $this._PlatformName) { $this._PlatformName = "iOS/iPadOS" }
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
#ImportOrder 32
|
||||
|
||||
class IntuneAssignmentsProviderBase
|
||||
{
|
||||
[string] $Name
|
||||
[string] $Value
|
||||
[string] $OptionsXaml
|
||||
|
||||
IntuneAssignmentsProviderBase([string]$name, [string]$value, [string]$optionsXaml)
|
||||
{
|
||||
$this.Name = $name
|
||||
$this.Value = $value
|
||||
$this.OptionsXaml = $optionsXaml
|
||||
}
|
||||
|
||||
[bool] Validate() { return $true }
|
||||
[void] SaveSettings() { }
|
||||
[object[]] GetAssignments() { return @() }
|
||||
[string] ToString() { return $this.Name }
|
||||
}
|
||||
|
||||
class IntuneAssignmentsFolderProvider : IntuneAssignmentsProviderBase
|
||||
{
|
||||
[string] $ExportPath
|
||||
|
||||
IntuneAssignmentsFolderProvider() : base(
|
||||
"From Folder",
|
||||
"folder",
|
||||
"IntuneToolsAssignmentsFolderOptions"
|
||||
) {}
|
||||
|
||||
[bool] Validate()
|
||||
{
|
||||
if([string]::IsNullOrWhiteSpace($this.ExportPath) -or -not [IO.Directory]::Exists($this.ExportPath))
|
||||
{
|
||||
throw "Select a valid folder containing exported objects"
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
[void] SaveSettings()
|
||||
{
|
||||
Save-SettingStoreValue "IntuneAssignments" "ExportPath" $this.ExportPath
|
||||
}
|
||||
|
||||
[object[]] GetAssignments()
|
||||
{
|
||||
return (Get-IntuneAssignmentsFromFolder $this.ExportPath)
|
||||
}
|
||||
}
|
||||
|
||||
class IntuneAssignmentsIntuneProvider : IntuneAssignmentsProviderBase
|
||||
{
|
||||
IntuneAssignmentsIntuneProvider() : base(
|
||||
"From Intune",
|
||||
"intune",
|
||||
"IntuneToolsAssignmentsIntuneOptions"
|
||||
) {}
|
||||
|
||||
[bool] Validate()
|
||||
{
|
||||
# Ask the active auth provider whether a session exists, not the MSAL-specific
|
||||
# $script:MSALTokens registry. The latter is empty when MgGraph is the active
|
||||
# provider, so this method incorrectly blocked signed-in MgGraph users.
|
||||
$signedIn = $false
|
||||
try {
|
||||
$provider = Get-AuthProvider
|
||||
if($provider) {
|
||||
$userInfo = $provider.GetUserInfo(0)
|
||||
if($userInfo) { $signedIn = $true }
|
||||
}
|
||||
} catch { }
|
||||
|
||||
if(-not $signedIn)
|
||||
{
|
||||
throw "You must be logged in to read assignments from Intune"
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
[object[]] GetAssignments()
|
||||
{
|
||||
return (Get-IntuneAssignmentsFromIntune)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,377 +0,0 @@
|
||||
#ImportOrder 220
|
||||
|
||||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||
class ComplianceGroup : IntunePolicyGroupBase
|
||||
{
|
||||
ComplianceGroup() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._ID = "Compliance"
|
||||
$this._Name = "Compliance"
|
||||
$this._Icon = "CompliancePolicies"
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Device Compliance
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
# region Device Compliance
|
||||
class DeviceComplianceType : IntunePolicyTypeBase
|
||||
{
|
||||
DeviceComplianceType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "ComplianceGroup")
|
||||
$this._PolicyName = "Compliance Policy"
|
||||
$this._ID = "CompliancePolicies"
|
||||
$this._API = "deviceManagement/deviceCompliancePolicies"
|
||||
# This endpoint answers HTTP 400 to startswith() on displayName
|
||||
# (verified 2026-08-27; 'displayName eq' works, prefix search does not),
|
||||
# so name searches filter client-side instead.
|
||||
$this._SupportsNameFilter = $false
|
||||
$this._Permissions = @("DeviceManagementConfiguration.ReadWrite.All")
|
||||
$this._Expand = "scheduledActionsForRule(`$expand=scheduledActionConfigurations)"
|
||||
# "Locations" (v3's deprecated Intune managementConditions type) is not a
|
||||
# PolicyType here, so listing it resolved to nothing on import.
|
||||
$this._Dependencies = @("Notifications","ComplianceScripts")
|
||||
$this._ObjectClass = "DeviceComplianceObject"
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
|
||||
PostExportCommand([PSCustomObject]$PolicyObject, [String]$PathToFile)
|
||||
{
|
||||
foreach($scheduledActionsForRule in $PolicyObject.JsonObject.scheduledActionsForRule)
|
||||
{
|
||||
foreach($scheduledActionConfiguration in $scheduledActionsForRule.scheduledActionConfigurations)
|
||||
{
|
||||
foreach($notificationMessageCCGroup in $scheduledActionConfiguration.notificationMessageCCList)
|
||||
{
|
||||
Add-GraphMigrationObject $notificationMessageCCGroup "groups" "Group" ([IO.Path]::GetDirectoryName($PathToFile)) $PolicyObject._TokenId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Hashtable]PreUpdateCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
|
||||
{
|
||||
$api = "deviceManagement/deviceCompliancePolicies/$($PolicyObject.Id)/scheduleActionsForRules"
|
||||
|
||||
$tmpObj = [PSCustomObject]@{
|
||||
deviceComplianceScheduledActionForRules = $PolicyObject.JsonObject.scheduledActionsForRule
|
||||
}
|
||||
|
||||
$json = ConvertTo-Json $tmpObj -Depth 20
|
||||
Invoke-MSGraphAPI -Url $api -Content $json -HttpMethod "POST" -TokenId $PolicyObject._TokenId | Out-Null
|
||||
|
||||
Remove-Property $PolicyObject.JsonObject "scheduledActionsForRule"
|
||||
|
||||
return (@{})
|
||||
}
|
||||
}
|
||||
|
||||
Class DeviceComplianceObject : IntunePolicyBase
|
||||
{
|
||||
DeviceComplianceObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
DeviceComplianceObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PolicyType = (Get-SingletonObject "DeviceComplianceType")
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Device Compliance V2
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
Class DeviceComplianceV2Type : IntunePolicyTypeBase
|
||||
{
|
||||
DeviceComplianceV2Type() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "ComplianceGroup")
|
||||
# Its own name so a mixed Compliance list tells V1 from V2 without a
|
||||
# separate "Policy base" column.
|
||||
$this._PolicyName = "Compliance Policy (Settings Catalog)"
|
||||
$this._PolicyBaseName = "Compliance Policy V2"
|
||||
$this._APITitle = "Compliance Policy (Linux)"
|
||||
$this._ID = "CompliancePoliciesV2"
|
||||
$this._API = "deviceManagement/compliancePolicies"
|
||||
$this._PropertiesToRemove = @('settingCount')
|
||||
$this._Permissions = @("DeviceManagementConfiguration.ReadWrite.All")
|
||||
$this._NameProperty = "Name"
|
||||
$this._Expand = "settings"
|
||||
$this._ObjectClass = "DeviceComplianceV2Object"
|
||||
$this._Icon = "CompliancePolicies"
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
|
||||
[Hashtable]PreUpdateCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
|
||||
{
|
||||
# V2 compliance runs on the settings-catalog engine: updates must PUT
|
||||
# the full body (including settings); PATCH with settings is rejected.
|
||||
return @{ "Method" = "PUT" }
|
||||
}
|
||||
}
|
||||
|
||||
Class DeviceComplianceV2Object : IntunePolicyBase
|
||||
{
|
||||
DeviceComplianceV2Object([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
DeviceComplianceV2Object() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PolicyType = (Get-SingletonObject "DeviceComplianceV2Type")
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Compliance Scripts
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
Class ComplianceScriptsType : IntunePolicyTypeBase
|
||||
{
|
||||
ComplianceScriptsType() : Base()
|
||||
{
|
||||
([ComplianceScriptsType]$this).Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "ComplianceGroup")
|
||||
$this._APITitle = "Compliance Scripts"
|
||||
$this._PolicyName = "Compliance Script"
|
||||
$this._ID = "ComplianceScripts"
|
||||
$this._API = "deviceManagement/deviceComplianceScripts"
|
||||
$this._Permissions = @("DeviceManagementConfiguration.ReadWrite.All")
|
||||
$this._ObjectClass = "ComplianceScriptObject"
|
||||
$this._Icon = "Scripts"
|
||||
# Graph rejects PATCHing the read-only version back ("Invalid property
|
||||
# name: Version").
|
||||
$this._PropertiesToRemoveForUpdate = @('version')
|
||||
# Custom compliance scripts are REFERENCED by compliance policies, not
|
||||
# assigned to devices - the portal has no Assignments blade and Graph's
|
||||
# /assign action rejects the request (400 "Action parameters do not
|
||||
# contain parameter 'deviceHealthScriptAssignments'").
|
||||
$this._SupportsAssignments = $false
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Class ComplianceScriptObject : IntunePolicyBase
|
||||
{
|
||||
ComplianceScriptObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
ComplianceScriptObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PlatformName = Get-LanguageString "Platform.windows10AndLater"
|
||||
$this._PolicyType = (Get-SingletonObject "ComplianceScriptsType")
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Compliance Scripts - Linux
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
Class ComplianceScriptsLinuxType : ReusableSettingsTypeBase
|
||||
{
|
||||
ComplianceScriptsLinuxType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "ComplianceGroup")
|
||||
$this._APITitle = "Compliance Scripts (Linux)"
|
||||
$this._PolicyName = "Compliance Script"
|
||||
$this._ID = "ComplianceScriptsScriptsLinux"
|
||||
$this._QueryList = "?`$filter=settingDefinitionId eq 'linux_customcompliance_discoveryscript_reusablesetting'"
|
||||
# Reusable settings carry no roleScopeTagIds in the Graph schema.
|
||||
$this._ScopeTagProperty = ""
|
||||
$this._ObjectClass = "ComplianceScriptLinuxObject"
|
||||
$this._Icon = "Scripts"
|
||||
$this._Folder = "ReusableSettings"
|
||||
$this._PolicyTypeOrder = 140
|
||||
$this._ExpandAssignmentsList = $false
|
||||
$this._SupportsAssignments = $false
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
|
||||
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
if($PolicyObject.'@odata.type' -ne "#microsoft.graph.deviceManagementReusablePolicySetting") { return $false }
|
||||
|
||||
if($PolicyObject.settingDefinitionId -eq 'linux_customcompliance_discoveryscript_reusablesetting') {
|
||||
return $true
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
Class ComplianceScriptLinuxObject : ReusableSettingsObjectBase
|
||||
{
|
||||
ComplianceScriptLinuxObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
ComplianceScriptLinuxObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PolicyType = (Get-SingletonObject "ComplianceScriptsLinuxType")
|
||||
$this._PlatformName = Get-LanguageString "Platform.linux" -IgnoreMissing
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Compliance Notifications
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
Class NotificationsType : IntunePolicyTypeBase
|
||||
{
|
||||
NotificationsType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "ComplianceGroup")
|
||||
$this._PolicyName = "Notifications"
|
||||
$this._ID = "Notifications"
|
||||
$this._HasPlatform = $false
|
||||
$this._API = "deviceManagement/notificationMessageTemplates"
|
||||
#$this._QueryList = "?`$filter=displayName ne 'EnrollmentNotificationInternalMEO'"
|
||||
$this._Permissions = @("DeviceManagementServiceConfig.ReadWrite.All")
|
||||
$this._ObjectClass = "NotificationObject"
|
||||
$this._ImportOrder = 40
|
||||
$this._Expand = "localizedNotificationMessages"
|
||||
$this._ExpandAssignmentsList = $false
|
||||
# notificationMessageTemplate has no `assignments` navigation property at
|
||||
# all (its only nav is localizedNotificationMessages), so both the nav GET
|
||||
# and ?$expand=assignments return 400. Notification templates are targeted
|
||||
# from compliance policies, not assigned - don't ask for assignments.
|
||||
$this._SupportsAssignments = $false
|
||||
# notificationMessageTemplate has no description property in Graph.
|
||||
$this._HasDescription = $false
|
||||
# localizedNotificationMessages is a navigation property - PATCHing it
|
||||
# inline is rejected; messages are managed on their own sub-endpoint.
|
||||
$this._PropertiesToRemoveForUpdate = @('localizedNotificationMessages','defaultLocale')
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
|
||||
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
Remove-Property $PolicyObject.JsonObject "defaultLocale"
|
||||
Remove-Property $PolicyObject.JsonObject "localizedNotificationMessages"
|
||||
Remove-Property $PolicyObject.JsonObject "localizedNotificationMessages@odata.context"
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
PostImportCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
|
||||
{
|
||||
$this.UpdateNotificationMessages($PolicyObject, $SourceObject.JsonObject.localizedNotificationMessages)
|
||||
}
|
||||
|
||||
Hidden UpdateNotificationMessages($PolicyObject, $localizedNotificationMessages)
|
||||
{
|
||||
if(-not $localizedNotificationMessages) {
|
||||
return
|
||||
}
|
||||
|
||||
$updated = $false
|
||||
foreach($localizedNotificationMessage in $localizedNotificationMessages)
|
||||
{
|
||||
Remove-GraphPropertiesForImport $this $localizedNotificationMessage
|
||||
$response = Invoke-MSGraphAPI -Url "$($this.API)/$($PolicyObject.Id)/localizedNotificationMessages" -Body ($localizedNotificationMessage | ConvertTo-Json -Depth 20) -Method "POST" -FullResponseObject
|
||||
if($response.Success) {
|
||||
Write-log "Notification message '$($localizedNotificationMessage.subject)' ($($localizedNotificationMessage.locale)) added successfully"
|
||||
$updated = $true
|
||||
}
|
||||
else {
|
||||
Write-log "Failed to add notification message: '$($localizedNotificationMessage.subject)' ($($localizedNotificationMessage.locale))" 3
|
||||
}
|
||||
}
|
||||
if($updated) {
|
||||
[void]$PolicyObject.Get()
|
||||
}
|
||||
}
|
||||
|
||||
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
if($PolicyObject.'displayName' -eq "EnrollmentNotificationInternalMEO") { return $false } # Skip built in
|
||||
|
||||
return (([IntunePolicyTypeBase]$this).CheckPolicy($PolicyObject))
|
||||
}
|
||||
}
|
||||
|
||||
Class NotificationObject : IntunePolicyBase
|
||||
{
|
||||
NotificationObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
NotificationObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PolicyType = (Get-SingletonObject "NotificationsType")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,524 +0,0 @@
|
||||
#ImportOrder 220
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Apple Update Group
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||
class ConditionalAccessGroup : IntunePolicyGroupBase
|
||||
{
|
||||
ConditionalAccessGroup() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._ID = "ConditionalAccess"
|
||||
$this._Name = "Conditional Access"
|
||||
$this._Icon = "ConditionalAccess"
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Conditional Access Policies
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
# region Conditional Access Policies
|
||||
class ConditionalAccessType : IntunePolicyTypeBase
|
||||
{
|
||||
ConditionalAccessType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "ConditionalAccessGroup")
|
||||
$this._PolicyName = "Conditional Access"
|
||||
$this._ID = "ConditionalAccess"
|
||||
$this._HasPlatform = $false
|
||||
$this._API = "identity/conditionalAccess/policies"
|
||||
# Entra object - no roleScopeTagIds in the Graph schema (a PATCH
|
||||
# no-ops), so no scope-tag support.
|
||||
$this._ScopeTagProperty = ""
|
||||
$this._Dependencies = @("NamedLocations","Applications","TermsOfUse","AuthenticationStrengths","AssignmentFilters")
|
||||
$this._Permissions = @("Policy.ReadWrite.ConditionalAccess")
|
||||
$this._ObjectClass = "ConditionalAccessObject"
|
||||
$this._ExpandAssignmentsList = $false
|
||||
$this._SupportsAssignments = $false
|
||||
$this._HasPageSizeSupport = $false
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
|
||||
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
# Tenant-lockout guard: rewrite the imported policy's state per the
|
||||
# ConditionalAccessState setting (default: disabled). Logic lives in
|
||||
# Internal/IntuneManager.ps1 so it is unit-testable.
|
||||
Set-CAPolicyImportState $PolicyObject
|
||||
|
||||
if($PolicyObject.grantControls.authenticationStrength)
|
||||
{
|
||||
$PolicyObject.JsonObject.grantControls.operator = "AND"
|
||||
#$tmpObj = Get-GraphObjectFromFile $file
|
||||
|
||||
#$authSetting = [PSCustomObject]@{
|
||||
# id = $tmpObj.grantControls.authenticationStrength.id
|
||||
#}
|
||||
#$PolicyObject.JsonObject.grantControls.authenticationStrength = $authSetting
|
||||
}
|
||||
|
||||
if($PolicyObject.JsonObject.sessionControls.disableResilienceDefaults -eq $false)
|
||||
{
|
||||
$PolicyObject.JsonObject.sessionControls.disableResilienceDefaults = $null
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
PostExportCommand([PSCustomObject]$PolicyObject, [String]$PathToFile)
|
||||
{
|
||||
$ids = @()
|
||||
foreach($id in ($PolicyObject.JsonObject.conditions.users.includeGroups + $PolicyObject.JsonObject.conditions.users.excludeGroups))
|
||||
{
|
||||
if($id -in $ids) { continue }
|
||||
elseif($id -eq "GuestsOrExternalUsers") { continue }
|
||||
elseif($id -eq "All") { continue }
|
||||
elseif($id -eq "None") { continue }
|
||||
|
||||
$ids += $id
|
||||
Add-GraphMigrationObject $id "groups" "Group" ([IO.Path]::GetDirectoryName($PathToFile)) $PolicyObject._TokenId
|
||||
}
|
||||
|
||||
foreach($id in ($PolicyObject.JsonObject.conditions.users.includeUsers + $PolicyObject.JsonObject.conditions.users.excludeUsers))
|
||||
{
|
||||
if($id -in $ids) { continue }
|
||||
elseif($id -eq "GuestsOrExternalUsers") { continue }
|
||||
elseif($id -eq "All") { continue }
|
||||
elseif($id -eq "None") { continue }
|
||||
|
||||
$ids += $id
|
||||
Add-GraphMigrationObject $id "users" "User" ([IO.Path]::GetDirectoryName($PathToFile)) $PolicyObject._TokenId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Class ConditionalAccessObject : IntunePolicyBase
|
||||
{
|
||||
ConditionalAccessObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
ConditionalAccessObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PolicyType = (Get-SingletonObject "ConditionalAccessType")
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Authentication Strengths
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
# region Authentication Strengths
|
||||
class AuthenticationStrengthsType : IntunePolicyTypeBase
|
||||
{
|
||||
AuthenticationStrengthsType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "ConditionalAccessGroup")
|
||||
$this._PolicyName = "Authentication Strengths"
|
||||
$this._ID = "AuthenticationStrengths"
|
||||
$this._HasPlatform = $false
|
||||
$this._API = "identity/conditionalAccess/authenticationStrengths/policies"
|
||||
$this._ImportOrder = 45
|
||||
$this._Permissions = @("Policy.ReadWrite.ConditionalAccess")
|
||||
$this._ObjectClass = "AuthenticationStrengthObject"
|
||||
$this._ExpandAssignmentsList = $false
|
||||
$this._SupportsAssignments = $false
|
||||
$this._TopItems = 0
|
||||
$this._Icon = "ConditionalAccess"
|
||||
$this._HasPageSizeSupport = $false
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
|
||||
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
if($PolicyObject.Object.policyType -ne "custom")
|
||||
{
|
||||
Write-Log "Built-in Authentication Strength objects cannot be imported" 2
|
||||
@{ "Import" = $false }
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
Class AuthenticationStrengthObject : IntunePolicyBase
|
||||
{
|
||||
AuthenticationStrengthObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
AuthenticationStrengthObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PolicyType = (Get-SingletonObject "AuthenticationStrengthsType")
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Authentication Context
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
# region Authentication Context
|
||||
class AuthenticationContextType : IntunePolicyTypeBase
|
||||
{
|
||||
AuthenticationContextType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "ConditionalAccessGroup")
|
||||
$this._PolicyName = "Authentication Context"
|
||||
$this._ID = "AuthenticationContext"
|
||||
$this._HasPlatform = $false
|
||||
$this._HasModified = $false
|
||||
# Endpoint rejects a name $filter (verified live 2026-08-27) - searches filter client-side.
|
||||
$this._SupportsNameFilter = $false
|
||||
$this._API = "identity/conditionalAccess/authenticationContextClassReferences"
|
||||
$this._PropertiesToRemove = @("@odata.type")
|
||||
$this._SkipRemoveProperties = @('Id')
|
||||
$this._ImportOrder = 46
|
||||
$this._Permissions = @("Policy.ReadWrite.ConditionalAccess")
|
||||
$this._ObjectClass = "AuthenticationContextObject"
|
||||
$this._ExpandAssignmentsList = $false
|
||||
$this._SupportsAssignments = $false
|
||||
$this._TopItems = 0
|
||||
$this._Icon = "ConditionalAccess"
|
||||
$this._HasPageSizeSupport = $false
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Class AuthenticationContextObject : IntunePolicyBase
|
||||
{
|
||||
AuthenticationContextObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
AuthenticationContextObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PolicyType = (Get-SingletonObject "AuthenticationContextType")
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Authentication Context
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
# region Authentication Context
|
||||
class NamedLocationType : IntunePolicyTypeBase
|
||||
{
|
||||
NamedLocationType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "ConditionalAccessGroup")
|
||||
$this._PolicyName = "Named Locations"
|
||||
$this._ID = "NamedLocations"
|
||||
$this._HasPlatform = $false
|
||||
$this._API = "identity/conditionalAccess/namedLocations"
|
||||
# Entra object - no roleScopeTagIds in the Graph schema.
|
||||
$this._ScopeTagProperty = ""
|
||||
$this._ImportOrder = 50
|
||||
$this._Permissions = @("Policy.ReadWrite.ConditionalAccess")
|
||||
$this._ObjectClass = "NamedLocationObject"
|
||||
$this._ExpandAssignmentsList = $false
|
||||
$this._SupportsAssignments = $false
|
||||
$this._HasPageSizeSupport = $false
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Class NamedLocationObject : IntunePolicyBase
|
||||
{
|
||||
NamedLocationObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
NamedLocationObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PolicyType = (Get-SingletonObject "NamedLocationType")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Terms of use
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
# region Terms of use
|
||||
class TermsOfUseType : IntunePolicyTypeBase
|
||||
{
|
||||
TermsOfUseType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "ConditionalAccessGroup")
|
||||
$this._PolicyName = "Terms of use"
|
||||
$this._ID = "TermsOfUse"
|
||||
$this._HasPlatform = $false
|
||||
$this._HasModified = $false
|
||||
# Endpoint rejects a name $filter (verified live 2026-08-27) - searches filter client-side.
|
||||
$this._SupportsNameFilter = $false
|
||||
$this._API = "identityGovernance/termsOfUse/agreements"
|
||||
# Entra object - no roleScopeTagIds in the Graph schema.
|
||||
$this._ScopeTagProperty = ""
|
||||
$this._ImportOrder = 75
|
||||
$this._Expand = "files"
|
||||
$this._QueryList = "?`$expand=files"
|
||||
$this._Permissions = @("Agreement.ReadWrite.All")
|
||||
$this._ObjectClass = "TermsOfUseObject"
|
||||
$this._ExpandAssignmentsList = $false
|
||||
$this._SupportsAssignments = $false
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
|
||||
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
$pkgPath = Get-SettingValue "IntuneAppPackagesFolder"
|
||||
|
||||
if(-not $pkgPath -or [IO.Directory]::Exists($pkgPath) -eq $false)
|
||||
{
|
||||
Write-Log "Intune app directory is either missing or does not exist" 2
|
||||
}
|
||||
|
||||
# The agreement document is resolved in this order, per localization:
|
||||
# 1. fileData.data already on the object - an export carries the PDF
|
||||
# inline, fetched per localization by the sub-resource contract.
|
||||
# 2. <fileName> next to the exported json (FileInfo), then in the app
|
||||
# packages folder - for a json that was exported without the data.
|
||||
# 3. The source object, for an in-memory COPY (below).
|
||||
# Refusing rather than proceeding matters: an agreement created without
|
||||
# its document lists fine but /file, /files and /file/localizations all
|
||||
# 404, and a later list with $expand=files returns 500 for the WHOLE
|
||||
# collection. Three of those were found in the test tenant on 2026-09-06
|
||||
# and had to be deleted by hand.
|
||||
#
|
||||
# An earlier version of this block required the PDF on disk whenever
|
||||
# FileInfo was set and ignored the embedded data. That only held together
|
||||
# because Clone() used to drop FileInfo, so an import from disk never
|
||||
# reached it with FileInfo populated. Once Clone() kept FileInfo, every
|
||||
# import of an export with an inline PDF was refused.
|
||||
$hasData = { param($f) ($f.PSObject.Properties['fileData'] -and $f.fileData -and
|
||||
$f.fileData.PSObject.Properties['data'] -and $f.fileData.data) }
|
||||
|
||||
if($PolicyObject.FileInfo) {
|
||||
foreach($file in $PolicyObject.Object.Files)
|
||||
{
|
||||
if(& $hasData $file) { continue }
|
||||
|
||||
$pdfFile = $null
|
||||
if($PolicyObject.FileInfo.Directory.FullName)
|
||||
{
|
||||
$pdfFile = [IO.Path]::Combine($PolicyObject.FileInfo.Directory.FullName, "$($file.fileName)")
|
||||
}
|
||||
if(($null -eq $pdfFile -or [IO.File]::Exists($pdfFile) -eq $false) -and $pkgPath)
|
||||
{
|
||||
$pdfFile = [IO.Path]::Combine($pkgPath, "$($file.fileName)")
|
||||
}
|
||||
if($pdfFile -and [IO.File]::Exists($pdfFile))
|
||||
{
|
||||
Write-Log "Add file data: $pdfFile"
|
||||
$bytes = [IO.File]::ReadAllBytes($pdfFile)
|
||||
$file | Add-Member -MemberType NoteProperty -Name 'fileData' -Value ([PSCustomObject]@{ data = [Convert]::ToBase64String($bytes) }) -Force
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Log "Terms of use file $($file.fileName) not found next to the export or in the app packages folder" 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$touFiles = @($PolicyObject.Object.Files)
|
||||
$touMissing = @($touFiles | Where-Object { -not (& $hasData $_) })
|
||||
|
||||
if($touFiles.Count -gt 0 -and $touMissing.Count -gt 0)
|
||||
{
|
||||
$touSource = $PolicyObject._ClonedFromObject
|
||||
if($touSource -and $touSource.Id)
|
||||
{
|
||||
Write-Log "Terms of use '$($PolicyObject.Name)': agreement file(s) not loaded - fetching them from the source object"
|
||||
$touTokenId = 0
|
||||
if($touSource.PSObject.Properties['_TokenId'] -and $null -ne $touSource._TokenId) {
|
||||
$touTokenId = [int]$touSource._TokenId
|
||||
}
|
||||
elseif($PolicyObject.PSObject.Properties['_TokenId'] -and $null -ne $PolicyObject._TokenId) {
|
||||
$touTokenId = [int]$PolicyObject._TokenId
|
||||
}
|
||||
try { Invoke-PolicySubresourceFetch -Policies @($touSource) -TokenId $touTokenId | Out-Null }
|
||||
catch { Write-LogError "Failed to fetch terms of use file data from the source object" $_.Exception }
|
||||
|
||||
foreach($touFile in $touMissing)
|
||||
{
|
||||
$srcFile = @($touSource.Object.Files) | Where-Object { $_.id -eq $touFile.id } | Select-Object -First 1
|
||||
if($srcFile -and $srcFile.PSObject.Properties['fileData'] -and $srcFile.fileData -and $srcFile.fileData.data)
|
||||
{
|
||||
$touFile | Add-Member -MemberType NoteProperty -Name 'fileData' -Value ([PSCustomObject]@{ data = $srcFile.fileData.data }) -Force
|
||||
}
|
||||
}
|
||||
|
||||
$touMissing = @($touFiles | Where-Object { -not (& $hasData $_) })
|
||||
}
|
||||
}
|
||||
|
||||
if($touFiles.Count -eq 0 -or $touMissing.Count -gt 0)
|
||||
{
|
||||
Write-Log "Terms of use '$($PolicyObject.Name)': the agreement document is missing and could not be loaded from the source. The object will not be imported - creating it would leave an agreement with no document, which breaks the whole Terms of Use list." 2
|
||||
return @{"Import" = $false}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
PostExportCommand([PSCustomObject]$PolicyObject, [String]$PathToFile)
|
||||
{
|
||||
if(-not $PathToFile) { return }
|
||||
$fi = [IO.FileInfo]$PathToFile
|
||||
# File binary was fetched into fileData.data by TermsOfUseObject's
|
||||
# sub-resource contract during hydration. Write each to disk; no re-fetch.
|
||||
foreach($file in @($PolicyObject.Object.Files))
|
||||
{
|
||||
$data = $null
|
||||
if($file.PSObject.Properties['fileData'] -and $file.fileData -and $file.fileData.PSObject.Properties['data']) {
|
||||
$data = $file.fileData.data
|
||||
}
|
||||
if($data)
|
||||
{
|
||||
Write-Log "Save file $($file.FileName)"
|
||||
$fileName = [IO.Path]::Combine($fi.DirectoryName, "$($file.FileName)")
|
||||
[IO.File]::WriteAllBytes($fileName, [System.Convert]::FromBase64String($data))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Class TermsOfUseObject : IntunePolicyBase
|
||||
{
|
||||
# Maps each in-flight file-data request key back to its file object so
|
||||
# ApplySubResourceBatchResult can attach the fetched binary.
|
||||
Hidden [Hashtable]$_SubResourceState = $null
|
||||
|
||||
TermsOfUseObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
TermsOfUseObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PolicyType = (Get-SingletonObject "TermsOfUseType")
|
||||
|
||||
# Agreement file binaries aren't in the body; fetch each localization's
|
||||
# fileData via the sub-resource contract.
|
||||
$this._HasSubResourceBatch = $true
|
||||
}
|
||||
|
||||
# The agreements/<id>/file/localizations('<fid>')/fileData/data API lives
|
||||
# ONLY here — was previously duplicated in Sync-BulkExportTermsOfUseFiles
|
||||
# (Internal/PolicyHydrateExtras.ps1) and TermsOfUseType.PostExportCommand.
|
||||
[PSCustomObject[]] GetSubResourceBatchRequests([int]$Phase)
|
||||
{
|
||||
if($Phase -ne 1) { return @() }
|
||||
$this._SubResourceState = @{}
|
||||
$reqs = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
foreach($file in @($this.Object.Files)) {
|
||||
if(-not $file.id) { continue }
|
||||
$existing = $null
|
||||
if($file.PSObject.Properties['fileData'] -and $file.fileData -and $file.fileData.PSObject.Properties['data']) {
|
||||
$existing = $file.fileData.data
|
||||
}
|
||||
if($existing) { continue }
|
||||
$key = "toufile_$($file.id)"
|
||||
$this._SubResourceState[$key] = $file
|
||||
[void]$reqs.Add([PSCustomObject]@{
|
||||
Key = $key
|
||||
Url = "agreements/$($this.Id)/file/localizations('$($file.id)')/fileData/data"
|
||||
})
|
||||
}
|
||||
return $reqs.ToArray()
|
||||
}
|
||||
|
||||
[PSCustomObject[]] ApplySubResourceBatchResult([int]$Phase, [string]$Key, $Body)
|
||||
{
|
||||
if($Phase -ne 1 -or $null -eq $this._SubResourceState -or -not $this._SubResourceState.ContainsKey($Key)) { return @() }
|
||||
$file = $this._SubResourceState[$Key]
|
||||
|
||||
$data = $null
|
||||
if($Body -is [string]) { $data = $Body }
|
||||
elseif($Body -and $Body.PSObject.Properties['value']) { $data = $Body.value }
|
||||
elseif($Body -and $Body.PSObject.Properties['data']) { $data = $Body.data }
|
||||
|
||||
if($data) {
|
||||
if($file.PSObject.Properties['fileData'] -and $file.fileData) {
|
||||
if($file.fileData.PSObject.Properties['data']) { $file.fileData.data = $data }
|
||||
else { $file.fileData | Add-Member -MemberType NoteProperty -Name 'data' -Value $data -Force }
|
||||
}
|
||||
else {
|
||||
Add-Member -InputObject $file -MemberType NoteProperty -Name 'fileData' -Value ([PSCustomObject]@{ data = $data }) -Force
|
||||
}
|
||||
}
|
||||
return @()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,327 +0,0 @@
|
||||
#ImportOrder 205
|
||||
|
||||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||
class EndpointSecurityGroup : IntunePolicyGroupBase
|
||||
{
|
||||
EndpointSecurityGroup() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._ID = "EndpointSecurity"
|
||||
$this._Name = "Endpoint Security"
|
||||
$this._Icon = "EndpointSecurity"
|
||||
$this._ExtraColumns = @("TemplateFamily=Parent type") # two of three members supply it
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Intents
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
# region Intents
|
||||
|
||||
class EndpointSecurityType : IntunePolicyTypeBase
|
||||
{
|
||||
EndpointSecurityType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "EndpointSecurityGroup")
|
||||
$this._PolicyName = "Endpoint Security"
|
||||
$this._APITitle = "Endpoint Security (Intents)"
|
||||
|
||||
$this._ID = "EndpointSecurity"
|
||||
$this._API = "deviceManagement/intents"
|
||||
$this._PolicyBaseName = "Intents"
|
||||
$this._PropertiesToRemove = @('Settings','@OData.Type')
|
||||
# Graph: "Properties not patchable specified: IsAssigned,
|
||||
# IsMigratingToConfigurationPolicy, TemplateId". Settings are updated
|
||||
# via the /updateSettings action, not the intent PATCH.
|
||||
$this._PropertiesToRemoveForUpdate = @('isAssigned','isMigratingToConfigurationPolicy','templateId','settings')
|
||||
$this._Permissions = @("DeviceManagementConfiguration.ReadWrite.All")
|
||||
$this._SubTypeColumn = "PolicyName=Type" # per-row template name (Antivirus, Firewall, ...)
|
||||
$this._ExtraColumns = @("TemplateFamily=Parent type")
|
||||
$this._Expand = "Settings"
|
||||
$this._Icon = "EndpointSecurity"
|
||||
$this._Dependencies = @("ReusableSettings")
|
||||
$this._ObjectClass = "IntentObject"
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
|
||||
[Hashtable]GetCompareConfig()
|
||||
{
|
||||
return @{
|
||||
Prop = "settings"
|
||||
GetKey = { param($s) "$($s.definitionId)" }
|
||||
GetValue = { param($s) Get-IntentSettingValue $s }
|
||||
GetCategory = { param($s) Get-IntentSettingCategory $s }
|
||||
}
|
||||
}
|
||||
|
||||
Hidden [PSCustomObject]GetTemplate($TemplateId)
|
||||
{
|
||||
# Tag the baseline-template cache with TenantCache_<tenantId> so it gets wiped by
|
||||
# Clear-TenantCache on disconnect — previous code stored it untagged and the
|
||||
# previous tenant's templates would leak across tenant switches.
|
||||
$tenantId = $script:OrganizationId
|
||||
$cacheId = "BaseLineTemplates_$tenantId"
|
||||
$baseLineTemplates = Get-CacheObject $cacheId
|
||||
if(-not $baseLineTemplates)
|
||||
{
|
||||
$baseLineTemplates = (Invoke-MSGraphAPI -Url "/deviceManagement/templates").Value
|
||||
if($baseLineTemplates) {
|
||||
Set-CacheObject $cacheId $baseLineTemplates "TenantCache_$tenantId"
|
||||
}
|
||||
}
|
||||
|
||||
if(-not $baseLineTemplates) { return $null}
|
||||
|
||||
return ($baseLineTemplates | Where-Object Id -eq $TemplateId)
|
||||
}
|
||||
|
||||
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
return (@{
|
||||
"API"="deviceManagement/templates/$($PolicyObject.JsonObject.templateId)/createInstance"
|
||||
})
|
||||
}
|
||||
|
||||
PostImportCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
|
||||
{
|
||||
$this.UpdateSettings($PolicyObject, $SourceObject.JsonObject.Settings)
|
||||
}
|
||||
|
||||
Hidden UpdateSettings($PolicyObject, $Settings)
|
||||
{
|
||||
if(($Settings | Measure-Object).Count -eq 0) { return }
|
||||
|
||||
$clonedSettings = @()
|
||||
$Settings | ConvertTo-Json -Depth 50 | ConvertFrom-Json | ForEach-Object { $clonedSettings += $_ }
|
||||
$newSettings = ([HashTable]@{
|
||||
"settings" = $clonedSettings
|
||||
})
|
||||
Remove-GraphPropertiesForImport $PolicyObject $newSettings.Settings -KeepProperties "@odata.type"
|
||||
|
||||
$response = Invoke-MSGraphAPI -Url "$($this.API)/$($PolicyObject.id)/updateSettings" -Body ($newSettings | ConvertTo-Json -Depth 50) -Method "POST" -FullResponseObject -TokenId $PolicyObject._TokenID
|
||||
if($response.Success) {
|
||||
Write-Log "Settings updated successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class IntentObject : IntunePolicyBase
|
||||
{
|
||||
Hidden [PSCustomObject]$_BaselineTemplate = $null
|
||||
|
||||
IntentObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
IntentObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PolicyType = (Get-SingletonObject "EndpointSecurityType")
|
||||
|
||||
if($this._PolicyType -and $this.JsonObject.templateId) {
|
||||
$this._BaselineTemplate = $this._PolicyType.GetTemplate($this.JsonObject.templateId)
|
||||
|
||||
if($this._BaselineTemplate) {
|
||||
Add-ObjectProperty $this "BaselineTemplate" { $this._BaselineTemplate }
|
||||
Add-ObjectProperty $this "TemplateFamily" { (Get-EndpointSecurityCategoryName (?: ($this.BaselineTemplate.templateSubtype -eq "none") $this.BaselineTemplate.templateType $this.BaselineTemplate.templateSubtype)) } # ToDo: Get actual language string
|
||||
Add-ObjectProperty $this "Category" { $this.TemplateFamily }
|
||||
Add-ObjectProperty $this "TemplateVersion" { $this.BaselineTemplate.versionInfo }
|
||||
$this._PlatformName = Get-LanguageString "Platform.$($this._BaselineTemplate.platformType)" -IgnoreMissing
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
$this._PolicyName = ?? $this.BaselineTemplate.displayName $this._PolicyType.PolicyBaseType
|
||||
}
|
||||
}
|
||||
|
||||
function Get-EndpointSecurityCategoryName
|
||||
{
|
||||
param($TemplateType)
|
||||
|
||||
if(-not $TemplateType)
|
||||
{
|
||||
Write-Log "Get-EndpointSecurityCategoryName called with empty Category" 2
|
||||
return
|
||||
}
|
||||
|
||||
$returnString = $null
|
||||
|
||||
if($TemplateType.StartsWith("endpointSecurity"))
|
||||
{
|
||||
$TemplateType = $TemplateType.Substring(16)
|
||||
}
|
||||
|
||||
if($TemplateType -eq "none")
|
||||
{
|
||||
return ""
|
||||
}
|
||||
elseif($TemplateType -eq "accountProtection")
|
||||
{
|
||||
$returnString = Get-LanguageString "SecurityTemplate.accountProtection"
|
||||
}
|
||||
elseif($TemplateType -eq "antivirus")
|
||||
{
|
||||
$returnString = Get-LanguageString "SecurityTemplate.antivirus"
|
||||
}
|
||||
elseif($TemplateType -eq "diskEncryption")
|
||||
{
|
||||
$returnString = Get-LanguageString "SecurityTemplate.diskEncryption"
|
||||
}
|
||||
elseif($TemplateType -eq "endpointDetectionReponse" -or $TemplateType -eq "EndpointDetectionAndResponse")
|
||||
{
|
||||
$returnString = Get-LanguageString "SecurityTemplate.eDR"
|
||||
}
|
||||
elseif($TemplateType -eq "attackSurfaceReduction")
|
||||
{
|
||||
$returnString = Get-LanguageString "SecurityTemplate.aSR"
|
||||
}
|
||||
elseif($TemplateType -eq "attackSurfaceReduction")
|
||||
{
|
||||
$returnString = Get-LanguageString "SecurityTemplate.aSR"
|
||||
}
|
||||
elseif($TemplateType -eq "firewall")
|
||||
{
|
||||
$returnString = Get-LanguageString "SecurityTemplate.firewall"
|
||||
}
|
||||
elseif($TemplateType -eq "applicationControl")
|
||||
{
|
||||
$returnString = Get-LanguageString "PolicyType.applicationControl"
|
||||
}
|
||||
elseif($TemplateType -eq "securityBaseline" -or
|
||||
$TemplateType -eq "advancedThreatProtectionSecurityBaseline" -or
|
||||
$TemplateType -eq "microsoftEdgeSecurityBaseline" -or
|
||||
$TemplateType -eq "baseline")
|
||||
{
|
||||
$returnString = Get-LanguageString "Titles.securityBaselines"
|
||||
}
|
||||
elseif($TemplateType -eq "enrollmentConfiguration")
|
||||
{
|
||||
$returnString = Get-LanguageString "SettingDetails.enrollment"
|
||||
}
|
||||
|
||||
if([String]::IsNullOrEmpty($returnString))
|
||||
{
|
||||
Write-Log "Could not translate templateSubtype $TemplateType" 2
|
||||
return $TemplateType
|
||||
}
|
||||
|
||||
return $returnString
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Settings Catalog
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
# region Settings catalog
|
||||
class EndpointSecuritySettingsCatalogType : SettingsCatalogTypeBase
|
||||
{
|
||||
EndpointSecuritySettingsCatalogType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "EndpointSecurityGroup")
|
||||
$this._ID = "EndpointSecuritySettingsCatalog"
|
||||
# The template version a policy was created from - how you spot an
|
||||
# antivirus or baseline policy still on a superseded template.
|
||||
$this._ExtraColumns = @("Object.templateReference.templateDisplayVersion=Template version")
|
||||
$this._APITitle = "Endpoint Security (Settings Catalog)"
|
||||
$this._QueryList = "?`$filter=templateReference/templateFamily eq 'baseline' or templateReference/templateFamily eq 'endpointSecurityAccountProtection' or templateReference/templateFamily eq 'endpointSecurityAntivirus' or templateReference/templateFamily eq 'endpointSecurityDiskEncryption' or templateReference/templateFamily eq 'endpointSecurityEndpointDetectionAndResponse' or templateReference/templateFamily eq 'endpointSecurityAttackSurfaceReduction' or templateReference/templateFamily eq 'endpointSecurityFirewall' or templateReference/templateFamily eq 'endpointSecurityApplicationControl'"
|
||||
$this._Icon = "EndpointSecurity"
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
|
||||
$this._PolicyTypeOrder = 100
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Reusable Settings
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
# region Reusable Settings
|
||||
class ReusableSettingsEndpointSecurityType : ReusableSettingsTypeBase
|
||||
{
|
||||
ReusableSettingsEndpointSecurityType() : Base()
|
||||
{
|
||||
([ReusableSettingsEndpointSecurityType]$this).Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "EndpointSecurityGroup")
|
||||
# !!! For now...Should check actual supported settingDefinitionId for Endpoint Security.
|
||||
$this._QueryList = "?`$filter=settingDefinitionId ne 'linux_customcompliance_discoveryscript_reusablesetting'"
|
||||
$this._ID = "ReusableSettingsEndpointSecurity"
|
||||
$this._HasPlatform = $false
|
||||
$this._Folder = "ReusableSettings"
|
||||
$this._ObjectClass = "ReusableSettingEndpointSecurityObject"
|
||||
$this._ImportOrder = 75
|
||||
$this._PolicyTypeOrder = 145
|
||||
$this._Icon = "EndpointSecurity"
|
||||
$this._ExpandAssignmentsList = $false
|
||||
$this._SupportsAssignments = $false
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
|
||||
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
if($PolicyObject.'@odata.type' -ne "#microsoft.graph.deviceManagementReusablePolicySetting") { return $false }
|
||||
|
||||
# !!! For now...Should check actual supported settingDefinitionId for Endpoint Security. deviceConfigurationScripts
|
||||
if($PolicyObject.settingDefinitionId -ne 'linux_customcompliance_discoveryscript_reusablesetting') {
|
||||
return $true
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
Class ReusableSettingEndpointSecurityObject : ReusableSettingsObjectBase
|
||||
{
|
||||
ReusableSettingEndpointSecurityObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
ReusableSettingEndpointSecurityObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PolicyType = (Get-SingletonObject "ReusableSettingsEndpointSecurityType")
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -1,692 +0,0 @@
|
||||
#ImportOrder 220
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Script Group
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||
class DeviceEnrollmentGroup : IntunePolicyGroupBase
|
||||
{
|
||||
DeviceEnrollmentGroup() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._ID = "DeviceEnrollments"
|
||||
$this._Name = "Device enrollment"
|
||||
$this._Icon = "WindowsEnrollments"
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Autopilot
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
# region Autopilot
|
||||
class AutopilotType : IntunePolicyTypeBase
|
||||
{
|
||||
AutopilotType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "DeviceEnrollmentGroup")
|
||||
$this._PolicyName = "Autopilot"
|
||||
$this._ID = "Autopilot"
|
||||
# Platform default: the endpoint serves exactly one platform and the
|
||||
# objects carry no platforms/platformType field, so the column would
|
||||
# otherwise be blank (windowsAutopilotDeploymentProfiles is
|
||||
# Windows-only).
|
||||
$this._PlatformName = Get-LanguageString "Platform.windows" -IgnoreMissing
|
||||
$this._API = "deviceManagement/windowsAutopilotDeploymentProfiles"
|
||||
$this._CopyDefaultName = "%Name% Copy"
|
||||
$this._Permissions = @("DeviceManagementServiceConfig.ReadWrite.All")
|
||||
$this._ObjectClass = "AutopilotObject"
|
||||
$this._PropertiesToRemoveForUpdate = @('managementServiceAppId')
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
|
||||
[Hashtable]PreDeleteCommand([IntunePolicyBase]$PolicyObject)
|
||||
{
|
||||
Write-Log "Delete AutoPilot profile assignments"
|
||||
|
||||
foreach($assignment in $PolicyObject.Assignments)
|
||||
{
|
||||
if($assignment.Source -ne "direct") { continue }
|
||||
|
||||
$api = "$($PolicyObject.PolicyType.API)/$($PolicyObject.Id)/assignments/$($assignment.Id)"
|
||||
|
||||
$repsone = Invoke-MSGraphAPI -Url $api -HttpMethod "DELETE" -TokenId $PolicyObject._TokenId -FullResponseObject
|
||||
if($repsone.Success)
|
||||
{
|
||||
Write-LogDebug "Assignemnt with Id $($assignment.Id) deleted successfully"
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
[Hashtable]PreImportAssignmentsCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
|
||||
{
|
||||
return (Add-GraphAssignmentsToObject $PolicyObject $SourceObject)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Class AutopilotObject : IntunePolicyBase
|
||||
{
|
||||
AutopilotObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
AutopilotObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
|
||||
$this._PlatformName = Get-LanguageString "Platform.windows" -IgnoreMissing
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
|
||||
$this._PolicyType = (Get-SingletonObject "AutopilotType")
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Device Enrollment
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
# region Device Enrollment
|
||||
|
||||
# DeviceEnrollmentType is the SHARED BASE for everything served by
|
||||
# /deviceManagement/deviceEnrollmentConfigurations. The Graph endpoint returns
|
||||
# eight subtypes of an abstract base type (per the schema):
|
||||
# deviceEnrollmentLimitConfiguration ('limit' / 'defaultLimit')
|
||||
# deviceEnrollmentPlatformRestrictionConfiguration ('singlePlatformRestriction')
|
||||
# deviceEnrollmentPlatformRestrictionsConfiguration ('platformRestrictions' / 'defaultPlatformRestrictions')
|
||||
# deviceEnrollmentWindowsHelloForBusinessConfiguration('windowsHelloForBusiness' / 'defaultWindowsHelloForBusiness')
|
||||
# windows10EnrollmentCompletionPageConfiguration ('windows10EnrollmentCompletionPageConfiguration' / 'defaultWindows10EnrollmentCompletionPageConfiguration')
|
||||
# deviceComanagementAuthorityConfiguration ('deviceComanagementAuthorityConfiguration')
|
||||
# deviceEnrollmentNotificationConfiguration ('enrollmentNotificationsConfiguration')
|
||||
# windowsRestoreDeviceEnrollmentConfiguration ('windowsRestore')
|
||||
#
|
||||
# Each logical bucket gets its own thin subtype below. The base only carries
|
||||
# import/export/replace behavior — it is NOT registered as a policy type itself
|
||||
# (no _PolicyGroup), so $script:IntuneTypes only contains the concrete buckets.
|
||||
#
|
||||
# Server-side filtering by `deviceEnrollmentConfigurationType eq '...'` is value-exact
|
||||
# and excludes the 'default*' variants, so subtypes filter client-side via CheckPolicy
|
||||
# on @odata.type. With identical _API/_QueryList across siblings, Get-GraphPolicies
|
||||
# coalesces them into ONE batch sub-request and fans rows out to the matching subtype.
|
||||
class DeviceEnrollmentType : IntunePolicyTypeBase
|
||||
{
|
||||
# Abstract: only concrete subtypes (EnrollmentStatusPageType, EnrollmentLimitType, ...)
|
||||
# may be instantiated. Auto-discovery loops in Invoke-IntuneEventAppInitialized and
|
||||
# the UI extensions consult Test-ClassIsAbstract on each candidate and skip those
|
||||
# declaring this static marker, so $script:IntuneTypes only contains concrete buckets.
|
||||
static [bool] $IsAbstract = $true
|
||||
|
||||
DeviceEnrollmentType() : Base()
|
||||
{
|
||||
([DeviceEnrollmentType]$this).Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
# Everything in this Init is shared across every concrete bucket. Subtypes only
|
||||
# override _ID, _APITitle, _PolicyName, _Folder, _QueryList, _PlatformName (when
|
||||
# platform-specific), and CheckPolicy. Subtypes do NOT need to call this Init
|
||||
# explicitly — the constructor chain already runs it via : Base() → DeviceEnrollmentType()
|
||||
# body's ([DeviceEnrollmentType]$this).Init().
|
||||
$this._API = "deviceManagement/deviceEnrollmentConfigurations"
|
||||
# Endpoint rejects a name $filter (verified live 2026-08-27) - searches filter client-side.
|
||||
$this._SupportsNameFilter = $false
|
||||
$this._Permissions = @("DeviceManagementServiceConfig.ReadWrite.All")
|
||||
$this._SkipRemoveProperties = @('Id')
|
||||
$this._PropertiesToRemoveForUpdate = @('priority')
|
||||
$this._Dependencies = @('Applications')
|
||||
$this._AssignmentsType = "enrollmentConfigurationAssignments"
|
||||
$this._Icon = "EnrollmentStatusPage"
|
||||
$this._PolicyGroup = (Get-SingletonObject "DeviceEnrollmentGroup")
|
||||
$this._ObjectClass = "DeviceEnrollmentObject"
|
||||
$this._VerifyObject = $true
|
||||
# _ExpandAssignmentsList stays at its default ($true) so the list URL appends
|
||||
# &$expand=assignments. The Intune portal does this on every enrollment-config
|
||||
# subtype, and Graph accepts it here, so we get assignments inline and skip the
|
||||
# follow-up /assignments round-trip in Add-GraphPolicyAssignments.
|
||||
# No _QueryList here — each subtype owns its filter. Combining filters across
|
||||
# subtypes via OR was unreliable (Graph's batch endpoint dropped most matches).
|
||||
}
|
||||
|
||||
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
if($PolicyObject.Object.Priority -eq 0)
|
||||
{
|
||||
$ret = @{}
|
||||
$ret.Add("API","$($PolicyObject.PolicyType.API)/$($PolicyObject.Id)")
|
||||
$ret.Add("Method","PATCH") # Default profile always exists so update them
|
||||
$ret
|
||||
}
|
||||
else
|
||||
{
|
||||
Remove-Property $PolicyObject.Object "Id"
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
[Hashtable]PreImportAssignmentsCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
|
||||
{
|
||||
if($SourceObject.Object.Priority -eq 0) { return @{ "Import" = $false } }
|
||||
return $null
|
||||
}
|
||||
|
||||
[Hashtable]PreDeleteCommand([IntunePolicyBase]$PolicyObject)
|
||||
{
|
||||
if($PolicyObject.Object.Priority -eq 0) { return @{ "Delete" = $false } }
|
||||
return $null
|
||||
}
|
||||
|
||||
[Hashtable]PreReplaceCommand([IntunePolicyBase]$PolicyObject)
|
||||
{
|
||||
if($PolicyObject.Object.Priority -eq 0) { return @{ "Delete" = $false } }
|
||||
return $null
|
||||
}
|
||||
|
||||
PostReplaceCommand([IntunePolicyBase]$PolicyObject, [IntunePolicyBase]$SourceObject)
|
||||
{
|
||||
Set-EnrollmentRestrictionsPriority $PolicyObject $SourceObject
|
||||
}
|
||||
}
|
||||
|
||||
# Shared Object class for every DeviceEnrollment subtype. PolicyName, Folder and
|
||||
# Platform routing all flow from the subtype's TYPE class (_PolicyName / _Folder /
|
||||
# _PlatformName), not per-instance switches on @odata.type:
|
||||
# * IntunePolicyTypeBase.GetObject sets policyObject._PolicyType = $this (the calling
|
||||
# subtype) after construction, so $obj.PolicyType.Folder / .PolicyName resolve to
|
||||
# the right subtype's values.
|
||||
# * IntunePolicyBase.Platform getter falls back to _PolicyType._PlatformName when the
|
||||
# instance doesn't set its own — that's how Windows-only buckets (ESP, WHfB,
|
||||
# CoMgmt, WindowsRestore) report Platform=Windows.
|
||||
# The constructor is intentionally empty: IntunePolicyBase's : Base($JsonObj) chain
|
||||
# already runs the Add-ObjectProperty setup. Anything we'd add to a subclass Init here
|
||||
# is per-subtype concern and lives on the subtype TYPE.
|
||||
Class DeviceEnrollmentObject : IntunePolicyBase
|
||||
{
|
||||
DeviceEnrollmentObject([PSCustomObject]$JsonObj) : Base($JsonObj) { }
|
||||
DeviceEnrollmentObject() : Base() { }
|
||||
|
||||
[String]GetFileName([String]$Path)
|
||||
{
|
||||
# Default policies (priority=0) have a localized boilerplate displayName
|
||||
# ("All users and all devices") that collides across subtypes — both
|
||||
# DefaultLimit and DefaultPlatformRestrictions land on the same name and
|
||||
# one overwrites the other. Their id-suffix is unique per subtype
|
||||
# (DefaultLimit / DefaultPlatformRestrictions / DefaultWindowsHelloForBusiness /
|
||||
# DefaultWindows10EnrollmentCompletionPageConfiguration), so we use that.
|
||||
# Non-default policies have id format <randomGuid>_<configTypeSuffix> where
|
||||
# the suffix is identical for every policy of a given subtype, so we use
|
||||
# displayName instead.
|
||||
# We use TWO signals — priority OR a TenantId-prefixed id — because each
|
||||
# has a failure mode on its own:
|
||||
# * priority alone: depends on Graph exposing 'priority' on every payload
|
||||
# and on it staying =0 for default policies (mostly true but not
|
||||
# guaranteed across endpoints / future schema changes).
|
||||
# * TenantId prefix alone: requires $this.TenantId to be populated by the
|
||||
# time GetFileName runs; it isn't always (file-load paths, certain
|
||||
# bulk-export code paths skip the TenantId-set step).
|
||||
# Combining them is robust: a row that's a default in EITHER sense uses the
|
||||
# id-suffix, everything else uses displayName.
|
||||
|
||||
$isDefault = ($this.Object.priority -eq 0) -or `
|
||||
($this.TenantId -and $this.Id -and $this.Id.StartsWith($this.TenantId + "_"))
|
||||
|
||||
if($isDefault) {
|
||||
$parts = $this.Id -split '_', 2
|
||||
$name = if($parts.Count -ge 2) { $parts[1] } else { $null }
|
||||
}
|
||||
else {
|
||||
$name = $this.Object.displayName
|
||||
}
|
||||
if(-not $name) { $name = $this.Id }
|
||||
|
||||
# Same id-suffix rule as IntunePolicyBase.GetFileName, which this override
|
||||
# replaced wholesale and therefore silently dropped: both the user-facing
|
||||
# AddIDToExportFile setting AND the bulk-export collision flag were ignored
|
||||
# here, so two non-default enrollment policies of the same subtype sharing a
|
||||
# displayName still wrote the same file and one overwrote the other - the
|
||||
# collision was DETECTED and then not acted on.
|
||||
#
|
||||
# A default policy takes its name from the id suffix already, which is unique
|
||||
# per subtype, so the flag will not normally fire for one; the rule is applied
|
||||
# unconditionally anyway rather than only in the else-branch, so an id-suffix
|
||||
# name that does somehow collide is still disambiguated.
|
||||
$forceId = (Get-SettingValue "AddIDToExportFile") -eq $true -or $this._NeedsIdInFilename -eq $true
|
||||
if($forceId -and $this.Id -and $this.PolicyType.SkipAddIDOnFileName -ne $true -and $name -ne $this.Id) {
|
||||
$name = ($name + "_" + $this.Id)
|
||||
}
|
||||
|
||||
$fileName = "$((Remove-InvalidFileNameChars $name)).json"
|
||||
if($Path) { $fileName = [IO.Path]::Combine($Path, $fileName) }
|
||||
return $fileName
|
||||
}
|
||||
}
|
||||
|
||||
# Concrete bucket conventions:
|
||||
# * Constructor delegates to : Base() (= DeviceEnrollmentType) which runs the shared
|
||||
# Init via the constructor chain. Subtype Init does NOT call the base Init
|
||||
# explicitly — that would run the base Init twice.
|
||||
# * Subtype Init only sets bucket-specific fields: _ID, _APITitle, _PolicyName,
|
||||
# _Folder, _QueryList, optionally _PlatformName, then registers via AddPolicyType.
|
||||
# * CheckPolicy stays as a defensive client-side filter; @odata.type is the primary
|
||||
# discriminator with deviceEnrollmentConfigurationType as a fallback.
|
||||
|
||||
class EnrollmentStatusPageType : DeviceEnrollmentType
|
||||
{
|
||||
EnrollmentStatusPageType() : Base() { ([EnrollmentStatusPageType]$this).Init() }
|
||||
Init()
|
||||
{
|
||||
$this._ID = "EnrollmentStatusPage"
|
||||
$this._APITitle = "Enrollment Status Page"
|
||||
$this._PolicyName = "Enrollment Status Page"
|
||||
$this._Folder = "EnrollmentStatusPage"
|
||||
$this._PlatformName = Get-LanguageString "Platform.windows" -IgnoreMissing
|
||||
# Single eq is enough: empirically Graph returns both default and non-default
|
||||
# ESPs for this filter. (Adding 'defaultWindows10…' as a second clause causes
|
||||
# a 400 — that enum value is in the schema but rejected by the live filter parser.)
|
||||
$this._QueryList = "?`$filter=deviceEnrollmentConfigurationType eq 'windows10EnrollmentCompletionPageConfiguration'"
|
||||
if($null -ne $this._PolicyGroup) { $this._PolicyGroup.AddPolicyType($this) }
|
||||
}
|
||||
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
if ($PolicyObject.'@odata.type' -eq '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration') { return $true }
|
||||
return ($PolicyObject.deviceEnrollmentConfigurationType -eq 'windows10EnrollmentCompletionPageConfiguration')
|
||||
}
|
||||
}
|
||||
|
||||
class EnrollmentRestrictionsPageType : DeviceEnrollmentType
|
||||
{
|
||||
EnrollmentRestrictionsPageType() : Base() { ([EnrollmentRestrictionsPageType]$this).Init() }
|
||||
Init()
|
||||
{
|
||||
$this._ID = "EnrollmentRestrictions"
|
||||
$this._APITitle = "Enrollment Restrictions"
|
||||
$this._PolicyName = "Device platform restrictions"
|
||||
$this._Folder = "EnrollmentRestrictions"
|
||||
# Cross-platform (covers iOS / Android / Windows etc.) — no _PlatformName.
|
||||
# Single eq clause empirically returns BOTH per-platform Block Android-style
|
||||
# configs (@odata.type singular) AND the default combined platform restrictions
|
||||
# (@odata.type plural, id-suffix _DefaultPlatformRestrictions). 'limit' lives
|
||||
# on EnrollmentLimitType because stacking a second eq clause on the same
|
||||
# property in batch mode caused Graph's batch endpoint to drop most matches.
|
||||
$this._QueryList = "?`$filter=deviceEnrollmentConfigurationType eq 'singlePlatformRestriction'"
|
||||
if($null -ne $this._PolicyGroup) { $this._PolicyGroup.AddPolicyType($this) }
|
||||
}
|
||||
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
if ($PolicyObject.'@odata.type' -in @(
|
||||
'#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration',
|
||||
'#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration')) { return $true }
|
||||
return ($PolicyObject.deviceEnrollmentConfigurationType -in @(
|
||||
'singlePlatformRestriction',
|
||||
'platformRestrictions',
|
||||
'defaultPlatformRestrictions'))
|
||||
}
|
||||
}
|
||||
|
||||
class EnrollmentLimitType : DeviceEnrollmentType
|
||||
{
|
||||
EnrollmentLimitType() : Base() { ([EnrollmentLimitType]$this).Init() }
|
||||
Init()
|
||||
{
|
||||
$this._ID = "EnrollmentLimit"
|
||||
$this._HasPlatform = $false
|
||||
$this._APITitle = "Enrollment Limit"
|
||||
$this._PolicyName = "Device limit restrictions"
|
||||
# Same folder as platform restrictions — matches the OLD baseline export where
|
||||
# EnrollmentRestrictions/ bundled limit + platform restriction policies together.
|
||||
$this._Folder = "EnrollmentRestrictions"
|
||||
$this._QueryList = "?`$filter=deviceEnrollmentConfigurationType eq 'limit'"
|
||||
if($null -ne $this._PolicyGroup) { $this._PolicyGroup.AddPolicyType($this) }
|
||||
}
|
||||
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
if ($PolicyObject.'@odata.type' -eq '#microsoft.graph.deviceEnrollmentLimitConfiguration') { return $true }
|
||||
return ($PolicyObject.deviceEnrollmentConfigurationType -in @('limit', 'defaultLimit'))
|
||||
}
|
||||
}
|
||||
|
||||
class WindowsHelloForBusinessType : DeviceEnrollmentType
|
||||
{
|
||||
WindowsHelloForBusinessType() : Base() { ([WindowsHelloForBusinessType]$this).Init() }
|
||||
Init()
|
||||
{
|
||||
$this._ID = "WindowsHelloForBusiness"
|
||||
$this._APITitle = "Windows Hello for Business"
|
||||
$this._PolicyName = "Windows Hello for Business"
|
||||
$this._Folder = "WindowsHelloForBusiness"
|
||||
$this._PlatformName = Get-LanguageString "Platform.windows" -IgnoreMissing
|
||||
$this._QueryList = "?`$filter=deviceEnrollmentConfigurationType eq 'windowsHelloForBusiness'"
|
||||
if($null -ne $this._PolicyGroup) { $this._PolicyGroup.AddPolicyType($this) }
|
||||
}
|
||||
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
if ($PolicyObject.'@odata.type' -eq '#microsoft.graph.deviceEnrollmentWindowsHelloForBusinessConfiguration') { return $true }
|
||||
return ($PolicyObject.deviceEnrollmentConfigurationType -eq 'windowsHelloForBusiness')
|
||||
}
|
||||
}
|
||||
|
||||
class CoManagementSettingsType : DeviceEnrollmentType
|
||||
{
|
||||
CoManagementSettingsType() : Base() { ([CoManagementSettingsType]$this).Init() }
|
||||
Init()
|
||||
{
|
||||
$this._ID = "CoManagementSettings"
|
||||
$this._APITitle = "Co-Management Settings"
|
||||
$this._PolicyName = "Co-Management Settings"
|
||||
$this._Folder = "CoManagementSettings"
|
||||
$this._PlatformName = Get-LanguageString "Platform.windows" -IgnoreMissing
|
||||
$this._QueryList = "?`$filter=deviceEnrollmentConfigurationType eq 'deviceComanagementAuthorityConfiguration'"
|
||||
if($null -ne $this._PolicyGroup) { $this._PolicyGroup.AddPolicyType($this) }
|
||||
}
|
||||
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
if ($PolicyObject.'@odata.type' -eq '#microsoft.graph.deviceComanagementAuthorityConfiguration') { return $true }
|
||||
return ($PolicyObject.deviceEnrollmentConfigurationType -eq 'deviceComanagementAuthorityConfiguration')
|
||||
}
|
||||
}
|
||||
|
||||
class WindowsRestoreType : DeviceEnrollmentType
|
||||
{
|
||||
WindowsRestoreType() : Base() { ([WindowsRestoreType]$this).Init() }
|
||||
Init()
|
||||
{
|
||||
$this._ID = "WindowsRestore"
|
||||
$this._APITitle = "Windows Restore"
|
||||
$this._PolicyName = "Windows Restore"
|
||||
$this._Folder = "WindowsRestore"
|
||||
$this._PlatformName = Get-LanguageString "Platform.windows" -IgnoreMissing
|
||||
$this._QueryList = "?`$filter=deviceEnrollmentConfigurationType eq 'windowsRestore'"
|
||||
if($null -ne $this._PolicyGroup) { $this._PolicyGroup.AddPolicyType($this) }
|
||||
}
|
||||
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
if ($PolicyObject.'@odata.type' -eq '#microsoft.graph.windowsRestoreDeviceEnrollmentConfiguration') { return $true }
|
||||
return ($PolicyObject.deviceEnrollmentConfigurationType -eq 'windowsRestore')
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Enrollment Notification
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
# region Enrollment Notification
|
||||
# Note: Email and Push notifications are defined in the Notifications class in the Compliance class file.
|
||||
# This bucket follows the same shape as the other DeviceEnrollment subtypes:
|
||||
# no _QueryList (so the URL coalesces with siblings in Get-GraphPolicies), client-side
|
||||
# filter via CheckPolicy on @odata.type, shared DeviceEnrollmentObject.
|
||||
class EnrollmentNotificationType : DeviceEnrollmentType
|
||||
{
|
||||
EnrollmentNotificationType() : Base() { ([EnrollmentNotificationType]$this).Init() }
|
||||
Init()
|
||||
{
|
||||
$this._ID = "EnrollmentNotification"
|
||||
$this._HasPlatform = $false
|
||||
$this._APITitle = "Enrollment notifications"
|
||||
$this._PolicyName = "Enrollment notification"
|
||||
$this._Folder = "EnrollmentNotifications"
|
||||
# Cross-platform (email + push notifications target any enrolled device).
|
||||
$this._QueryList = "?`$filter=deviceEnrollmentConfigurationType eq 'enrollmentNotificationsConfiguration'"
|
||||
if($null -ne $this._PolicyGroup) { $this._PolicyGroup.AddPolicyType($this) }
|
||||
}
|
||||
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
if ($PolicyObject.'@odata.type' -eq '#microsoft.graph.deviceEnrollmentNotificationConfiguration') { return $true }
|
||||
return ($PolicyObject.deviceEnrollmentConfigurationType -eq 'enrollmentNotificationsConfiguration')
|
||||
}
|
||||
|
||||
# ToDo: Add support for importing, exporting, copying Notifications between environment eg
|
||||
# notificationTemplates property has a string list of actual notification template policies Email_<GUID of Notification Template>
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Generic functions
|
||||
#
|
||||
#########################################################################################
|
||||
function Set-EnrollmentRestrictionsPriority
|
||||
{
|
||||
param($PolicyObject, $SourceObj)
|
||||
|
||||
if($PolicyObject.Object.Priority -eq 0) { return }
|
||||
|
||||
$api = "$($PolicyObject.PolicyType.API)/$($PolicyObject.Id)/setpriority"
|
||||
|
||||
$priority = [PSCustomObject]@{
|
||||
priority = $SourceObj.Object.Priority
|
||||
}
|
||||
$json = $priority | ConvertTo-Json -Depth 20
|
||||
|
||||
Write-Log "Update priority for $($PolicyObject.Name) to $($PolicyObject.Object.Priority)"
|
||||
Invoke-MSGraphAPI -Url $api -HttpMethod "POST" -Content $json -TokenId $PolicyObject._TokenId
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Android Device Owner Enrollment Profiles
|
||||
#
|
||||
#########################################################################################
|
||||
#
|
||||
# Profile used to enrol corporate-owned Android devices (dedicated devices,
|
||||
# fully-managed, AOSP, Teams devices) via QR code or token. Listed flat at
|
||||
# /deviceManagement/androidDeviceOwnerEnrollmentProfiles — not part of the
|
||||
# deviceEnrollmentConfigurations multi-subtype tree.
|
||||
|
||||
# region AndroidDeviceOwnerEnrollmentProfilesType
|
||||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||
class AndroidDeviceOwnerEnrollmentProfilesType : IntunePolicyTypeBase
|
||||
{
|
||||
AndroidDeviceOwnerEnrollmentProfilesType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "DeviceEnrollmentGroup")
|
||||
$this._PolicyName = "Android Enterprise — corporate"
|
||||
$this._ID = "AndroidDeviceOwnerEnrollmentProfiles"
|
||||
$this._API = "deviceManagement/androidDeviceOwnerEnrollmentProfiles"
|
||||
# AndroidCOWP icon (Corporate-Owned With Profile) is the closest
|
||||
# existing match for the Device Owner enrolment surface.
|
||||
$this._Icon = "AndroidCOWP"
|
||||
$this._Permissions = @("DeviceManagementServiceConfig.ReadWrite.All")
|
||||
# Server-generated bits that come back on GET but Graph rejects on
|
||||
# POST/PATCH. enrolledDeviceCount + token{*} + qrCode* are derived;
|
||||
# accountId is set from the calling tenant.
|
||||
$this._PropertiesToRemove = @('accountId','enrolledDeviceCount','enrollmentTokenUsageCount','qrCodeContent','qrCodeImage','tokenCreationDateTime','tokenExpirationDateTime','tokenValue')
|
||||
$this._PropertiesToRemoveForUpdate = @('accountId','enrolledDeviceCount','enrollmentTokenUsageCount','qrCodeContent','qrCodeImage','tokenCreationDateTime','tokenExpirationDateTime','tokenValue','enrollmentMode','enrollmentTokenType')
|
||||
# Profile, not policy — no group assignments.
|
||||
$this._SupportsAssignments = $false
|
||||
# Graph returns HTTP 400 on `androidDeviceOwnerEnrollmentProfiles?$expand=assignments`,
|
||||
# even though SupportsAssignments=$false; the list-URL builder still
|
||||
# appends the expand unless this is explicitly suppressed.
|
||||
$this._ExpandAssignmentsList = $false
|
||||
$this._ObjectClass = "AndroidDeviceOwnerEnrollmentProfileObject"
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||
class AndroidDeviceOwnerEnrollmentProfileObject : IntunePolicyBase
|
||||
{
|
||||
AndroidDeviceOwnerEnrollmentProfileObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
AndroidDeviceOwnerEnrollmentProfileObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PolicyType = (Get-SingletonObject "AndroidDeviceOwnerEnrollmentProfilesType")
|
||||
# Language pack uses Platform.androidForWork for AOSP/Android Enterprise
|
||||
# surfaces; fall back to a literal if the key is missing in en-US.
|
||||
$this._PlatformName = Get-LanguageString "Platform.androidForWork" -IgnoreMissing
|
||||
if(-not $this._PlatformName) { $this._PlatformName = "Android Enterprise" }
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Android For Work Enrollment Profiles
|
||||
#
|
||||
#########################################################################################
|
||||
#
|
||||
# Profile used to enrol personal Android devices into a managed Work Profile
|
||||
# (BYOD). Endpoint at /deviceManagement/androidForWorkEnrollmentProfiles. No
|
||||
# scope tag support, no assignments — purely token + QR for the end user.
|
||||
|
||||
# region AndroidForWorkEnrollmentProfilesType
|
||||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||
class AndroidForWorkEnrollmentProfilesType : IntunePolicyTypeBase
|
||||
{
|
||||
AndroidForWorkEnrollmentProfilesType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "DeviceEnrollmentGroup")
|
||||
$this._PolicyName = "Android Enterprise — work profile"
|
||||
$this._ID = "AndroidForWorkEnrollmentProfiles"
|
||||
$this._API = "deviceManagement/androidForWorkEnrollmentProfiles"
|
||||
# AndroidGooglePlay icon — work-profile enrolment is the personal-device
|
||||
# / Play-store-managed surface, so the GP icon reads better than the
|
||||
# corporate AndroidCOWP one used for the Device Owner type.
|
||||
$this._Icon = "AndroidGooglePlay"
|
||||
$this._Permissions = @("DeviceManagementServiceConfig.ReadWrite.All")
|
||||
# Schema has no roleScopeTagIds, so disable the scope-tag column /
|
||||
# detail-view widget for this type. Leaving the default ("roleScopeTagIds")
|
||||
# would surface an empty UI and a 400 on save.
|
||||
$this._ScopeTagProperty = $null
|
||||
# Server-generated fields Graph rejects on POST/PATCH.
|
||||
$this._PropertiesToRemove = @('accountId','enrolledDeviceCount','qrCodeContent','qrCodeImage','tokenValue','tokenExpirationDateTime')
|
||||
$this._PropertiesToRemoveForUpdate = @('accountId','enrolledDeviceCount','qrCodeContent','qrCodeImage','tokenValue','tokenExpirationDateTime')
|
||||
$this._SupportsAssignments = $false
|
||||
# Graph returns HTTP 400 on `androidForWorkEnrollmentProfiles?$expand=assignments`.
|
||||
$this._ExpandAssignmentsList = $false
|
||||
$this._ObjectClass = "AndroidForWorkEnrollmentProfileObject"
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||
class AndroidForWorkEnrollmentProfileObject : IntunePolicyBase
|
||||
{
|
||||
AndroidForWorkEnrollmentProfileObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
AndroidForWorkEnrollmentProfileObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PolicyType = (Get-SingletonObject "AndroidForWorkEnrollmentProfilesType")
|
||||
$this._PlatformName = Get-LanguageString "Platform.androidForWork" -IgnoreMissing
|
||||
if(-not $this._PlatformName) { $this._PlatformName = "Android Enterprise" }
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Settings Catalog
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
# region Settings Catalog
|
||||
|
||||
class EnrollmentSettingsCatalogType : SettingsCatalogTypeBase
|
||||
{
|
||||
EnrollmentSettingsCatalogType() : Base()
|
||||
{
|
||||
([EnrollmentSettingsCatalogType]$this).Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "DeviceEnrollmentGroup")
|
||||
$this._APITitle = "Enrollment Policies (Settings Catalog)"
|
||||
$this._ID = "EnrollmentSettingsCatalog"
|
||||
# Startup default only - Invoke-IntuneSettingsCatalogAuthenticated rebuilds
|
||||
# this from _FamilyTypes once Graph reports the live template list.
|
||||
# windowsOsRecoveryPolicies is NOT listed here: it falls to SettingsCatalog's
|
||||
# catch-all spec, so claiming it would only fetch rows CheckPolicy rejects.
|
||||
$this._QueryList = "?`$filter=templateReference/templateFamily eq 'enrollmentConfiguration'"
|
||||
$this._Icon = "EnrollmentStatusPage"
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
|
||||
$this._PolicyTypeOrder = 160
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
class AutopilotDevicePreparationSettingsCatalogType : SettingsCatalogTypeBase
|
||||
{
|
||||
AutopilotDevicePreparationSettingsCatalogType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "DeviceEnrollmentGroup")
|
||||
$this._ID = "AutopilotDevicePreparationSettingsCatalog"
|
||||
$this._APITitle = "Autopilot Device Preparation (Settings Catalog)"
|
||||
#$this._QueryList = "?`$filter=(technologies has 'enrollment') and (platforms eq 'windows10') and (TemplateReference/templateId eq '80d33118-b7b4-40d8-b15f-81be745e053f_1') and (Templatereference/templateFamily eq 'enrollmentConfiguration')"
|
||||
$this._QueryList = "?`$filter=(technologies has 'enrollment') and (platforms eq 'windows10') and (Templatereference/templateFamily eq 'enrollmentConfiguration')"
|
||||
$this._Folder = "SettingsCatalog"
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
|
||||
$this._PolicyTypeOrder = 100
|
||||
}
|
||||
|
||||
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
if($PolicyObject.'@odata.type' -ne "#microsoft.graph.deviceManagementConfigurationPolicy") { return $false }
|
||||
|
||||
if($PolicyObject.templateReference.templateFamily -and $PolicyObject.templateReference.templateFamily -eq 'enrollmentConfiguration') {
|
||||
return $true
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
}
|
||||
#>
|
||||
#endregion
|
||||
@@ -1,378 +0,0 @@
|
||||
#ImportOrder 220
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Intune Info Group
|
||||
#
|
||||
# Read-only objects ported from the original project's "Intune Info" view
|
||||
# (Extensions/EndpointManagerInfo.psm1). Every type here is Export/View only:
|
||||
# no import, delete, copy or assignment support. Android Google Play status
|
||||
# and Tenant Settings are single-object endpoints (_SingleObject; the body IS
|
||||
# the row) with a synthesized displayName because the API has none.
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||
class IntuneInfoGroup : IntunePolicyGroupBase
|
||||
{
|
||||
IntuneInfoGroup() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._ID = "IntuneInfo"
|
||||
# Templates are this group's substance: Version fills for both template
|
||||
# kinds, State for templates, VPP tokens and the Google Play binding.
|
||||
$this._ExtraColumns = @("Version", "State")
|
||||
$this._Name = "Intune Info"
|
||||
$this._Icon = "Report"
|
||||
$this._ShowButtons = @("Export","View")
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Baseline Templates - Intent
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
class BaselineTemplatesType : IntunePolicyTypeBase
|
||||
{
|
||||
BaselineTemplatesType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "IntuneInfoGroup")
|
||||
$this._PolicyName = "Baseline Templates - Intent"
|
||||
$this._ID = "BaselineTemplates"
|
||||
$this._ExtraColumns = @("Version", "State")
|
||||
$this._API = "deviceManagement/templates"
|
||||
$this._Icon = "SecurityBaselines"
|
||||
$this._Permissions = @("DeviceManagementConfiguration.Read.All")
|
||||
$this._ObjectClass = "BaselineTemplatesObject"
|
||||
$this._ShowButtons = @("Export","View")
|
||||
$this._SupportsAssignments = $false
|
||||
$this._ExpandAssignmentsList = $false
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Class BaselineTemplatesObject : IntunePolicyBase
|
||||
{
|
||||
BaselineTemplatesObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
BaselineTemplatesObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PolicyType = (Get-SingletonObject "BaselineTemplatesType")
|
||||
# Same vocabulary as the Settings Catalog templates, so one Version / State
|
||||
# column reads evenly across the Intune Info group.
|
||||
Add-ObjectProperty $this "Version" { if($this.JsonObject) { $this.JsonObject.versionInfo } }
|
||||
Add-ObjectProperty $this "State" { if($null -eq $this.JsonObject) { $null } elseif($this.JsonObject.isDeprecated -eq $true) { "Deprecated" } else { "Active" } }
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Baseline Templates - Settings Catalog
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
class BaselineTemplatesSettingsCatalogType : IntunePolicyTypeBase
|
||||
{
|
||||
BaselineTemplatesSettingsCatalogType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "IntuneInfoGroup")
|
||||
$this._PolicyName = "Templates - Settings Catalog"
|
||||
$this._ID = "BaselineTemplatesSettingsCatalog"
|
||||
$this._ExtraColumns = @("Version", "State")
|
||||
$this._API = "deviceManagement/configurationPolicyTemplates"
|
||||
$this._Icon = "SecurityBaselines"
|
||||
$this._Permissions = @("DeviceManagementConfiguration.Read.All")
|
||||
$this._ObjectClass = "BaselineTemplatesSettingsCatalogObject"
|
||||
$this._ShowButtons = @("Export","View")
|
||||
$this._SupportsAssignments = $false
|
||||
$this._ExpandAssignmentsList = $false
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Class BaselineTemplatesSettingsCatalogObject : IntunePolicyBase
|
||||
{
|
||||
BaselineTemplatesSettingsCatalogObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
BaselineTemplatesSettingsCatalogObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PolicyType = (Get-SingletonObject "BaselineTemplatesSettingsCatalogType")
|
||||
Add-ObjectProperty $this "Version" { if($this.JsonObject) { $this.JsonObject.displayVersion } }
|
||||
Add-ObjectProperty $this "State" { if($this.JsonObject) { ConvertTo-DisplayWords $this.JsonObject.lifecycleState } }
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Apple VPP Tokens
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
class AppleVPPTokensType : IntunePolicyTypeBase
|
||||
{
|
||||
AppleVPPTokensType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "IntuneInfoGroup")
|
||||
$this._PolicyName = "Apple VPP Tokens"
|
||||
$this._ID = "AppleVPPTokens"
|
||||
$this._ExtraColumns = @("State")
|
||||
$this._HasPlatform = $false
|
||||
# Endpoint rejects a name $filter (verified live 2026-08-27) - searches filter client-side.
|
||||
$this._SupportsNameFilter = $false
|
||||
$this._API = "deviceAppManagement/vppTokens"
|
||||
$this._Icon = "AppleVPPTokens"
|
||||
$this._Permissions = @("DeviceManagementApps.Read.All")
|
||||
$this._ObjectClass = "AppleVPPTokensObject"
|
||||
$this._NameProperty = "appleId"
|
||||
$this._ShowButtons = @("Export","View")
|
||||
$this._SupportsAssignments = $false
|
||||
$this._ExpandAssignmentsList = $false
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Class AppleVPPTokensObject : IntunePolicyBase
|
||||
{
|
||||
AppleVPPTokensObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
AppleVPPTokensObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PolicyType = (Get-SingletonObject "AppleVPPTokensType")
|
||||
Add-ObjectProperty $this "State" { if($this.JsonObject) { ConvertTo-DisplayWords $this.JsonObject.state } }
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Apple Enrollment Tokens (DEP / Apple Business Manager)
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
class AppleEnrollmentTokensType : IntunePolicyTypeBase
|
||||
{
|
||||
AppleEnrollmentTokensType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "IntuneInfoGroup")
|
||||
$this._PolicyName = "Apple Enrollment Tokens"
|
||||
$this._ID = "AppleEnrollmentTokens"
|
||||
$this._ExtraColumns = @("State")
|
||||
$this._HasPlatform = $false
|
||||
# Endpoint rejects a name $filter (verified live 2026-08-27) - searches filter client-side.
|
||||
$this._SupportsNameFilter = $false
|
||||
$this._API = "deviceManagement/depOnboardingSettings"
|
||||
$this._QueryList = "?`$top=100"
|
||||
$this._Icon = "AppleEnrollmentTokens"
|
||||
$this._Permissions = @("DeviceManagementServiceConfig.Read.All")
|
||||
$this._ObjectClass = "AppleEnrollmentTokensObject"
|
||||
$this._NameProperty = "tokenName"
|
||||
$this._ShowButtons = @("Export","View")
|
||||
$this._SupportsAssignments = $false
|
||||
$this._ExpandAssignmentsList = $false
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Class AppleEnrollmentTokensObject : IntunePolicyBase
|
||||
{
|
||||
AppleEnrollmentTokensObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
AppleEnrollmentTokensObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PolicyType = (Get-SingletonObject "AppleEnrollmentTokensType")
|
||||
# A DEP token has no state field; whether it has expired is the state that
|
||||
# matters, and it lines up with the VPP token's Valid / Expired.
|
||||
Add-ObjectProperty $this "State" {
|
||||
if($null -eq $this.JsonObject -or -not $this.JsonObject.tokenExpirationDateTime) { return $null }
|
||||
$exp = [DateTime]::MinValue
|
||||
if([DateTime]::TryParse([string]$this.JsonObject.tokenExpirationDateTime, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::RoundtripKind, [ref]$exp)) {
|
||||
if($exp -lt (Get-Date)) { "Expired" } else { "Valid" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Android Google Play (managed store account status - single object)
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
class AndroidGooglePlayType : IntunePolicyTypeBase
|
||||
{
|
||||
AndroidGooglePlayType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "IntuneInfoGroup")
|
||||
$this._PolicyName = "Android Google Play"
|
||||
$this._ID = "AndroidGooglePlay"
|
||||
$this._ExtraColumns = @("State")
|
||||
# Platform default: the endpoint serves exactly one platform and the
|
||||
# objects carry no platforms/platformType field, so the column would
|
||||
# otherwise be blank (Managed Google Play is the Android Enterprise
|
||||
# connection).
|
||||
$this._PlatformName = Get-LanguageString "Platform.androidEnterprise" -IgnoreMissing
|
||||
$this._API = "deviceManagement/androidManagedStoreAccountEnterpriseSettings"
|
||||
$this._Icon = "AndroidGooglePlay"
|
||||
$this._Permissions = @("DeviceManagementConfiguration.Read.All")
|
||||
$this._ObjectClass = "AndroidGooglePlayObject"
|
||||
$this._ShowButtons = @("Export","View")
|
||||
$this._SupportsAssignments = $false
|
||||
$this._ExpandAssignmentsList = $false
|
||||
$this._SingleObject = $true
|
||||
$this._HasPageSizeSupport = $false
|
||||
$this._SkipAddIDOnFileName = $true
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
|
||||
[IntunePolicyBase]GetObject([PSCustomObject]$JsonObj)
|
||||
{
|
||||
# The settings object has no displayName - synthesize one so the grid
|
||||
# row and the export file name aren't blank.
|
||||
if($JsonObj -and -not $JsonObj.PSObject.Properties['displayName']) {
|
||||
$JsonObj | Add-Member -MemberType NoteProperty -Name 'displayName' -Value 'Managed Google Play' -Force
|
||||
}
|
||||
return ([IntunePolicyTypeBase]$this).GetObject($JsonObj)
|
||||
}
|
||||
}
|
||||
|
||||
Class AndroidGooglePlayObject : IntunePolicyBase
|
||||
{
|
||||
AndroidGooglePlayObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
AndroidGooglePlayObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PolicyType = (Get-SingletonObject "AndroidGooglePlayType")
|
||||
Add-ObjectProperty $this "State" { if($this.JsonObject) { ConvertTo-DisplayWords $this.JsonObject.bindStatus } }
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Tenant Settings (Intune service settings - single object)
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
class TenantSettingsType : IntunePolicyTypeBase
|
||||
{
|
||||
TenantSettingsType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "IntuneInfoGroup")
|
||||
$this._PolicyName = "Tenant Settings"
|
||||
$this._ID = "TenantSettings"
|
||||
$this._HasPlatform = $false
|
||||
$this._API = "deviceManagement/settings"
|
||||
$this._Icon = "TenantSettings"
|
||||
$this._Permissions = @("DeviceManagementConfiguration.Read.All")
|
||||
$this._ObjectClass = "TenantSettingsObject"
|
||||
$this._ShowButtons = @("Export","View")
|
||||
$this._SupportsAssignments = $false
|
||||
$this._ExpandAssignmentsList = $false
|
||||
$this._SingleObject = $true
|
||||
$this._HasPageSizeSupport = $false
|
||||
$this._SkipAddIDOnFileName = $true
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
|
||||
[IntunePolicyBase]GetObject([PSCustomObject]$JsonObj)
|
||||
{
|
||||
# deviceManagement/settings has neither id nor displayName - synthesize
|
||||
# the name so the grid row and the export file name aren't blank.
|
||||
if($JsonObj -and -not $JsonObj.PSObject.Properties['displayName']) {
|
||||
$JsonObj | Add-Member -MemberType NoteProperty -Name 'displayName' -Value 'Intune Tenant Settings' -Force
|
||||
}
|
||||
return ([IntunePolicyTypeBase]$this).GetObject($JsonObj)
|
||||
}
|
||||
}
|
||||
|
||||
Class TenantSettingsObject : IntunePolicyBase
|
||||
{
|
||||
TenantSettingsObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
TenantSettingsObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PolicyType = (Get-SingletonObject "TenantSettingsType")
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
#ImportOrder 220
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Multi Admin Approval (MAA)
|
||||
#
|
||||
# operationApprovalPolicies - the access policies that decide WHICH operations need a
|
||||
# second administrator's approval, and which Entra groups may approve them.
|
||||
#
|
||||
# Exposed read-only on purpose. Creating or editing an access policy is itself an
|
||||
# MAA-protected "Tenant Configuration" change that needs a second admin to approve, and
|
||||
# a wrong policy can lock every administrator out of a workload. Export is enabled so
|
||||
# the configuration can be documented, diffed and migrated by hand; import is not.
|
||||
#
|
||||
# The request queue (deviceManagement/operationApprovalRequests - where a 412 from a
|
||||
# write lands; the approval code is the request id) is deliberately NOT a policy type:
|
||||
# it is transient state keyed by GUID with nothing to export, and the only useful
|
||||
# actions on it (approve / reject) would belong to a small tenant-admin tool.
|
||||
#
|
||||
# See Internal/MSGraphErrors.ps1 for the 400/403/412 classification that surfaces the
|
||||
# approval code to the caller.
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
#region Operation Approval Policies
|
||||
|
||||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||
class OperationApprovalPoliciesType : IntunePolicyTypeBase
|
||||
{
|
||||
OperationApprovalPoliciesType() : Base() { $this.Init() }
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "TenantAdminGroup")
|
||||
$this._PolicyName = "Multi Admin Approval Policies"
|
||||
$this._ID = "OperationApprovalPolicies"
|
||||
$this._HasPlatform = $false
|
||||
$this._API = "deviceManagement/operationApprovalPolicies"
|
||||
$this._Permissions = @("DeviceManagementRBAC.Read.All")
|
||||
# Read-only: see the file header. Editing an access policy is itself gated by
|
||||
# MAA and can lock admins out of a workload.
|
||||
$this._ShowButtons = @("View","Export")
|
||||
$this._ExpandAssignmentsList = $false
|
||||
$this._SupportsAssignments = $false
|
||||
$this._ObjectClass = "OperationApprovalPolicyObject"
|
||||
$this._Icon = "TenantSettings"
|
||||
|
||||
if($null -ne $this._PolicyGroup) { $this._PolicyGroup.AddPolicyType($this) }
|
||||
}
|
||||
|
||||
PostExportCommand([PSCustomObject]$PolicyObject, [String]$PathToFile)
|
||||
{
|
||||
# approverGroupIds are Entra group ids, so they are meaningless in another
|
||||
# tenant without translation. Capture them the same way Conditional Access
|
||||
# captures its include/exclude groups, so the export carries the sidecars a
|
||||
# future migration would need.
|
||||
$ids = @()
|
||||
foreach($id in @($PolicyObject.JsonObject.approverGroupIds))
|
||||
{
|
||||
if([String]::IsNullOrWhiteSpace($id)) { continue }
|
||||
if($id -in $ids) { continue }
|
||||
|
||||
$ids += $id
|
||||
Add-GraphMigrationObject $id "groups" "Group" ([IO.Path]::GetDirectoryName($PathToFile)) $PolicyObject._TokenId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||
class OperationApprovalPolicyObject : IntunePolicyBase
|
||||
{
|
||||
OperationApprovalPolicyObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
OperationApprovalPolicyObject() : Base() { $this.Init() }
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PolicyType = (Get-SingletonObject "OperationApprovalPoliciesType")
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -1,425 +0,0 @@
|
||||
#ImportOrder 220
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Script Group
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||
class ScriptsGroup : IntunePolicyGroupBase
|
||||
{
|
||||
ScriptsGroup() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._ID = "ScriptsAndRemediations"
|
||||
$this._Name = "Scripts and remediations"
|
||||
$this._Icon = "Scripts"
|
||||
# Three members share the Policy type "Platform Script"; Script type is the
|
||||
# language. File name is worth its blanks on the members without a file.
|
||||
$this._ExtraColumns = @("ScriptType=Script type", "Object.fileName=File name")
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Script Base Type
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
class ScriptTypeBase : IntunePolicyTypeBase
|
||||
{
|
||||
static [bool] $IsAbstract = $true
|
||||
|
||||
ScriptTypeBase() : Base()
|
||||
{
|
||||
([ScriptTypeBase]$this).Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
PostExportCommand([PSCustomObject]$PolicyObject, [String]$PathToFile)
|
||||
{
|
||||
if($PathToFile -and [IO.File]::Exists($PathToFile) -and $PolicyObject.JsonObject.scriptContent -and (Get-CacheObject "ExportScripts") -eq $true)
|
||||
{
|
||||
$fi = [IO.FileInfo]$PathToFile
|
||||
Write-Log "Export script $($PolicyObject.JsonObject.FileName)"
|
||||
$fileName = [IO.Path]::Combine($fi.DirectoryName, $PolicyObject.JsonObject.FileName)
|
||||
try {
|
||||
[IO.File]::WriteAllBytes($fileName, ([System.Convert]::FromBase64String($PolicyObject.JsonObject.scriptContent)))
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to save script file" $_.Exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# PowerShell Script
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
# region PowerShell Script
|
||||
class PowerShellScriptType : ScriptTypeBase
|
||||
{
|
||||
PowerShellScriptType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "ScriptsGroup")
|
||||
$this._PolicyName = "Platform Script"
|
||||
$this._PolicyBaseName = "PowerShell Scripts"
|
||||
$this._APITitle = "Scripts (PowerShell)"
|
||||
$this._ID = "PowerShellScripts"
|
||||
$this._API = "deviceManagement/deviceManagementScripts"
|
||||
$this._Permissions = @("DeviceManagementScripts.ReadWrite.All")
|
||||
$this._AssignmentsType = "deviceManagementScriptAssignments"
|
||||
$this._ObjectClass = "PowerShellScriptObject"
|
||||
$this._Icon = "Scripts"
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Class PowerShellScriptObject : IntunePolicyBase
|
||||
{
|
||||
PowerShellScriptObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
PowerShellScriptObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PlatformName = Get-LanguageString "Platform.windows"
|
||||
|
||||
Add-ObjectProperty $this "ScriptType" { "PowerShell script" }
|
||||
|
||||
$this._PolicyType = (Get-SingletonObject "PowerShellScriptType")
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Shell Script
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
# region PowerShell Script
|
||||
class ShellScriptType : ScriptTypeBase
|
||||
{
|
||||
ShellScriptType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "ScriptsGroup")
|
||||
$this._PolicyName = "Platform Script"
|
||||
$this._PolicyBaseName = "DeviceShell Scripts"
|
||||
$this._APITitle = "Scripts (Shell)"
|
||||
$this._ID = "MacScripts"
|
||||
$this._API = "deviceManagement/deviceShellScripts"
|
||||
$this._Permissions = @("DeviceManagementScripts.ReadWrite.All")
|
||||
$this._AssignmentsType = "deviceManagementScriptAssignments"
|
||||
# GET {id}/assignments returns 400 for shell scripts (verified live
|
||||
# 2026-09-22); {id}?$expand=assignments is the working read path.
|
||||
$this._AssignmentsViaExpand = $true
|
||||
$this._ObjectClass = "ShellScriptObject"
|
||||
$this._Icon = "Scripts"
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Class ShellScriptObject : IntunePolicyBase
|
||||
{
|
||||
ShellScriptObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
ShellScriptObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PlatformName = Get-LanguageString "Platform.macOS"
|
||||
|
||||
Add-ObjectProperty $this "ScriptType" { "Shell script" }
|
||||
|
||||
$this._PolicyType = (Get-SingletonObject "ShellScriptType")
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Platform Script
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
# region Platform Script
|
||||
class ScriptSettingsCatalogType : SettingsCatalogTypeBase
|
||||
{
|
||||
ScriptSettingsCatalogType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "ScriptsGroup")
|
||||
$this._PolicyName = "Platform Script"
|
||||
$this._APITitle = "Scripts (Linux)"
|
||||
$this._ID = "DeviceConfigurationScripts"
|
||||
$this._QueryList = "?`$filter=templateReference/TemplateFamily eq 'deviceConfigurationScripts'"
|
||||
$this._ObjectClass = "DeviceConfigurationScriptObject"
|
||||
$this._Icon = "Scripts"
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
|
||||
$this._PolicyTypeOrder = 50
|
||||
}
|
||||
}
|
||||
|
||||
Class DeviceConfigurationScriptObject : IntunePolicyBase
|
||||
{
|
||||
DeviceConfigurationScriptObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
DeviceConfigurationScriptObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PlatformName = Get-LanguageString "Platform.linux"
|
||||
|
||||
Add-ObjectProperty $this "ScriptType" { "Shell script" }
|
||||
|
||||
$this._PolicyType = (Get-SingletonObject "ScriptSettingsCatalogType")
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Shell Script
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
# region PowerShell Script
|
||||
class MacCustomAttributeType : ScriptTypeBase
|
||||
{
|
||||
MacCustomAttributeType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "ScriptsGroup")
|
||||
$this._PolicyName = "Custom Attributes"
|
||||
$this._PolicyBaseName = "Custom Attributes"
|
||||
$this._ID = "MacCustomAttributes"
|
||||
$this._API = "deviceManagement/deviceCustomAttributeShellScripts"
|
||||
$this._Permissions = @("DeviceManagementScripts.ReadWrite.All")
|
||||
$this._AssignmentsType = "deviceManagementScriptAssignments"
|
||||
# Same 400 on GET {id}/assignments as the shell scripts above.
|
||||
$this._AssignmentsViaExpand = $true
|
||||
$this._ObjectClass = "MacCustomAttributeObject"
|
||||
$this._Icon = "CustomAttributes"
|
||||
$this._PropertiesToRemoveForUpdate = @('customAttributeName','customAttributeType','displayName')
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Class MacCustomAttributeObject : IntunePolicyBase
|
||||
{
|
||||
MacCustomAttributeObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
MacCustomAttributeObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PlatformName = Get-LanguageString "Platform.macOS"
|
||||
|
||||
Add-ObjectProperty $this "ScriptType" { "Shell script" }
|
||||
|
||||
$this._PolicyType = (Get-SingletonObject "MacCustomAttributeType")
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Shell Script
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
# region PowerShell Script
|
||||
class DeviceHealthScriptType : IntunePolicyTypeBase
|
||||
{
|
||||
DeviceHealthScriptType() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyGroup = (Get-SingletonObject "ScriptsGroup")
|
||||
$this._PolicyName = "Remediation Script"
|
||||
$this._APITitle = "Remediation Scripts"
|
||||
$this._PolicyBaseName = "Remediations"
|
||||
$this._ID = "DeviceHealthScripts"
|
||||
$this._API = "deviceManagement/deviceHealthScripts"
|
||||
$this._QueryList = "?`$filter=isGlobalScript eq false" # Looks like filters are not working for deviceHealthScripts
|
||||
$this._Permissions = @("DeviceManagementScripts.ReadWrite.All")
|
||||
$this._ObjectClass = "DeviceHealthScriptObject"
|
||||
$this._Icon = "Report"
|
||||
$this._AssignmentsType = "deviceHealthScriptAssignments"
|
||||
$this._ExpandAssignmentsList = $false
|
||||
$this._AssignmentPropertiesToKeep = @("target","runSchedule","runRemediationScript")
|
||||
# deviceHealthScriptType is a read-only GET property - PATCHing it back
|
||||
# is rejected ("Invalid property name: DeviceHealthScriptType").
|
||||
$this._PropertiesToRemoveForUpdate = @('version','isGlobalScript','highestAvailableVersion','deviceHealthScriptType')
|
||||
|
||||
if($null -ne $this._PolicyGroup) {
|
||||
$this._PolicyGroup.AddPolicyType($this)
|
||||
}
|
||||
}
|
||||
|
||||
[Hashtable]PreImportCommand([IntunePolicyBase]$PolicyObject)
|
||||
{
|
||||
if($PolicyObject.Object.isGlobalScript -eq $true)
|
||||
{
|
||||
@{ "Import" = $false }
|
||||
}
|
||||
return (@{})
|
||||
}
|
||||
|
||||
[Hashtable]PreDeleteCommand([IntunePolicyBase]$PolicyObject)
|
||||
{
|
||||
if($PolicyObject.Object.isGlobalScript -eq $true)
|
||||
{
|
||||
@{ "Delete" = $false }
|
||||
}
|
||||
return (@{})
|
||||
}
|
||||
|
||||
[Hashtable]PreUpdateCommand([IntunePolicyBase]$PolicyObject, [IntunePolicyBase]$SourceObject)
|
||||
{
|
||||
if($SourceObject.Object.isGlobalScript -eq $true)
|
||||
{
|
||||
# Class methods discard non-return expressions - without the
|
||||
# explicit return, global scripts were NOT skipped.
|
||||
return @{ "Update" = $false }
|
||||
}
|
||||
return (@{})
|
||||
}
|
||||
|
||||
PostExportCommand([PSCustomObject]$PolicyObject, [String]$PathToFile)
|
||||
{
|
||||
if($PathToFile -and [IO.File]::Exists($PathToFile) -and $PolicyObject.JsonObject.detectionScriptContent -and (Get-CacheObject "ExportScripts") -eq $true)
|
||||
{
|
||||
Write-Log "Export remediation scripts"
|
||||
$fi = [IO.FileInfo]$PathToFile
|
||||
|
||||
try
|
||||
{
|
||||
if($PolicyObject.JsonObject.detectionScriptContent) {
|
||||
[IO.File]::WriteAllBytes(([IO.Path]::Combine($fi.DirectoryName, "$($fi.BaseName)_DetectionScript.ps1")), ([System.Convert]::FromBase64String($PolicyObject.JsonObject.detectionScriptContent)))
|
||||
}
|
||||
|
||||
if($PolicyObject.JsonObject.remediationScriptContent) {
|
||||
[IO.File]::WriteAllBytes(([IO.Path]::Combine($fi.DirectoryName, "$($fi.BaseName)_RemediationScript.ps1")), ([System.Convert]::FromBase64String($PolicyObject.JsonObject.remediationScriptContent)))
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-LogError "Failed to export remediation scripts" $_.Exception
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Class DeviceHealthScriptObject : IntunePolicyBase
|
||||
{
|
||||
DeviceHealthScriptObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
DeviceHealthScriptObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._PlatformName = Get-LanguageString "Platform.windows"
|
||||
|
||||
Add-ObjectProperty $this "ScriptType" { "Remediation Script" }
|
||||
|
||||
$this._PolicyType = (Get-SingletonObject "DeviceHealthScriptType")
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Script functions
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
function Save-IntuneScriptContent
|
||||
{
|
||||
param($ScriptPolicy, [string]$FileName)
|
||||
|
||||
if(-not $ScriptPolicy) { return }
|
||||
|
||||
if($ScriptPolicy.IsFullObject -eq $false) {
|
||||
[void]$ScriptPolicy.Get()
|
||||
}
|
||||
|
||||
if(-not $ScriptPolicy.JsonObject.scriptContent) { return }
|
||||
if([string]::IsNullOrWhiteSpace($FileName)) { throw "A destination file path is required." }
|
||||
|
||||
Write-Log "Download PowerShell script '$($ScriptPolicy.JsonObject.FileName)' from $($ScriptPolicy.Name)"
|
||||
|
||||
# Changed to WriteAllBytes to get rid of BOM characters from Custom Attribute file
|
||||
[IO.File]::WriteAllBytes($FileName, ([System.Convert]::FromBase64String($ScriptPolicy.JsonObject.scriptContent)))
|
||||
return $FileName
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
#ImportOrder 110
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Settings Catalog
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
# region Settings Catalog
|
||||
|
||||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||
class SettingsCatalogTypeBase : IntunePolicyTypeBase
|
||||
{
|
||||
static [bool] $IsAbstract = $true
|
||||
Hidden [string[]]$_FamilyTypes = @()
|
||||
|
||||
SettingsCatalogTypeBase() : Base()
|
||||
{
|
||||
([SettingsCatalogTypeBase]$this).Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._NameProperty = "name"
|
||||
$this._PolicyName = "Settings Catalog"
|
||||
$this._ID = "SettingsCatalogBase"
|
||||
$this._API = "deviceManagement/configurationPolicies"
|
||||
$this._PolicyBaseName = "Settings Catalog"
|
||||
$this._PropertiesToRemove = @('settingCount')
|
||||
$this._Permissions = @("DeviceManagementConfiguration.ReadWrite.All")
|
||||
$this._SubTypeColumn = "TemplateFamily=Family"
|
||||
$this._Expand = "Settings"
|
||||
$this._Icon = "DeviceConfiguration"
|
||||
$this._Dependencies = @("ReusableSettings")
|
||||
$this._ObjectClass = "SettingsCatalogObject"
|
||||
$this._VerifyObject = $true
|
||||
|
||||
$this._PolicyTypeOrder = 200
|
||||
}
|
||||
|
||||
[Hashtable]PreUpdateCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
|
||||
{
|
||||
# Settings Catalog updates must PUT the full body (including settings);
|
||||
# a PATCH carrying the settings payload is rejected by Graph. Same
|
||||
# contract as the portal and the original project
|
||||
# (Start-PreUpdateSettingsCatalog).
|
||||
return @{ "Method" = "PUT" }
|
||||
}
|
||||
|
||||
[Hashtable]GetCompareConfig()
|
||||
{
|
||||
return @{
|
||||
Prop = "settings"
|
||||
GetKey = { param($s) Get-SettingsCatalogSettingKey $s }
|
||||
GetValue = { param($s) Get-SettingsCatalogSettingValue $s }
|
||||
GetCategory = { param($s) Get-SettingsCatalogSettingCategory $s }
|
||||
}
|
||||
}
|
||||
|
||||
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
if($PolicyObject.'@odata.type' -ne "#microsoft.graph.deviceManagementConfigurationPolicy") { return $false }
|
||||
|
||||
if($PolicyObject.templateReference.templateFamily -notin $this._FamilyTypes) {
|
||||
return $false
|
||||
}
|
||||
|
||||
return $true
|
||||
}
|
||||
|
||||
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
if($PolicyObject.JsonObject.templateReference.templateId) {
|
||||
# I do not like this at all and it is a lazy but simple implementation...
|
||||
# It turns out that settingInstanceTemplateId and settingValueTemplateId are case sensitive
|
||||
# and there is ONE setting with a different casing in the Windows Baseline template.
|
||||
# The export saves it with lowercase which causes the import to fail.
|
||||
|
||||
Write-Log "Get template $($PolicyObject.JsonObject.templateReference.templateId)"
|
||||
$templateObj = Invoke-MSGraphAPI -Url "/deviceManagement/configurationPolicyTemplates('$($PolicyObject.JsonObject.templateReference.templateId)')"
|
||||
if($templateObj.lifecycleState -and $templateObj.lifecycleState -ne "active") {
|
||||
Write-Log "Template '$($templateObj.displayName)' '$($templateObj.displayVersion)' is in '$($templateObj.lifecycleState)' state. Current state: $($templateObj.lifecycleState). Import might fail." 2
|
||||
}
|
||||
#Todo: Should probably check for the latest active version and use that instead of the one in the templateReference
|
||||
|
||||
if(-not $script:baseLineTemplate) {
|
||||
$script:baseLineTemplate = @{}
|
||||
}
|
||||
if($script:baseLineTemplate.ContainsKey($PolicyObject.JsonObject.templateReference.templateId)) {
|
||||
$templateReference = $script:baseLineTemplate[$PolicyObject.JsonObject.templateReference.templateId]
|
||||
}
|
||||
else {
|
||||
Write-Log "Get template settings for '$($templateObj.displayName)' '$($templateObj.displayVersion)' ($($PolicyObject.JsonObject.templateReference.templateId))"
|
||||
$templateReference = Invoke-MSGraphAPI -Url "/deviceManagement/configurationPolicyTemplates('$($PolicyObject.JsonObject.templateReference.templateId)')/settingTemplates?`$expand=settingDefinitions&top=1000"
|
||||
$script:baseLineTemplate.Add($PolicyObject.JsonObject.templateReference.templateId, $templateReference)
|
||||
}
|
||||
|
||||
if($templateReference) {
|
||||
$settingsJson = $PolicyObject.JsonObject.Settings | ConvertTo-Json -Depth 50
|
||||
$templateIDs, $dummy = Get-DependencyIDs ($templateReference | ConvertTo-Json -Depth 50)
|
||||
$objectIDs, $dummy = Get-DependencyIDs $settingsJson
|
||||
$diff = Compare-Object @($templateIDs) @($objectIDs) -CaseSensitive
|
||||
$updated = $false
|
||||
foreach($diffItem in ($diff | Where-Object SideIndicator -eq "=>")) {
|
||||
$templateID = $templateIDs | Where-Object { $_ -eq $diffItem.InputObject }
|
||||
if($templateID) {
|
||||
# Found but with different casing
|
||||
$settingsJson = $settingsJson -replace $diffItem.InputObject, $templateID
|
||||
$updated = $true
|
||||
}
|
||||
}
|
||||
if($updated) {
|
||||
$PolicyObject.JsonObject.Settings = @($settingsJson | ConvertFrom-Json -Depth 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||
class SettingsCatalogObject : IntunePolicyBase
|
||||
{
|
||||
Hidden [String]$_TemplateFamilyName = $null
|
||||
|
||||
SettingsCatalogObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||
|
||||
SettingsCatalogObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$settingCatalogClasses = Get-IntuneSettingsCatalogClasses
|
||||
$policyType = $settingCatalogClasses | Where-Object { $_._FamilyTypes -contains $this.JsonObject.templateReference.templateFamily }
|
||||
if($policyType) {
|
||||
$this._PolicyType = $policyType
|
||||
}
|
||||
|
||||
$this._PolicyName = ?? $this.JsonObject.templateReference.templateDisplayName $this._PolicyType.PolicyBaseType
|
||||
|
||||
# Opt into the sub-resource batching contract so Invoke-PolicyHydrate
|
||||
# fans out the assignments fetch via $batch.
|
||||
$this._HasSubResourceBatch = $true
|
||||
|
||||
Add-ObjectProperty $this "TemplateVersion" { $this.JsonObject.templateReference.templateDisplayVersion }
|
||||
if($this.JsonObject.templateReference.templateFamily) {
|
||||
$this._TemplateFamilyName = (?? (Get-EndpointSecurityCategoryName $this.JsonObject.templateReference.templateFamily) $this.JsonObject.templateReference.templateFamily)
|
||||
}
|
||||
else {
|
||||
$this._TemplateFamilyName = $null
|
||||
}
|
||||
Add-ObjectProperty $this "TemplateFamily" { $this._TemplateFamilyName }
|
||||
Add-ObjectProperty $this "Category" { $this._TemplateFamilyName }
|
||||
}
|
||||
|
||||
#Hidden [String] GetName()
|
||||
#{
|
||||
# return $this.JsonObject.Name
|
||||
#}
|
||||
|
||||
# Sub-resource contract. The per-id body GET ($expand=assignments,settings)
|
||||
# returns an empty `assignments` array — known Graph quirk on
|
||||
# configurationPolicies. We hit the dedicated /assignments endpoint via
|
||||
# $batch instead so Invoke-PolicyHydrate fans them out in parallel.
|
||||
[PSCustomObject[]] GetSubResourceBatchRequests([int]$Phase)
|
||||
{
|
||||
if($Phase -ne 1) { return @() }
|
||||
return @([PSCustomObject]@{
|
||||
Key = 'assignments'
|
||||
Url = "$($this._PolicyType.API)/$($this.Id)/assignments"
|
||||
})
|
||||
}
|
||||
|
||||
[PSCustomObject[]] ApplySubResourceBatchResult([int]$Phase, [string]$Key, $Body)
|
||||
{
|
||||
if($Phase -eq 1 -and $Key -eq 'assignments') {
|
||||
# Comma-prefix forces an array reference through the if-expression —
|
||||
# without it, PowerShell unwraps a single-element @() on assignment
|
||||
# and ConvertTo-Json then emits a bare object instead of [{...}].
|
||||
# Lowercase `assignments` matches the Graph wire shape and the
|
||||
# `assignments@odata.*` metadata properties the body fetch leaves
|
||||
# alongside. PSObject is case-insensitive on read, so existing
|
||||
# callers reading `Assignments` keep working.
|
||||
$assignments = if($Body -and $Body.value) { ,@($Body.value) } else { ,@() }
|
||||
Add-Member -InputObject $this.JsonObject -MemberType NoteProperty -Name 'assignments' -Value $assignments -Force
|
||||
}
|
||||
return @()
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Reusable Settings
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
# region Reusable Settings
|
||||
|
||||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||
class ReusableSettingsTypeBase : IntunePolicyTypeBase
|
||||
{
|
||||
static [bool] $IsAbstract = $true
|
||||
|
||||
ReusableSettingsTypeBase() : Base()
|
||||
{
|
||||
([ReusableSettingsTypeBase]$this).Init()
|
||||
}
|
||||
|
||||
Init()
|
||||
{
|
||||
$this._PolicyName = "Reusable Settings"
|
||||
$this._ID = "ReusableSettingsBase"
|
||||
$this._API = "deviceManagement/reusablePolicySettings"
|
||||
$this._PolicyBaseName = "Reusable Settings"
|
||||
$this._PropertiesToRemove = @('Settings','@OData.Type')
|
||||
$this._Permissions=@("DeviceManagementConfiguration.ReadWrite.All")
|
||||
$this._ImportOrder = 70
|
||||
$this._ExpandAssignmentsList = $false
|
||||
$this._SkipRemoveProperties = @("@OData.Type")
|
||||
$this._ObjectClass = "ReusableSettingObject"
|
||||
$this._SupportsAssignments = $false
|
||||
|
||||
$this._PolicyTypeOrder = 210
|
||||
}
|
||||
|
||||
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
|
||||
{
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
class ReusableSettingsObjectBase : IntunePolicyBase
|
||||
{
|
||||
ReusableSettingsObjectBase([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.InitReusable() }
|
||||
|
||||
ReusableSettingsObjectBase() : Base() { $this.InitReusable() }
|
||||
|
||||
Hidden InitReusable()
|
||||
{
|
||||
# The list endpoint omits settingInstance; hydration fetches it via the
|
||||
# sub-resource contract (single GET coalesced into the hydrate $batch).
|
||||
# Owns the API in one place — was previously duplicated in
|
||||
# Sync-BulkExportReusableSettings (Internal/PolicyHydrateExtras.ps1).
|
||||
$this._HasSubResourceBatch = $true
|
||||
}
|
||||
|
||||
[PSCustomObject[]] GetSubResourceBatchRequests([int]$Phase)
|
||||
{
|
||||
if($Phase -ne 1) { return @() }
|
||||
if($this.JsonObject.settingInstance) { return @() } # already present
|
||||
return @([PSCustomObject]@{
|
||||
Key = 'reusableSettingInstance'
|
||||
Url = "$($this._PolicyType.API)/$($this.Id)?`$select=settinginstance,displayname,description"
|
||||
Headers = @{ Accept = 'application/json;odata.metadata=none' }
|
||||
})
|
||||
}
|
||||
|
||||
[PSCustomObject[]] ApplySubResourceBatchResult([int]$Phase, [string]$Key, $Body)
|
||||
{
|
||||
if($Phase -eq 1 -and $Key -eq 'reusableSettingInstance' -and $Body -and $Body.settingInstance) {
|
||||
Add-Member -InputObject $this.JsonObject -MemberType NoteProperty -Name 'settingInstance' -Value $Body.settingInstance -Force
|
||||
}
|
||||
return @()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
#endregion
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user