├── .mailmap ├── .markdownlint.json ├── images ├── Entra-Tiering-Security-Model-Cloud Admin Lifecycle.png ├── Entra-Tiering-Security-Model-Cloud Admin Access Controls.png ├── Entra-Tiering-Security-Model-Cloud Account Tier Separation.png └── logo.svg ├── documents ├── Entra-Tiering-Security-Model-Cloud Admin Lifecycle.pdf ├── Entra-Tiering-Security-Model-Cloud Admin Access Controls.pdf └── Entra-Tiering-Security-Model-Cloud Account Tier Separation.pdf ├── .github └── ISSUE_TEMPLATE │ ├── feature-request---.md │ └── bug-report---.md ├── .vscode ├── launch.json ├── settings.json ├── extensions.json ├── tasks.json ├── PSScriptAnalyzerSettings.psd1 └── PSScriptAnalyzerCustomRules.ps1 ├── .gitattributes ├── .editorconfig ├── Entra-Tiering-Security-Model.code-workspace ├── LICENSE.txt ├── SECURITY.md ├── .devcontainer └── devcontainer.json ├── config └── AzAutoFWProject │ └── AzAutoFWProject.local.template.psd1 ├── .gitignore ├── scripts └── AzAutoFWProject │ └── Update-AzAutoFWProject.ps1 ├── Runbooks ├── CloudAdmin_0000__Common_0002__Get-PrimaryAccountsByCloudAdminAccount.ps1 ├── CloudAdmin_3100__Invoke-Scheduled-CloudAdministrator-AccountLifecycleManagement.ps1 └── CloudAdmin_0000__Common_0000__Get-ConfigurationConstants.ps1 └── README.md /.mailmap: -------------------------------------------------------------------------------- 1 | Julian Pawlowski <75446+jpawlowski@users.noreply.github.com> 2 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "MD012": false, 3 | "MD013": false, 4 | "MD033": false, 5 | "MD041": false 6 | } 7 | -------------------------------------------------------------------------------- /images/Entra-Tiering-Security-Model-Cloud Admin Lifecycle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/workoho/Entra-Tiering-Security-Model/HEAD/images/Entra-Tiering-Security-Model-Cloud Admin Lifecycle.png -------------------------------------------------------------------------------- /documents/Entra-Tiering-Security-Model-Cloud Admin Lifecycle.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/workoho/Entra-Tiering-Security-Model/HEAD/documents/Entra-Tiering-Security-Model-Cloud Admin Lifecycle.pdf -------------------------------------------------------------------------------- /images/Entra-Tiering-Security-Model-Cloud Admin Access Controls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/workoho/Entra-Tiering-Security-Model/HEAD/images/Entra-Tiering-Security-Model-Cloud Admin Access Controls.png -------------------------------------------------------------------------------- /documents/Entra-Tiering-Security-Model-Cloud Admin Access Controls.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/workoho/Entra-Tiering-Security-Model/HEAD/documents/Entra-Tiering-Security-Model-Cloud Admin Access Controls.pdf -------------------------------------------------------------------------------- /images/Entra-Tiering-Security-Model-Cloud Account Tier Separation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/workoho/Entra-Tiering-Security-Model/HEAD/images/Entra-Tiering-Security-Model-Cloud Account Tier Separation.png -------------------------------------------------------------------------------- /documents/Entra-Tiering-Security-Model-Cloud Account Tier Separation.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/workoho/Entra-Tiering-Security-Model/HEAD/documents/Entra-Tiering-Security-Model-Cloud Account Tier Separation.pdf -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request---.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Feature request \U0001F680" 3 | about: Suggest an idea 4 | labels: enhancement 5 | 6 | --- 7 | 8 | ## Summary 9 | Brief explanation of the feature. 10 | 11 | ### Basic example 12 | Include a basic example or links here. 13 | 14 | ### Motivation 15 | Why are we doing this? What use cases does it support? What is the expected outcome? 16 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "PowerShell: Launch Current File", 6 | "type": "PowerShell", 7 | "request": "launch", 8 | "script": "${file}", 9 | "cwd": "${file}" 10 | }, 11 | { 12 | "name": "PowerShell: Interactive Session", 13 | "type": "PowerShell", 14 | "request": "launch", 15 | "cwd": "${workspaceFolder}" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report---.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Bug report \U0001F41E" 3 | about: Create a bug report 4 | labels: bug 5 | 6 | --- 7 | 8 | ## Describe the bug 9 | A clear and concise description of what the bug is. 10 | 11 | ### Steps to reproduce 12 | Steps to reproduce the behavior. 13 | 14 | ### Expected behavior 15 | A clear and concise description of what you expected to happen. 16 | 17 | ### Environment 18 | - OS: [e.g. Arch Linux] 19 | - Other details that you think may affect. 20 | 21 | ### Additional context 22 | Add any other context about the problem here. 23 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Theme settings 3 | "workbench.preferredDarkColorTheme": "Dark Powershell Theme", 4 | "workbench.preferredLightColorTheme": "PowerShell ISE", 5 | 6 | // PowerShell settings 7 | "powershell.scriptAnalysis.settingsPath": ".vscode/PSScriptAnalyzerSettings.psd1", 8 | "powershell.cwd": "Runbooks", 9 | "powershell.integratedConsole.showOnStartup": true, 10 | 11 | // Terminal settings 12 | "terminal.integrated.defaultProfile.linux": "pwsh", 13 | "terminal.integrated.defaultProfile.osx": "pwsh", 14 | "terminal.integrated.defaultProfile.windows": "PowerShell", 15 | } 16 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior, in case people don't have core.autocrlf set. 2 | * text=auto 3 | 4 | # PowerShell files will always have CRLF line endings on checkout and in the repository. 5 | *.ps1 text eol=crlf 6 | *.psd1 text eol=crlf 7 | *.psm1 text eol=crlf 8 | *.ps1xml text eol=crlf 9 | *.psc1 text eol=crlf 10 | *.clixml text eol=crlf 11 | 12 | # Windows script and batch files will always have CRLF line endings on checkout and in the repository. 13 | *.cmd text eol=crlf 14 | *.bat text eol=crlf 15 | 16 | # Set svg to binary type, as SVG is unlikely to be editted by hand. Can be treated as checked in blob 17 | *.svg binary 18 | 19 | # Denote all files that are truly binary and should not be modified. 20 | *.png binary 21 | *.jpg binary 22 | *.pdf binary 23 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | 10 | # PowerShell files 11 | [*.{ps1,psd1,psm1,ps1xml,psc1,clixml}] 12 | end_of_line = crlf 13 | indent_style = space 14 | indent_size = 4 15 | trim_trailing_whitespace = true 16 | 17 | # Windows script and batch files 18 | [*.{cmd,bat}] 19 | end_of_line = crlf 20 | indent_style = space 21 | indent_size = 4 22 | trim_trailing_whitespace = true 23 | 24 | # CSV, Markdown, and Text files 25 | [*.{csv,md,txt}] 26 | trim_trailing_whitespace = true 27 | 28 | # JSON and XML files 29 | [*.{json,xml,yml,code-workspace}] 30 | indent_style = space 31 | indent_size = 2 32 | 33 | # Matches the exact file .editorconfig 34 | [.editorconfig] 35 | indent_style = space 36 | indent_size = 2 37 | -------------------------------------------------------------------------------- /Entra-Tiering-Security-Model.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": ".", 5 | "name": "Entra Tiering Security Model", 6 | }, 7 | { 8 | "path": "../AzAuto-Common-Runbook-FW", 9 | "name": "AzAutoFramework", 10 | } 11 | ], 12 | "settings": { 13 | // Theme settings 14 | "workbench.preferredDarkColorTheme": "Dark Powershell Theme", 15 | "workbench.preferredLightColorTheme": "PowerShell ISE", 16 | 17 | // PowerShell settings 18 | "powershell.scriptAnalysis.settingsPath": ".vscode/PSScriptAnalyzerSettings.psd1", 19 | "powershell.cwd": "Entra Tiering Security Model", 20 | "powershell.integratedConsole.showOnStartup": true, 21 | 22 | // Terminal settings 23 | "terminal.integrated.defaultProfile.linux": "pwsh", 24 | "terminal.integrated.defaultProfile.osx": "pwsh", 25 | "terminal.integrated.defaultProfile.windows": "PowerShell", 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright © Workoho GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | // Container development related extensions 4 | "ms-vscode-remote.remote-containers", 5 | "github.codespaces", 6 | 7 | // Code Editing related extensions 8 | "editorconfig.editorconfig", 9 | 10 | // Git related extensions 11 | "mhutchie.git-graph", 12 | "donjayamanne.githistory", 13 | "eamodio.gitlens", 14 | 15 | // GitHub related extensions 16 | "ms-vscode.vscode-github-issue-notebooks", 17 | "github.vscode-pull-request-github", 18 | "bierner.github-markdown-preview", 19 | "github.vscode-github-actions", 20 | "me-dutour-mathieu.vscode-github-actions", 21 | 22 | // Markdown related extensions 23 | "davidanson.vscode-markdownlint", 24 | "yzhang.markdown-all-in-one", 25 | 26 | // PowerShell related extensions 27 | "ms-vscode.powershell", 28 | "martinfliegner.dark-powershell-theme", 29 | 30 | // YAML related extensions 31 | "redhat.vscode-yaml", 32 | 33 | // Azure related extensions 34 | "ms-azuretools.vscode-azureresourcegroups", 35 | "ms-azuretools.vscode-azurestorage", 36 | "ms-azuretools.vscode-docker", 37 | "ms-azuretools.vscode-bicep" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Update-AzAutoFWProject", 6 | "detail": "Invoke Azure Automation Framework update script", 7 | "type": "shell", 8 | "command": "command -v pwsh >/dev/null 2>&1 || { echo 'PowerShell (pwsh) could not be found. Please install PowerShell or check your PATH.'; exit 1; }; pwsh -ExecutionPolicy Bypass -NoLogo -NonInteractive -File scripts/AzAutoFWProject/Update-AzAutoFWProject.ps1 -VsCodeTask", 9 | "windows": { 10 | "command": "if (Get-Command pwsh.exe -ErrorAction SilentlyContinue) { pwsh.exe -ExecutionPolicy Bypass -NoLogo -NonInteractive -File ./scripts/AzAutoFWProject/Update-AzAutoFWProject.ps1 -VsCodeTask } else { if (Get-Command powershell.exe -ErrorAction SilentlyContinue) { powershell.exe -ExecutionPolicy Bypass -NoLogo -NonInteractive -File ./scripts/AzAutoFWProject/Update-AzAutoFWProject.ps1 -VsCodeTask } else { Write-Output 'Neither PowerShell Core (pwsh.exe) nor Windows PowerShell (powershell.exe) could be found. Please install PowerShell or check your PATH.' } }" 11 | }, 12 | "problemMatcher": { 13 | "owner": "Update-AzAutoFWProject.ps1", 14 | "fileLocation": "autoDetect", 15 | "pattern": { 16 | "regexp": "^(WARNING|ERROR):\\s*([\\s\\S]*)$", 17 | "severity": 1, 18 | "message": 2 19 | } 20 | }, 21 | "presentation": { 22 | "echo": false, 23 | "reveal": "always", 24 | "revealProblems": "onProblem", 25 | "focus": false, 26 | "panel": "dedicated", 27 | "showReuseMessage": true, 28 | "clear": true 29 | }, 30 | "group": { 31 | "kind": "build", 32 | "isDefault": true 33 | }, 34 | "runOptions": { 35 | "runOn": "folderOpen" 36 | }, 37 | "icon": { 38 | "id": "terminal-powershell" 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security 2 | 3 | The runbooks of this Azure Automation framework intent to be as secure as possible. They are intended to be run in automation accounts that might follow a tiering security concept, and are classified as Tier 0. 4 | 5 | For that particular reason, it was also a design decision to _not_ have this functionality put into a dedicated PowerShell module that can be installed from PowerShell Gallery. 6 | 7 | If you believe you have found a security vulnerability or would like to suggest improvements that may contribute to increased security, please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the project team by opening a security advisory here: 14 | https://github.com/workoho/Entra-Tiering-Security-Model/security/advisories/new 15 | 16 | As an alternative, you may also report them to the Workoho Security Team by email to [secure@workoho.com](mailto:secure@workoho.com). If desired, you may also encrypt your message with our PGP key; please see [Workoho's Security.txt file](https://workoho.com/.well-known/security.txt) for further details. 17 | 18 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 19 | 20 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 21 | * Full paths of source file(s) related to the manifestation of the issue 22 | * The location of the affected source code (tag/branch/commit or direct URL) 23 | * Any special configuration required to reproduce the issue 24 | * Step-by-step instructions to reproduce the issue 25 | * Proof-of-concept or exploit code (if possible) 26 | * Impact of the issue, including how an attacker might exploit the issue 27 | 28 | This information will help us triage your report more quickly. 29 | 30 | **Please note that Workoho does _not_ offer any bug bounty program.** 31 | 32 | ## Preferred Languages 33 | 34 | We prefer all communications to be in English. 35 | -------------------------------------------------------------------------------- /.vscode/PSScriptAnalyzerSettings.psd1: -------------------------------------------------------------------------------- 1 | @{ 2 | # Use Severity when you want to limit the generated diagnostic records to a 3 | # subset of: Error, Warning and Information. 4 | # Uncomment the following line if you only want Errors and Warnings but 5 | # not Information diagnostic records. 6 | Severity = @('Error', 'Warning') 7 | 8 | # Use IncludeRules when you want to run only a subset of the default rule set. 9 | #IncludeRules = @('PSAvoidDefaultValueSwitchParameter', 10 | # 'PSMissingModuleManifestField', 11 | # 'PSReservedCmdletChar', 12 | # 'PSReservedParams', 13 | # 'PSShouldProcess', 14 | # 'PSUseApprovedVerbs', 15 | # 'PSUseDeclaredVarsMoreThanAssigments') 16 | 17 | # Use ExcludeRules when you want to run most of the default set of rules except 18 | # for a few rules you wish to "exclude". Note: if a rule is in both IncludeRules 19 | # and ExcludeRules, the rule will be excluded. 20 | #ExcludeRules = @('PSAvoidUsingWriteHost','PSMissingModuleManifestField') 21 | 22 | CustomRulePath = @( 23 | # Path to the custom rule module 24 | '.vscode/PSScriptAnalyzerCustomRules.ps1' 25 | ) 26 | 27 | # You can use the following entry to supply parameters to rules that take parameters. 28 | # For instance, the PSAvoidUsingCmdletAliases rule takes a whitelist for aliases you 29 | # want to allow. 30 | Rules = @{ 31 | # Do not flag 'cd' alias. 32 | PSAvoidUsingCmdletAliases = @{Whitelist = @('cd') } 33 | 34 | # Check if your script uses cmdlets that are compatible with the following platforms 35 | PSUseCompatibleCmdlets = @{ 36 | Compatibility = @( 37 | 'desktop-5.1.14393.206-windows' 38 | 'core-6.1.0-linux' 39 | 'core-6.1.0-macos' 40 | 'core-6.1.0-windows' 41 | ) 42 | } 43 | 44 | PSUseCompatibleSyntax = @{ 45 | # This turns the rule on (setting it to false will turn it off) 46 | Enable = $true 47 | 48 | # List the targeted versions of PowerShell here 49 | TargetVersions = @( 50 | '5.1' # Runbooks must work with PS 5.1 51 | '7.2' 52 | ) 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Entra-Tiering-Security-Model", 3 | "image": "ghcr.io/workoho/azauto-common-runbook-fw:latest", 4 | "features": { 5 | "ghcr.io/devcontainers/features/common-utils:2": { 6 | "installZsh": "true", 7 | "username": "vscode", 8 | "upgradePackages": "true", 9 | "nonFreePackages": "true" 10 | }, 11 | "ghcr.io/flexwie/devcontainer-features/op:1": {} 12 | }, 13 | 14 | "postCreateCommand": "sudo chsh vscode -s \"$(which pwsh)\"", 15 | 16 | // Configure tool-specific properties. 17 | "customizations": { 18 | // Configure properties specific to VS Code. 19 | "vscode": { 20 | // Set *default* container specific settings.json values on container create. 21 | "settings": { 22 | "terminal.integrated.defaultProfile.linux": "pwsh" 23 | }, 24 | 25 | // Add the IDs of extensions you want installed when the container is created. 26 | "extensions": [ 27 | // Container development related extensions 28 | "ms-vscode-remote.remote-containers", 29 | "github.codespaces", 30 | 31 | // Code Editing related extensions 32 | "editorconfig.editorconfig", 33 | 34 | // Git related extensions 35 | "mhutchie.git-graph", 36 | "donjayamanne.githistory", 37 | "eamodio.gitlens", 38 | 39 | // GitHub related extensions 40 | "ms-vscode.vscode-github-issue-notebooks", 41 | "github.vscode-pull-request-github", 42 | "bierner.github-markdown-preview", 43 | "github.vscode-github-actions", 44 | "me-dutour-mathieu.vscode-github-actions", 45 | 46 | // Markdown related extensions 47 | "davidanson.vscode-markdownlint", 48 | "yzhang.markdown-all-in-one", 49 | 50 | // PowerShell related extensions 51 | "ms-vscode.powershell", 52 | "martinfliegner.dark-powershell-theme", 53 | 54 | // YAML related extensions 55 | "redhat.vscode-yaml", 56 | 57 | // Azure related extensions 58 | "ms-azuretools.vscode-azureresourcegroups", 59 | "ms-azuretools.vscode-azurestorage", 60 | "ms-azuretools.vscode-docker", 61 | "ms-azuretools.vscode-bicep" 62 | ] 63 | } 64 | } 65 | 66 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 67 | // "forwardPorts": [], 68 | 69 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 70 | // "remoteUser": "root" 71 | } 72 | -------------------------------------------------------------------------------- /config/AzAutoFWProject/AzAutoFWProject.local.template.psd1: -------------------------------------------------------------------------------- 1 | @{ 2 | ModuleVersion = '0.0.0' # Ignored, only to have a valid psd1 file format. 3 | Author = 'Azure Automation Common Runbook Framework' 4 | Description = 'Template for machine local configuration items that shall not be added to the Git repository.' 5 | PrivateData = @{ 6 | 7 | # Configure the Automation Account to be created or updated 8 | AutomationAccount = @{ 9 | # Can be any name you want, but must be unique within the Azure tenant. 10 | # Following the naming convention --aa001 is recommended. 11 | Name = 'prodsub-germanywestcentral-aa001' 12 | 13 | # The plan determines the pricing tier of the automation account. 14 | # 'Free' is limited to 500 minutes per month, 'Basic' is limited to 10,000 minutes per month. 15 | Plan = 'Basic' 16 | 17 | # The location of the automation account. 18 | # If the location is not set, the location of the resource group will be used. 19 | Location = '' 20 | 21 | # The resource group should already exist, otherwise it will be created. 22 | # When following the naming convention --automation-rg, 23 | # the will be used for the location of the resource group, if the Location property is not set. 24 | ResourceGroupName = 'prodsub-germanywestcentral-automation-rg' 25 | 26 | # The subscription ID and tenant ID can be found in the Azure portal. 27 | SubscriptionId = '00000000-0000-0000-0000-000000000000' 28 | TenantId = '00000000-0000-0000-0000-000000000000' 29 | 30 | # Azure tags to be added to the automation account (once during creation only). 31 | # If the resource group does not exist yet and is created by this script, 32 | # the tags will be added to the resource group as well. 33 | Tag = @{ 34 | Application = 'CloudAdmin' # could be your project or application name 35 | Environment = 'Production' # Production, Staging, Development, etc. 36 | Owner = 'TeamA' # Team or owner of the resource, should be an email address 37 | } 38 | } 39 | 40 | # If you would like to set any values for Automation Variables, you can do so here. 41 | AutomationVariable = @( 42 | # # EXAMPLE: 43 | # @{ 44 | # Name = 'AV_ProjectName_VariableName' 45 | # Value = '' 46 | # } 47 | ) 48 | 49 | # Configure Managed Identities for the Azure Automation Account. 50 | ManagedIdentity = @( 51 | # You might move Managed Identity defintions from the public AzAutoFWProject.psd1 52 | # to this local configuration file for improved security. 53 | ) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Azure Automation Common Runbook Framework 2 | # https://github.com/workoho/AzAuto-Common-Runbook-FW 3 | setup/AzAutoFWProject/* 4 | scripts/AzAutoFWProject/* 5 | !scripts/AzAutoFWProject/Update-AzAutoFWProject.ps1 6 | Runbooks/Common_* 7 | config/*.local.psd1 8 | !config/AzAutoFWProject/AzAutoFWProject.local.template.psd1 9 | config/*.local.json 10 | 11 | # Created by https://www.toptal.com/developers/gitignore/api/git,linux,macos,windows,visualstudiocode,dotenv 12 | # Edit at https://www.toptal.com/developers/gitignore?templates=git,linux,macos,windows,visualstudiocode,dotenv 13 | 14 | ### dotenv ### 15 | .env 16 | 17 | ### Git ### 18 | # Created by git for backups. To disable backups in Git: 19 | # $ git config --global mergetool.keepBackup false 20 | *.orig 21 | 22 | # Created by git when using merge tools for conflicts 23 | *.BACKUP.* 24 | *.BASE.* 25 | *.LOCAL.* 26 | *.REMOTE.* 27 | *_BACKUP_*.txt 28 | *_BASE_*.txt 29 | *_LOCAL_*.txt 30 | *_REMOTE_*.txt 31 | 32 | ### Linux ### 33 | *~ 34 | 35 | # temporary files which can be created if a process still has a handle open of a deleted file 36 | .fuse_hidden* 37 | 38 | # KDE directory preferences 39 | .directory 40 | 41 | # Linux trash folder which might appear on any partition or disk 42 | .Trash-* 43 | 44 | # .nfs files are created when an open file is removed but is still being accessed 45 | .nfs* 46 | 47 | ### macOS ### 48 | # General 49 | .DS_Store 50 | .AppleDouble 51 | .LSOverride 52 | 53 | # Icon must end with two \r 54 | Icon 55 | 56 | # Thumbnails 57 | ._* 58 | 59 | # Files that might appear in the root of a volume 60 | .DocumentRevisions-V100 61 | .fseventsd 62 | .Spotlight-V100 63 | .TemporaryItems 64 | .Trashes 65 | .VolumeIcon.icns 66 | .com.apple.timemachine.donotpresent 67 | 68 | # Directories potentially created on remote AFP share 69 | .AppleDB 70 | .AppleDesktop 71 | Network Trash Folder 72 | Temporary Items 73 | .apdisk 74 | 75 | ### macOS Patch ### 76 | # iCloud generated files 77 | *.icloud 78 | 79 | ### VisualStudioCode ### 80 | .vscode/* 81 | !.vscode/settings.json 82 | !.vscode/tasks.json 83 | !.vscode/launch.json 84 | !.vscode/extensions.json 85 | !.vscode/*.code-snippets 86 | !.vscode/PSScriptAnalyzerSettings.psd1 87 | !.vscode/PSScriptAnalyzerCustomRules.ps1 88 | 89 | # Local History for Visual Studio Code 90 | .history/ 91 | 92 | # Built Visual Studio Code Extensions 93 | *.vsix 94 | 95 | ### VisualStudioCode Patch ### 96 | # Ignore all local history of files 97 | .history 98 | .ionide 99 | 100 | ### Windows ### 101 | # Windows thumbnail cache files 102 | Thumbs.db 103 | Thumbs.db:encryptable 104 | ehthumbs.db 105 | ehthumbs_vista.db 106 | 107 | # Dump file 108 | *.stackdump 109 | 110 | # Folder config file 111 | [Dd]esktop.ini 112 | 113 | # Recycle Bin used on file shares 114 | $RECYCLE.BIN/ 115 | 116 | # Windows Installer files 117 | *.cab 118 | *.msi 119 | *.msix 120 | *.msm 121 | *.msp 122 | 123 | # Windows shortcuts 124 | *.lnk 125 | 126 | # End of https://www.toptal.com/developers/gitignore/api/git,linux,macos,windows,visualstudiocode,dotenv 127 | -------------------------------------------------------------------------------- /images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /scripts/AzAutoFWProject/Update-AzAutoFWProject.ps1: -------------------------------------------------------------------------------- 1 | <#PSScriptInfo 2 | .VERSION 1.0.1 3 | .GUID b5e78940-5e2f-427d-87a1-c1630ed8c3da 4 | .AUTHOR Julian Pawlowski 5 | .COMPANYNAME Workoho GmbH 6 | .COPYRIGHT © 2024 Workoho GmbH 7 | .TAGS 8 | .LICENSEURI https://github.com/workoho/AzAuto-Project.tmpl/LICENSE.txt 9 | .PROJECTURI https://github.com/workoho/AzAuto-Project.tmpl 10 | .ICONURI 11 | .EXTERNALMODULEDEPENDENCIES 12 | .REQUIREDSCRIPTS 13 | .EXTERNALSCRIPTDEPENDENCIES 14 | .RELEASENOTES 15 | Version 1.0.1 (2024-05-25) 16 | - Use Write-Host to avoid output to the pipeline, avoiding interpretation as shell commands 17 | - Set error code when exiting with error 18 | #> 19 | 20 | <# 21 | .SYNOPSIS 22 | Clone the Azure Automation Common Runbook Framework repository and invoke its setup scripts. 23 | 24 | .DESCRIPTION 25 | Make sure that a clone of the Azure Automation Common Runbook Framework repository 26 | exists in parallel to this project repository. For example: 27 | 28 | C:\Developer\AzAuto-Project.tmpl 29 | C:\Developer\AzAuto-Common-Runbook-FW 30 | 31 | After this, invoke this script from the setup folder of the parent repository: 32 | 33 | C:\Developer\AzAuto-Common-Runbook-FW\setup\AzAutoFWProject\Update-AzAutoFWProject.ps1 34 | 35 | You may run this script at any time to update the project setup. 36 | When opening the project in Visual Studio Code, a task to run this script is already 37 | configured in .vscode\tasks.json. 38 | 39 | .EXAMPLE 40 | Update-AzAutoFWProject.ps1 41 | #> 42 | 43 | [CmdletBinding()] 44 | param( 45 | [switch]$VsCodeTask 46 | ) 47 | 48 | Write-Verbose "---START of $((Get-Item $PSCommandPath).Name), $((Test-ScriptFileInfo $PSCommandPath | Select-Object -Property Version, Guid | & { process{$_.PSObject.Properties | & { process{$_.Name + ': ' + $_.Value} }} }) -join ', ') ---" 49 | 50 | $commonParameters = 'Verbose', 'Debug', 'ErrorAction', 'WarningAction', 'InformationAction', 'ErrorVariable', 'WarningVariable', 'InformationVariable', 'OutVariable', 'OutBuffer', 'PipelineVariable', 'WhatIf' 51 | $commonBoundParameters = $PSBoundParameters.Keys | Where-Object { $_ -in $commonParameters } | ForEach-Object { @{ $_ = $PSBoundParameters[$_] } } 52 | 53 | #region Read Project Configuration 54 | $projectDir = (Get-Item $PSScriptRoot).Parent.Parent.FullName 55 | $configDir = Join-Path $projectDir (Join-Path 'config' 'AzAutoFWProject') 56 | $configName = 'AzAutoFWProject.psd1' 57 | $config = $null 58 | $configScriptPath = Join-Path $projectDir (Join-Path 'scripts' (Join-Path 'AzAutoFWProject' 'Get-AzAutoFWConfig.ps1')) 59 | 60 | Get-ChildItem -Path $configDir -File -Filter '*.template.*' -Recurse | & { 61 | process { 62 | $targetPath = $_.FullName -replace '\.template\.(.+)$', '.$1' 63 | if (-not (Test-Path $targetPath)) { 64 | Write-Verbose "Copying $_ to $targetPath" 65 | Copy-Item -Path $_.FullName -Destination $targetPath -Force 66 | } 67 | } 68 | } 69 | 70 | if ( 71 | (Test-Path $configScriptPath -PathType Leaf) -and 72 | ( 73 | ((Get-Item $configScriptPath).LinkType -ne "SymbolicLink") -or 74 | ( 75 | Test-Path -LiteralPath ( 76 | Resolve-Path -Path ( 77 | Join-Path -Path (Split-Path $configScriptPath) -ChildPath ( 78 | Get-Item -LiteralPath $configScriptPath -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Target 79 | ) 80 | ) -ErrorAction SilentlyContinue 81 | ) -PathType Leaf -ErrorAction SilentlyContinue 82 | ) 83 | ) 84 | ) { 85 | Write-Verbose 'Found parent update script.' 86 | if ($commonBoundParameters) { 87 | $config = & $configScriptPath -ConfigDir $configDir -ConfigName $configName @commonBoundParameters 88 | } 89 | else { 90 | $config = & $configScriptPath -ConfigDir $configDir -ConfigName $configName 91 | } 92 | } 93 | else { 94 | # This will only run when the project is not yet configured. 95 | Write-Verbose 'Missing parent update script: Reading minimum configuration for project initialization.' 96 | $configPath = Join-Path $configDir $configName 97 | $config = $null 98 | try { 99 | $config = Import-PowerShellDataFile -Path $configPath -ErrorAction Stop | & { 100 | process { 101 | $_.Keys | Where-Object { $_ -notin ('ModuleVersion', 'Author', 'Description', 'PrivateData') } | & { 102 | process { 103 | $_.Remove($_) 104 | } 105 | } 106 | $_.PrivateData.Remove('PSData') 107 | $local:configData = $_ 108 | $_.PrivateData.GetEnumerator() | & { 109 | process { 110 | $configData.Add($_.Key, $_.Value) 111 | } 112 | } 113 | $_.Remove('PrivateData') 114 | $_ 115 | } 116 | } 117 | } 118 | catch { 119 | Write-Error "Failed to read configuration file ${configPath}: $_" -ErrorAction Stop 120 | exit 1 121 | } 122 | $config.Project = @{ Directory = $projectDir } 123 | $config.Config = @{ Directory = $configDir; Name = $configName; Path = $configPath } 124 | $config.IsAzAutoFWProject = $true 125 | } 126 | 127 | if (-not $config.GitRepositoryUrl) { Write-Error "config.GitRepositoryUrl is missing in $configPath"; exit 1 } 128 | if (-not $config.GitReference) { Write-Error "config.GitReference is missing in $configPath"; exit 1 } 129 | #endregion 130 | 131 | #region Clone repository if not exists 132 | if (-not (Get-Command git -ErrorAction SilentlyContinue)) { 133 | Write-Error "Git is not installed on this system." 134 | exit 1 135 | } 136 | 137 | $AzAutoFWDir = Join-Path (Get-Item $PSScriptRoot).Parent.Parent.Parent.FullName ( 138 | [IO.Path]::GetFileNameWithoutExtension((Split-Path $config.GitRepositoryUrl -Leaf)) 139 | ).TrimEnd('.git') 140 | 141 | if (-Not (Test-Path (Join-Path $AzAutoFWDir '.git') -PathType Container)) { 142 | try { 143 | Write-Host "Cloning $($config.GitRepositoryUrl) to $AzAutoFWDir" 144 | $output = git clone --quiet $config.GitRepositoryUrl $AzAutoFWDir 2>&1 145 | if ($LASTEXITCODE -ne 0) { Throw "Failed to clone repository: $output" } 146 | } 147 | catch { 148 | Write-Error $_ 149 | exit 1 150 | } 151 | } 152 | #endregion 153 | 154 | #region Invoke sibling script from parent repository 155 | try { 156 | Join-Path $AzAutoFWDir (Join-Path 'scripts' (Join-Path 'AzAutoFWProject' (Split-Path $PSCommandPath -Leaf))) | & { 157 | process { 158 | if (Test-Path $_ -PathType Leaf) { 159 | if ($commonBoundParameters) { 160 | & $_ -ChildConfig $config -VsCodeTask:$VsCodeTask @commonBoundParameters 161 | } 162 | else { 163 | & $_ -ChildConfig $config -VsCodeTask:$VsCodeTask 164 | } 165 | } 166 | else { 167 | Write-Error "Could not find $_" -ErrorAction Stop 168 | } 169 | } 170 | } 171 | } 172 | catch { 173 | Write-Error $_ 174 | exit 1 175 | } 176 | #endregion 177 | 178 | Write-Verbose "-----END of $((Get-Item $PSCommandPath).Name) ---" 179 | -------------------------------------------------------------------------------- /.vscode/PSScriptAnalyzerCustomRules.ps1: -------------------------------------------------------------------------------- 1 | # Custom rule to check for usage of Join-Path 2 | function PSScriptAnalyzer_CustomRule_JoinPath { 3 | [CmdletBinding()] 4 | param( 5 | [Parameter(Mandatory = $true)] 6 | [System.Management.Automation.Language.ScriptBlockAst] $Ast 7 | ) 8 | 9 | # Check for usage of Join-Path 10 | $findings = $Ast.FindAll({ 11 | param($node) 12 | 13 | # Check for usage of Join-Path with more than two arguments 14 | if ($node.CommandElements[0].Value -eq 'Join-Path' -and $node.CommandElements.Count -gt 2) { 15 | $violationMessage = "Join-Path in PowerShell 5.1 can only handle two paths." 16 | $extent = $node.Extent 17 | $diagnosticRecord = New-Object -TypeName Microsoft.Windows.PSScriptAnalyzer.Generic.DiagnosticRecord -ArgumentList $violationMessage, $extent, 'CustomRule', 'Warning', $null, $null 18 | return $diagnosticRecord 19 | } 20 | 21 | return $null 22 | }, $true) 23 | 24 | return $findings 25 | } 26 | 27 | function PSScriptAnalyzer_CustomRule_SplitPath { 28 | [CmdletBinding()] 29 | param( 30 | [Parameter(Mandatory = $true)] 31 | [System.Management.Automation.Language.ScriptBlockAst] $Ast 32 | ) 33 | 34 | # Check for usage of Split-Path 35 | $findings = $Ast.FindAll({ 36 | param($node) 37 | 38 | # Check for usage of Split-Path with -LeafBase parameter 39 | if ($node.CommandElements[0].Value -eq 'Split-Path' -and $node.CommandElements.Value -contains '-LeafBase') { 40 | $violationMessage = "Split-Path in PowerShell 5.1 does not support the -LeafBase parameter." 41 | $extent = $node.Extent 42 | $diagnosticRecord = New-Object -TypeName Microsoft.Windows.PSScriptAnalyzer.Generic.DiagnosticRecord -ArgumentList $violationMessage, $extent, 'CustomRule', 'Warning', $null, $null 43 | return $diagnosticRecord 44 | } 45 | 46 | return $null 47 | }, $true) 48 | 49 | return $findings 50 | } 51 | 52 | function PSScriptAnalyzer_CustomRule_GetChildItem { 53 | [CmdletBinding()] 54 | param( 55 | [Parameter(Mandatory = $true)] 56 | [System.Management.Automation.Language.ScriptBlockAst] $Ast 57 | ) 58 | 59 | # Check for usage of Get-ChildItem 60 | $findings = $Ast.FindAll({ 61 | param($node) 62 | 63 | # Check for usage of Get-ChildItem with -Depth parameter 64 | if ($node.CommandElements[0].Value -eq 'Get-ChildItem' -and $node.CommandElements.Value -contains '-Depth') { 65 | $violationMessage = "Get-ChildItem in PowerShell 5.1 does not support the -Depth parameter." 66 | $extent = $node.Extent 67 | $diagnosticRecord = New-Object -TypeName Microsoft.Windows.PSScriptAnalyzer.Generic.DiagnosticRecord -ArgumentList $violationMessage, $extent, 'CustomRule', 'Warning', $null, $null 68 | return $diagnosticRecord 69 | } 70 | 71 | return $null 72 | }, $true) 73 | 74 | return $findings 75 | } 76 | 77 | function PSScriptAnalyzer_CustomRule_ConvertToJson { 78 | [CmdletBinding()] 79 | param( 80 | [Parameter(Mandatory = $true)] 81 | [System.Management.Automation.Language.ScriptBlockAst] $Ast 82 | ) 83 | 84 | # Check for usage of ConvertTo-Json 85 | $findings = $Ast.FindAll({ 86 | param($node) 87 | 88 | # Check for usage of ConvertTo-Json with -AsHashtable parameter 89 | if ($node.CommandElements[0].Value -eq 'ConvertTo-Json' -and $node.CommandElements.Value -contains '-AsHashtable') { 90 | $violationMessage = "ConvertTo-Json in PowerShell 5.1 does not support the -AsHashtable parameter." 91 | $extent = $node.Extent 92 | $diagnosticRecord = New-Object -TypeName Microsoft.Windows.PSScriptAnalyzer.Generic.DiagnosticRecord -ArgumentList $violationMessage, $extent, 'CustomRule', 'Warning', $null, $null 93 | return $diagnosticRecord 94 | } 95 | 96 | return $null 97 | }, $true) 98 | 99 | return $findings 100 | } 101 | 102 | function PSScriptAnalyzer_CustomRule_InvokeRestMethod { 103 | [CmdletBinding()] 104 | param( 105 | [Parameter(Mandatory = $true)] 106 | [System.Management.Automation.Language.ScriptBlockAst] $Ast 107 | ) 108 | 109 | # Check for usage of Invoke-RestMethod 110 | $findings = $Ast.FindAll({ 111 | param($node) 112 | 113 | # Check for usage of Invoke-RestMethod with -SkipCertificateCheck parameter 114 | if ($node.CommandElements[0].Value -eq 'Invoke-RestMethod' -and $node.CommandElements.Value -contains '-SkipCertificateCheck') { 115 | $violationMessage = "Invoke-RestMethod in PowerShell 5.1 does not support the -SkipCertificateCheck parameter." 116 | $extent = $node.Extent 117 | $diagnosticRecord = New-Object -TypeName Microsoft.Windows.PSScriptAnalyzer.Generic.DiagnosticRecord -ArgumentList $violationMessage, $extent, 'CustomRule', 'Warning', $null, $null 118 | return $diagnosticRecord 119 | } 120 | 121 | # Check for usage of Invoke-RestMethod without -UseBasicParsing parameter 122 | if ($node.CommandElements[0].Value -eq 'Invoke-RestMethod' -and $node.CommandElements.Value -notContains '-UseBasicParsing') { 123 | $violationMessage = "Invoke-RestMethod in PowerShell 5.1 should be used with the -UseBasicParsing parameter to avoid dependency on Internet Explorer." 124 | $extent = $node.Extent 125 | $diagnosticRecord = New-Object -TypeName Microsoft.Windows.PSScriptAnalyzer.Generic.DiagnosticRecord -ArgumentList $violationMessage, $extent, 'CustomRule', 'Warning', $null, $null 126 | return $diagnosticRecord 127 | } 128 | 129 | return $null 130 | }, $true) 131 | 132 | return $findings 133 | } 134 | 135 | function PSScriptAnalyzer_CustomRule_InvokeWebRequest { 136 | [CmdletBinding()] 137 | param( 138 | [Parameter(Mandatory = $true)] 139 | [System.Management.Automation.Language.ScriptBlockAst] $Ast 140 | ) 141 | 142 | # Check for usage of Invoke-WebRequest 143 | $findings = $Ast.FindAll({ 144 | param($node) 145 | 146 | # Check for usage of Invoke-WebRequest with -SkipCertificateCheck parameter 147 | if ($node.CommandElements[0].Value -eq 'Invoke-WebRequest' -and $node.CommandElements.Value -contains '-SkipCertificateCheck') { 148 | $violationMessage = "Invoke-WebRequest in PowerShell 5.1 does not support the -SkipCertificateCheck parameter." 149 | $extent = $node.Extent 150 | $diagnosticRecord = New-Object -TypeName Microsoft.Windows.PSScriptAnalyzer.Generic.DiagnosticRecord -ArgumentList $violationMessage, $extent, 'CustomRule', 'Warning', $null, $null 151 | return $diagnosticRecord 152 | } 153 | 154 | # Check for usage of Invoke-WebRequest without -UseBasicParsing parameter 155 | if ($node.CommandElements[0].Value -eq 'Invoke-WebRequest' -and $node.CommandElements.Value -notContains '-UseBasicParsing') { 156 | $violationMessage = "Invoke-WebRequest in PowerShell 5.1 should be used with the -UseBasicParsing parameter to avoid dependency on Internet Explorer." 157 | $extent = $node.Extent 158 | $diagnosticRecord = New-Object -TypeName Microsoft.Windows.PSScriptAnalyzer.Generic.DiagnosticRecord -ArgumentList $violationMessage, $extent, 'CustomRule', 'Warning', $null, $null 159 | return $diagnosticRecord 160 | } 161 | 162 | return $null 163 | }, $true) 164 | 165 | return $findings 166 | } 167 | 168 | function PSScriptAnalyzer_CustomRule_NewObject { 169 | [CmdletBinding()] 170 | param( 171 | [Parameter(Mandatory = $true)] 172 | [System.Management.Automation.Language.ScriptBlockAst] $Ast 173 | ) 174 | 175 | # Check for usage of New-Object 176 | $findings = $Ast.FindAll({ 177 | param($node) 178 | 179 | # Check for usage of New-Object with -Property parameter 180 | if ($node.CommandElements[0].Value -eq 'New-Object' -and $node.CommandElements.Value -contains '-Property') { 181 | # Get the argument to the -Property parameter 182 | $propertyArgument = $node.CommandElements[$node.CommandElements.IndexOf('-Property') + 1] 183 | 184 | # Check if the argument is a hashtable or psobject 185 | if ($propertyArgument.Type -is [System.Management.Automation.Language.HashtableAst] -or $propertyArgument.Type -is [System.Management.Automation.Language.PSObjectAst]) { 186 | # Check if the hashtable or psobject contains properties with values of type psobject 187 | foreach ($pair in $propertyArgument.Pairs) { 188 | if ($pair.Value.Type -is [System.Management.Automation.Language.PSObjectAst]) { 189 | $violationMessage = "New-Object in PowerShell 5.1 does not support the -Property parameter with a hashtable or psobject that contains properties with values of type psobject." 190 | $extent = $node.Extent 191 | $diagnosticRecord = New-Object -TypeName Microsoft.Windows.PSScriptAnalyzer.Generic.DiagnosticRecord -ArgumentList $violationMessage, $extent, 'PSScriptAnalyzer_CustomRule_NewObject', 'Warning', $null, $null 192 | return $diagnosticRecord 193 | } 194 | } 195 | } 196 | } 197 | 198 | return $null 199 | }, $true) 200 | 201 | return $findings 202 | } 203 | 204 | # Export the functions as a rule for PSScriptAnalyzer 205 | Export-ModuleMember -Function PSScriptAnalyzer_CustomRule_JoinPath 206 | Export-ModuleMember -Function PSScriptAnalyzer_CustomRule_SplitPath 207 | Export-ModuleMember -Function PSScriptAnalyzer_CustomRule_GetChildItem 208 | Export-ModuleMember -Function PSScriptAnalyzer_CustomRule_ConvertToJson 209 | Export-ModuleMember -Function PSScriptAnalyzer_CustomRule_InvokeRestMethod 210 | Export-ModuleMember -Function PSScriptAnalyzer_CustomRule_InvokeWebRequest 211 | Export-ModuleMember -Function PSScriptAnalyzer_CustomRule_NewObject 212 | -------------------------------------------------------------------------------- /Runbooks/CloudAdmin_0000__Common_0002__Get-PrimaryAccountsByCloudAdminAccount.ps1: -------------------------------------------------------------------------------- 1 | <#PSScriptInfo 2 | .VERSION 1.3.0 3 | .GUID 9be21e88-4210-47d9-a533-3beb443de48a 4 | .AUTHOR Julian Pawlowski 5 | .COMPANYNAME Workoho GmbH 6 | .COPYRIGHT © 2024 Workoho GmbH 7 | .TAGS 8 | .LICENSEURI https://github.com/workoho/Entra-Tiering-Security-Model/blob/main/LICENSE.txt 9 | .PROJECTURI https://github.com/workoho/Entra-Tiering-Security-Model 10 | .ICONURI 11 | .EXTERNALMODULEDEPENDENCIES Microsoft.Graph.Authentication 12 | .REQUIREDSCRIPTS CloudAdmin_0000__Common_0000__Get-ConfigurationConstants.ps1,CloudAdmin_0000__Common_0001__Get-CloudAdminAccountsByPrimaryAccount.ps1 13 | .EXTERNALSCRIPTDEPENDENCIES https://github.com/workoho/AzAuto-Common-Runbook-FW 14 | .RELEASENOTES 15 | Version 1.3.0 (2024-06-23) 16 | - Fixed CSV output when using hashtables. 17 | #> 18 | 19 | <# 20 | .SYNOPSIS 21 | Retrieves the referenced primary user account of a cloud admin account. 22 | 23 | .DESCRIPTION 24 | This script retrieves the primary user account of a cloud admin account from the Microsoft Graph API. 25 | 26 | .PARAMETER CloudAdminUserId 27 | Specifies the object ID of the cloud admin account that is used to search for the referenced primary user account. 28 | May be an array, or a comma-separated string of object IDs or user principal names. 29 | If not provided, all cloud admin accounts are retrieved. 30 | 31 | .PARAMETER Tier 32 | When provided without CloudAdminUserId, all cloud admin accounts of the specified security tier level are returned. Must be a value between 0 and 2. 33 | 34 | When provided together with CloudAdminUserId, it validates the cloud admin account to filter out accounts that do not match the specified security tier level. 35 | In case only one security tier level is specified, it is applied to all cloud admin accounts. 36 | Otherwise, the number of security tier levels must match the number of cloud admin accounts. 37 | 38 | May be an array, or a comma-separated string of security tier levels. 39 | 40 | .PARAMETER OutJson 41 | Specifies whether to output the result as JSON. 42 | 43 | .PARAMETER OutCsv 44 | Specifies whether to output the result as CSV. 45 | The 'cloudAdminAccounts' property is expanded into separate columns for each security tier level. 46 | Also, the 'signInActivity' and 'onPremisesExtensionAttributes' properties are expanded into separate columns. 47 | 48 | If the AV_CloudAdmin_StorageUri variable is set in the Azure Automation account, the CSV file is stored in the specified Azure Blob Storage container or Azure File Share. 49 | The file name is prefixed with the current date and time in the format 'yyyyMMddTHHmmssfffZ'. 50 | Note that the managed identity of the Azure Automation account must have the necessary permissions to write to the specified storage account. 51 | That is, the managed identity must have the 'Storage Blob Data Contributor' role for a blob container or the 'Storage File Data SMB Share Contributor' role for a file share. 52 | Remember that general roles like 'Owner' or 'Contributor' do not grant write access to storage accounts. 53 | 54 | .PARAMETER OutText 55 | Specifies whether to output the result as text. 56 | This will only output the user principal name of the primary user accounts. 57 | 58 | .OUTPUTS 59 | Output may be requested in JSON, CSV, or text format by using one of the parameters -OutJson, -OutCsv, or -OutText. 60 | The output includes properties such as 'userPrincipalName', 'accountEnabled', 'lastSuccessfulSignInDateTime', etc. 61 | 62 | If none of these parameters are used, the script returns an object array where each object represents a primary user account 63 | and its associated cloud admin accounts in the 'cloudAdminAccounts' property. 64 | #> 65 | 66 | [CmdletBinding()] 67 | Param ( 68 | [array] $CloudAdminUserId, 69 | [array] $Tier, 70 | [boolean] $OutJson, 71 | [boolean] $OutCsv, 72 | [boolean] $OutText 73 | ) 74 | 75 | if ($PSCommandPath) { Write-Verbose "---START of $((Get-Item $PSCommandPath).Name), $((Test-ScriptFileInfo $PSCommandPath | Select-Object -Property Version, Guid | & { process{$_.PSObject.Properties | & { process{$_.Name + ': ' + $_.Value} }} }) -join ', ') ---" } 76 | $StartupVariables = (Get-Variable | & { process { $_.Name } }) # Remember existing variables so we can cleanup ours at the end of the script 77 | 78 | #region [COMMON] PARAMETER VALIDATION ------------------------------------------ 79 | 80 | # Allow comma-separated values for CloudAdminUserId and Tier 81 | $CloudAdminUserId = if ([string]::IsNullOrEmpty($CloudAdminUserId)) { @() } else { 82 | @($CloudAdminUserId) | & { process { $_ -split '\s*,\s*' } } | & { process { if (-not [string]::IsNullOrEmpty($_)) { $_ } } } 83 | } 84 | $Tier = if ([string]::IsNullOrEmpty($Tier)) { @() } else { 85 | @($Tier) | & { process { $_ -split '\s*,\s*' } } | & { 86 | process { 87 | if (-not [string]::IsNullOrEmpty($_)) { 88 | try { 89 | [System.Convert]::ToInt32($_) 90 | if ($_ -lt 0 -or $_ -gt 2) { 91 | Throw 'Tier must be a value between 0 and 2.' 92 | } 93 | } 94 | catch { 95 | Throw "[GetPrimaryAccountsByCloudAdminAccount]: - Auto-converting of Tier string to Int32 failed: $_" 96 | } 97 | } 98 | } 99 | } 100 | } 101 | 102 | if ( 103 | ($CloudAdminUserId.Count -gt 1) -and 104 | ($Tier.Count -gt 1) -and 105 | ($CloudAdminUserId.Count -ne $Tier.Count) 106 | ) { 107 | Throw 'CloudAdminUserId and Tier must contain the same number of items for batch processing.' 108 | } 109 | #endregion --------------------------------------------------------------------- 110 | 111 | #region [COMMON] OPEN CONNECTIONS: Microsoft Graph ----------------------------- 112 | ./Common_0001__Connect-MgGraph.ps1 -Scopes @( 113 | # Read-only permissions 114 | 'AuditLog.Read.All' 115 | 'Directory.Read.All' 116 | ) 117 | #endregion --------------------------------------------------------------------- 118 | 119 | #region [COMMON] ENVIRONMENT --------------------------------------------------- 120 | ./Common_0002__Import-AzAutomationVariableToPSEnv.ps1 1> $null # Implicitly connects to Azure Cloud 121 | $Constants = ./CloudAdmin_0000__Common_0000__Get-ConfigurationConstants.ps1 122 | ./Common_0000__Convert-PSEnvToPSScriptVariable.ps1 -Variable $Constants 1> $null 123 | #endregion --------------------------------------------------------------------- 124 | 125 | #region Required Microsoft Entra Directory Permissions Validation -------------- 126 | $DirectoryPermissions = ./Common_0003__Confirm-MgDirectoryRoleActiveAssignment.ps1 -Roles @( 127 | # Read user sign-in activity logs 128 | Write-Verbose '[RequiredMicrosoftEntraDirectoryPermissionsValidation]: - Require directory role: Reports Reader, Directory Scope: /' 129 | @{ 130 | DisplayName = 'Reports Reader' 131 | TemplateId = '4a5d8f65-41da-4de4-8968-e035b65339cf' 132 | } 133 | ) 134 | #endregion --------------------------------------------------------------------- 135 | 136 | #region [COMMON] INITIALIZE SCRIPT VARIABLES ----------------------------------- 137 | if ([string]::IsNullOrEmpty($ReferenceExtensionAttribute)) { 138 | Throw 'ReferenceExtensionAttribute must not be null or empty.' 139 | } 140 | if ([string]::IsNullOrEmpty($AccountTypeExtensionAttributePrefix_Tier0)) { 141 | Throw 'AccountTypeExtensionAttributePrefix_Tier0 must not be null or empty.' 142 | } 143 | if ([string]::IsNullOrEmpty($AccountTypeExtensionAttributePrefix_Tier1)) { 144 | Throw 'AccountTypeExtensionAttributePrefix_Tier1 must not be null or empty.' 145 | } 146 | if ([string]::IsNullOrEmpty($AccountTypeExtensionAttributePrefix_Tier2)) { 147 | Throw 'AccountTypeExtensionAttributePrefix_Tier2 must not be null or empty.' 148 | } 149 | $TierLevel = @{ 150 | $AccountTypeExtensionAttributePrefix_Tier0 = 0 151 | $AccountTypeExtensionAttributePrefix_Tier1 = 1 152 | $AccountTypeExtensionAttributePrefix_Tier2 = 2 153 | } 154 | $TierPrefix = @( 155 | $AccountTypeExtensionAttributePrefix_Tier0 156 | $AccountTypeExtensionAttributePrefix_Tier1 157 | $AccountTypeExtensionAttributePrefix_Tier2 158 | ) 159 | $return = [System.Collections.ArrayList]::new() 160 | #endregion --------------------------------------------------------------------- 161 | 162 | #region Get all Cloud Admin User Accounts -------------------------------------- 163 | if ($null -eq $CloudAdminUserId -or $CloudAdminUserId.Count -eq 0) { 164 | $CloudAdminUserId = ./CloudAdmin_0000__Common_0001__Get-CloudAdminAccountsByPrimaryAccount.ps1 -Tier $Tier 165 | } 166 | #endregion --------------------------------------------------------------------- 167 | 168 | #region Find Primary User Accounts --------------------------------------------- 169 | if ($CloudAdminUserId.Count -gt 0) { 170 | $i = 0 171 | @($CloudAdminUserId) | & { 172 | process { 173 | try { 174 | if ($_ -is [string]) { 175 | Write-Verbose "[$i]: - Processing string userId '$_'." 176 | 177 | try { 178 | $userObj = @( 179 | ./Common_0002__Find-MgUserWithSoftDeleted.ps1 -UserId $_ -Property @( 180 | 'displayName' 181 | 'userPrincipalName' 182 | 'id' 183 | 'accountEnabled' 184 | 'createdDateTime' 185 | 'deletedDateTime' 186 | 'mail' 187 | 'signInActivity' 188 | 'onPremisesExtensionAttributes' 189 | ) 190 | )[0] | & { 191 | process { 192 | # Return as ordered hashtable to maintain the order of properties 193 | [ordered] @{ 194 | securityTierLevel = $null 195 | displayName = $_.displayName 196 | userPrincipalName = $_.userPrincipalName 197 | id = $_.id 198 | accountEnabled = $_.accountEnabled 199 | createdDateTime = $_.createdDateTime 200 | deletedDateTime = $_.deletedDateTime 201 | mail = $_.mail 202 | signInActivity = $_.signInActivity 203 | onPremisesExtensionAttributes = $_.onPremisesExtensionAttributes 204 | } 205 | } 206 | } 207 | } 208 | catch { 209 | Throw $_ 210 | } 211 | 212 | if ($null -eq $userObj) { 213 | Write-Error "userId '$_' not found." 214 | return 215 | } 216 | } 217 | else { 218 | $userObj = $_ 219 | Write-Verbose "[$i]: - Processing object userId '$($_.Id)'." 220 | } 221 | 222 | if ( 223 | $null -eq $userObj.onPremisesExtensionAttributes."extensionAttribute$ReferenceExtensionAttribute" -or 224 | $userObj.onPremisesExtensionAttributes."extensionAttribute$ReferenceExtensionAttribute" -notmatch '^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$' 225 | ) { 226 | Write-Error "userId '$($userObj.Id)' is not a cloud admin account: No reference extension attribute 'extensionAttribute$ReferenceExtensionAttribute' set." 227 | return 228 | } 229 | 230 | if ( 231 | $null -eq $userObj.onPremisesExtensionAttributes."extensionAttribute$AccountTypeExtensionAttribute" -or 232 | [string]::IsNullOrEmpty($userObj.onPremisesExtensionAttributes."extensionAttribute$AccountTypeExtensionAttribute".Trim()) 233 | ) { 234 | Write-Error "userId '$($userObj.Id)' is not a cloud admin account: No account type extension attribute 'extensionAttribute$AccountTypeExtensionAttribute' set." 235 | return 236 | } 237 | 238 | $securityTierLevel = $null 239 | foreach ($key in $TierLevel.Keys) { 240 | if ($userObj.onPremisesExtensionAttributes."extensionAttribute$AccountTypeExtensionAttribute" -like "$key*") { 241 | $securityTierLevel = $TierLevel[$key] 242 | break 243 | } 244 | } 245 | 246 | if ($null -eq $securityTierLevel) { 247 | Write-Error "userId '$($userObj.Id)' has an invalid security tier level marker: Account type extension attribute 'extensionAttribute$AccountTypeExtensionAttribute' value '$($userObj.onPremisesExtensionAttributes."extensionAttribute$AccountTypeExtensionAttribute")' is not valid." 248 | } 249 | 250 | if ($null -ne $Tier) { 251 | if ($Tier.Count -eq 1) { 252 | if ($Tier[0] -ne $securityTierLevel) { 253 | Write-Warning "userId '$($userObj.Id)' has defined security tier level '$securityTierLevel' but does not validate to the specified security tier level '$($Tier[0])'." 254 | return 255 | } 256 | } 257 | else { 258 | if ($Tier[$i] -ne $securityTierLevel) { 259 | Write-Warning "userId '$_' has defined security tier level '$securityTierLevel' but does not validate to the specified security tier level '$($Tier[$i])'." 260 | return 261 | } 262 | } 263 | } 264 | 265 | if ($null -eq $userObj.securityTierLevel) { 266 | $userObj.securityTierLevel = $securityTierLevel 267 | } 268 | 269 | try { 270 | $refUserObj = @( 271 | ./Common_0002__Find-MgUserWithSoftDeleted.ps1 -UserId $userObj.onPremisesExtensionAttributes."extensionAttribute$ReferenceExtensionAttribute" -Property @( 272 | 'displayName' 273 | 'userPrincipalName' 274 | 'onPremisesSamAccountName' 275 | 'id' 276 | 'accountEnabled' 277 | 'createdDateTime' 278 | 'deletedDateTime' 279 | 'mail' 280 | 'companyName' 281 | 'department' 282 | 'streetAddress' 283 | 'city' 284 | 'postalCode' 285 | 'state' 286 | 'country' 287 | 'signInActivity' 288 | 'onPremisesExtensionAttributes' 289 | ) -ExpandProperty @( 290 | @{ 291 | manager = @( 292 | 'displayName' 293 | 'userPrincipalName' 294 | 'onPremisesSamAccountName' 295 | 'id' 296 | 'accountEnabled' 297 | 'mail' 298 | ) 299 | } 300 | ) 301 | )[0] | & { 302 | process { 303 | # Return as ordered hashtable to maintain the order of properties 304 | [ordered] @{ 305 | displayName = $_.displayName 306 | userPrincipalName = $_.userPrincipalName 307 | onPremisesSamAccountName = $_.onPremisesSamAccountName 308 | id = $_.id 309 | accountEnabled = $_.accountEnabled 310 | createdDateTime = $_.createdDateTime 311 | deletedDateTime = $_.deletedDateTime 312 | mail = $_.mail 313 | companyName = $_.companyName 314 | department = $_.department 315 | streetAddress = $_.streetAddress 316 | city = $_.city 317 | postalCode = $_.postalCode 318 | state = $_.state 319 | country = $_.country 320 | signInActivity = $_.signInActivity 321 | onPremisesExtensionAttributes = $_.onPremisesExtensionAttributes 322 | manager = [ordered] @{ 323 | displayName = $_.manager.displayName 324 | userPrincipalName = $_.manager.userPrincipalName 325 | onPremisesSamAccountName = $_.manager.onPremisesSamAccountName 326 | id = $_.manager.id 327 | accountEnabled = $_.manager.accountEnabled 328 | mail = $_.manager.mail 329 | } 330 | } 331 | } 332 | } 333 | } 334 | catch { 335 | Throw $_ 336 | } 337 | 338 | if ($null -eq $refUserObj) { 339 | Write-Verbose "$($userObj.userPrincipalName): - Referral user Id '$($userObj.onPremisesExtensionAttributes."extensionAttribute$ReferenceExtensionAttribute")' not found." 340 | return 341 | } 342 | 343 | if ($null -eq $userObj.referralUserAccount) { 344 | $userObj.referralUserAccount = @{ 345 | id = $refUserObj.id 346 | } 347 | } 348 | 349 | $existingRefUserObj = $return | Where-Object { $_.Id -eq $refUserObj.Id } 350 | if ($null -eq $existingRefUserObj) { 351 | $refUserObj.cloudAdminAccounts = [System.Collections.ArrayList] @( 352 | $userObj 353 | ) 354 | [void] $return.Add($refUserObj) 355 | } 356 | else { 357 | [void] $existingRefUserObj.cloudAdminAccounts.Add($userObj) 358 | } 359 | } 360 | finally { 361 | $i++ 362 | Clear-Variable -Name existingRefUserObj -ErrorAction SilentlyContinue 363 | Clear-Variable -Name userObj -ErrorAction SilentlyContinue 364 | Clear-Variable -Name refUserObj -ErrorAction SilentlyContinue 365 | [System.GC]::Collect() 366 | [System.GC]::WaitForPendingFinalizers() 367 | } 368 | } 369 | } 370 | } 371 | 372 | Write-Verbose "[Get-ReferralUser-Account]: - Found $($return.Count) reference user accounts." 373 | #endregion --------------------------------------------------------------------- 374 | 375 | Get-Variable | Where-Object { $StartupVariables -notcontains $_.Name } | & { process { Remove-Variable -Scope 0 -Name $_.Name -Force -WarningAction SilentlyContinue -ErrorAction SilentlyContinue -Verbose:$false -Debug:$false -Confirm:$false -WhatIf:$false } } # Delete variables created in this script to free up memory for tiny Azure Automation sandbox 376 | if ($PSCommandPath) { Write-Verbose "-----END of $((Get-Item $PSCommandPath).Name) ---" } 377 | 378 | if ($OutJson) { if ($return.Count -eq 0) { return '[]' }; ./Common_0000__Write-JsonOutput.ps1 $return; return } 379 | 380 | if ($OutCsv) { 381 | if ($return.Count -eq 0) { 382 | return 'No referenced primary user accounts found.' 383 | } 384 | 385 | $properties = [ordered] @{ 386 | 'lastSignInDateTime' = 'signInActivity.lastSignInDateTime' 387 | 'lastNonInteractiveSignInDateTime' = 'signInActivity.lastNonInteractiveSignInDateTime' 388 | 'lastSuccessfulSignInDateTime' = 'signInActivity.lastSuccessfulSignInDateTime' 389 | 390 | 'onPremExtensionAttribute1' = 'onPremisesExtensionAttributes.extensionAttribute1' 391 | 'onPremExtensionAttribute2' = 'onPremisesExtensionAttributes.extensionAttribute2' 392 | 'onPremExtensionAttribute3' = 'onPremisesExtensionAttributes.extensionAttribute3' 393 | 'onPremExtensionAttribute4' = 'onPremisesExtensionAttributes.extensionAttribute4' 394 | 'onPremExtensionAttribute5' = 'onPremisesExtensionAttributes.extensionAttribute5' 395 | 'onPremExtensionAttribute6' = 'onPremisesExtensionAttributes.extensionAttribute6' 396 | 'onPremExtensionAttribute7' = 'onPremisesExtensionAttributes.extensionAttribute7' 397 | 'onPremExtensionAttribute8' = 'onPremisesExtensionAttributes.extensionAttribute8' 398 | 'onPremExtensionAttribute9' = 'onPremisesExtensionAttributes.extensionAttribute9' 399 | 'onPremExtensionAttribute10' = 'onPremisesExtensionAttributes.extensionAttribute10' 400 | 'onPremExtensionAttribute11' = 'onPremisesExtensionAttributes.extensionAttribute11' 401 | 'onPremExtensionAttribute12' = 'onPremisesExtensionAttributes.extensionAttribute12' 402 | 'onPremExtensionAttribute13' = 'onPremisesExtensionAttributes.extensionAttribute13' 403 | 'onPremExtensionAttribute14' = 'onPremisesExtensionAttributes.extensionAttribute14' 404 | 'onPremExtensionAttribute15' = 'onPremisesExtensionAttributes.extensionAttribute15' 405 | 406 | 'managerDisplayName' = 'manager.displayName' 407 | 'managerUserPrincipalName' = 'manager.userPrincipalName' 408 | 'managerOnPremisesSamAccountName' = 'manager.onPremisesSamAccountName' 409 | 'managerId' = 'manager.id' 410 | 'managerAccountEnabled' = 'manager.accountEnabled' 411 | 'managerMail' = 'manager.mail' 412 | } 413 | 414 | $cloudAdminAccountProperties = [ordered] @{ 415 | 'displayName' = 'displayName' 416 | 'userPrincipalName' = 'userPrincipalName' 417 | 'id' = 'id' 418 | 'accountEnabled' = 'accountEnabled' 419 | 'createdDateTime' = 'createdDateTime' 420 | 'deletedDateTime' = 'deletedDateTime' 421 | 'mail' = 'mail' 422 | 'lastSignInDateTime' = 'signInActivity.lastSignInDateTime' 423 | 'lastNonInteractiveSignInDateTime' = 'signInActivity.lastNonInteractiveSignInDateTime' 424 | 'lastSuccessfulSignInDateTime' = 'signInActivity.lastSuccessfulSignInDateTime' 425 | 'onPremExtensionAttribute1' = 'onPremisesExtensionAttributes.extensionAttribute1' 426 | 'onPremExtensionAttribute2' = 'onPremisesExtensionAttributes.extensionAttribute2' 427 | 'onPremExtensionAttribute3' = 'onPremisesExtensionAttributes.extensionAttribute3' 428 | 'onPremExtensionAttribute4' = 'onPremisesExtensionAttributes.extensionAttribute4' 429 | 'onPremExtensionAttribute5' = 'onPremisesExtensionAttributes.extensionAttribute5' 430 | 'onPremExtensionAttribute6' = 'onPremisesExtensionAttributes.extensionAttribute6' 431 | 'onPremExtensionAttribute7' = 'onPremisesExtensionAttributes.extensionAttribute7' 432 | 'onPremExtensionAttribute8' = 'onPremisesExtensionAttributes.extensionAttribute8' 433 | 'onPremExtensionAttribute9' = 'onPremisesExtensionAttributes.extensionAttribute9' 434 | 'onPremExtensionAttribute10' = 'onPremisesExtensionAttributes.extensionAttribute10' 435 | 'onPremExtensionAttribute11' = 'onPremisesExtensionAttributes.extensionAttribute11' 436 | 'onPremExtensionAttribute12' = 'onPremisesExtensionAttributes.extensionAttribute12' 437 | 'onPremExtensionAttribute13' = 'onPremisesExtensionAttributes.extensionAttribute13' 438 | 'onPremExtensionAttribute14' = 'onPremisesExtensionAttributes.extensionAttribute14' 439 | 'onPremExtensionAttribute15' = 'onPremisesExtensionAttributes.extensionAttribute15' 440 | } 441 | 442 | ./Common_0000__Write-CsvOutput.ps1 -InputObject ( 443 | $return | & { 444 | process { 445 | 446 | # Flatten the nested properties 447 | foreach ($property in $properties.GetEnumerator()) { 448 | $nestedPropertyPath = $property.Value -split '\.' 449 | if ($nestedPropertyPath.count -eq 3) { 450 | $_.$($property.Key) = $_.$($nestedPropertyPath[0]).$($nestedPropertyPath[1]).$($nestedPropertyPath[2]) 451 | } 452 | elseif ($nestedPropertyPath.count -eq 2) { 453 | $_.$($property.Key) = $_.$($nestedPropertyPath[0]).$($nestedPropertyPath[1]) 454 | } 455 | else { 456 | Throw "Invalid nested property path: $($property.Value)" 457 | } 458 | } 459 | 460 | if ($_.cloudAdminAccounts.Count -gt 0) { 461 | for ($t = 0; $t -lt 3; $t++) { 462 | 463 | # If a cloud admin account exists for the current security tier level, flatten the nested properties 464 | $ref = $_ 465 | $_.cloudAdminAccounts | Where-Object { $_.securityTierLevel -eq $t } | & { 466 | process { 467 | foreach ($property in $cloudAdminAccountProperties.GetEnumerator()) { 468 | $propertyName = "T$($t)$($property.Key)" 469 | $nestedPropertyPath = $property.Value -split '\.' 470 | if ($nestedPropertyPath.count -eq 3) { 471 | $ref.$propertyName = $_.$($nestedPropertyPath[0]).$($nestedPropertyPath[1]).$($nestedPropertyPath[2]) 472 | } 473 | elseif ($nestedPropertyPath.count -eq 2) { 474 | $ref.$propertyName = $_.$($nestedPropertyPath[0]).$($nestedPropertyPath[1]) 475 | } 476 | elseif ($nestedPropertyPath.count -eq 1) { 477 | $ref.$propertyName = $_.$($nestedPropertyPath[0]) 478 | } 479 | else { 480 | Throw "Invalid nested property path: $($property.Value)" 481 | } 482 | } 483 | } 484 | } 485 | 486 | # If no cloud admin account exists for the current security tier level, set all properties to $null 487 | if ($null -eq $_."T${t}id") { 488 | foreach ($property in $cloudAdminAccountProperties.GetEnumerator()) { 489 | $_."T$($t)$($property.Key)" = $null 490 | } 491 | } 492 | } 493 | } 494 | 495 | $_.Remove('signInActivity') 496 | $_.Remove('onPremisesExtensionAttributes') 497 | $_.Remove('manager') 498 | $_.Remove('cloudAdminAccounts') 499 | 500 | # Return the hashtable to the pipeline 501 | $_ 502 | } 503 | } 504 | ) -StorageUri $( 505 | if (-not [string]::IsNullOrEmpty($StorageUri)) { 506 | $baseUri = ($uri = [System.Uri]$StorageUri).GetLeftPart([System.UriPartial]::Path) 507 | $baseUri + '/' + [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssfffZ') + '_Get-PrimaryAccountsByCloudAdminAccount.csv' + $uri.Query 508 | } 509 | ) -Metadata $( 510 | $JobInfo = ./Common_0002__Get-AzAutomationJobInfo.ps1 511 | $Metadata = [ordered] @{ 512 | RunbookName = $JobInfo.Runbook.Name 513 | RunbookScriptVersion = $JobInfo.Runbook.ScriptVersion 514 | RunbookScriptGuid = $JobInfo.Runbook.ScriptGuid 515 | CreatedAt = $JobInfo.StartTime 516 | } 517 | $commonParameters = 'OutCsv', 'Verbose', 'Debug', 'ErrorAction', 'WarningAction', 'InformationAction', 'ErrorVariable', 'WarningVariable', 'InformationVariable', 'OutVariable', 'OutBuffer', 'PipelineVariable' 518 | $PSBoundParameters.Keys | Sort-Object | ForEach-Object { 519 | if ($_ -in $commonParameters) { return } 520 | $Metadata["ExportParameter_$_"] = $PSBoundParameters[$_] 521 | } 522 | if (-not ($Metadata.Keys -like 'ExportParameter_*')) { 523 | $Metadata['ExportParameters'] = 'None' 524 | } 525 | [pscustomobject] $Metadata 526 | ) 527 | return 528 | } 529 | 530 | if ($OutText) { if ($return.Count -eq 0) { return 'No referenced primary user accounts found.' }; $return.userPrincipalName; return } 531 | 532 | if ($return.Count -eq 0) { 533 | Write-Information 'No referenced primary user accounts found.' -InformationAction Continue 534 | } 535 | 536 | return $return 537 | -------------------------------------------------------------------------------- /Runbooks/CloudAdmin_3100__Invoke-Scheduled-CloudAdministrator-AccountLifecycleManagement.ps1: -------------------------------------------------------------------------------- 1 | <#PSScriptInfo 2 | .VERSION 1.4.0 3 | .GUID ae957fef-f6c2-458d-bf37-27211dfd2640 4 | .AUTHOR Julian Pawlowski 5 | .COMPANYNAME Workoho GmbH 6 | .COPYRIGHT © 2024 Workoho GmbH 7 | .TAGS TieringModel CloudAdministrator Identity Microsoft365 Security Azure Automation AzureAutomation 8 | .LICENSEURI https://github.com/workoho/Entra-Tiering-Security-Model/blob/main/LICENSE.txt 9 | .PROJECTURI https://github.com/workoho/Entra-Tiering-Security-Model 10 | .ICONURI 11 | .EXTERNALMODULEDEPENDENCIES Microsoft.Graph,Microsoft.Graph.Beta,Az 12 | .REQUIREDSCRIPTS CloudAdmin_0000__Common_0000__Get-ConfigurationConstants.ps1,CloudAdmin_0000__Common_0001__Get-CloudAdminAccountsByPrimaryAccount.ps1 13 | .EXTERNALSCRIPTDEPENDENCIES https://github.com/workoho/AzAuto-Common-Runbook-FW 14 | .RELEASENOTES 15 | Version 1.4.0 (2024-08-29) 16 | - Remove Directory.Write.Restricted checks, see MC866450 17 | #> 18 | 19 | <# 20 | .SYNOPSIS 21 | Manage the lifecycle of dedicated Cloud Administrator accounts based on the Entra Tiering Security Model. 22 | 23 | .DESCRIPTION 24 | This runbook manages the lifecycle of dedicated Cloud Administrator accounts based on the Entra Tiering Security Model. 25 | The runbook is designed to be scheduled and executed on a regular basis to ensure that the Cloud Administrator accounts are in sync with the primary user accounts. 26 | It may also be executed manually to perform lifecycle management actions on-demand, for example, after status changes to a primary user account where you want to ensure that the associated Cloud Administrator account is updated accordingly before the next scheduled run. 27 | 28 | The runbook performs the following actions: 29 | - Soft-deletes Cloud Administrator accounts that have a soft-deleted associated primary user account, or whose object ID cannot be found anymore. 30 | - Restores Cloud Administrator accounts that have been soft-deleted and have an associated primary user account that is NOT soft-deleted. 31 | - Disables Cloud Administrator accounts that have an associated primary user account that is disabled. 32 | - Re-enables Cloud Administrator accounts that have been disabled and have an associated primary user account that is enabled. 33 | 34 | The runbook can be executed in the following modes: 35 | - Single mode: The runbook processes a single Cloud Administrator account. 36 | - Batch mode: The runbook processes multiple Cloud Administrator accounts in a single run. 37 | 38 | Please note that special procedures might be required for early deletion of Cloud Administrator accounts, for example, for cloud admin accounts that are no longer needed, but the primary user account is still active. 39 | In such cases, the Cloud Administrator account must be permanently deleted from Microsoft Entra, including to remove the account from the Entra ID recycle bin. Otherwise, the cloud admin account will be restored automatically. 40 | Alternatively, you may set the 'LifecycleRestoreAfterDelete_Tier0', 'LifecycleRestoreAfterDelete_Tier1', or 'LifecycleRestoreAfterDelete_Tier2' variable to false to prevent automatic restoration of the cloud admin account. 41 | 42 | PLease note that for security reasons, it is highly recommended to keep the automatic restoration of Cloud Administrator accounts disabled. Once a Cloud Administrator account is deleted, it should not be restored anymore and better be re-created with blank permission history if needed. 43 | This is to prevent any hidden security risks that may arise from the restoration of prior permissions and access rights that might not be needed anymore, or that might have been granted to unauthorized users. 44 | You may decide to restore a Cloud Administrator account manually if needed, but this should be a conscious decision and not an automatic process. 45 | 46 | To control automatic lifecycle management, the runbook uses the following Azure Automation variables for configuration: 47 | - LifecycleDelete_Tier0, LifecycleDelete_Tier1, LifecycleDelete_Tier2: Specifies whether to automatically soft-delete Cloud Administrator accounts that have no associated primary user account. Default is false. 48 | - LifecycleRestoreAfterDelete_Tier0, LifecycleRestoreAfterDelete_Tier1, LifecycleRestoreAfterDelete_Tier2: Specifies whether to automatically restore Cloud Administrator accounts that have been soft-deleted and have an associated primary user account. Default is false. 49 | - LifecycleDisable_Tier0, LifecycleDisable_Tier1, LifecycleDisable_Tier2: Specifies whether to automatically disable Cloud Administrator accounts that have a disabled associated primary user account. Default is false. 50 | - LifecycleEnableAfterDisable_Tier0, LifecycleEnableAfterDisable_Tier1, LifecycleEnableAfterDisable_Tier2: Specifies whether to automatically enable Cloud Administrator accounts that have been disabled and have an enabled associated primary user account. Default is false. 51 | 52 | In any case, the runbook will log an action plan in the 'accountLifecycle' property of the output object. 53 | If the action is not performed by the runbook, the status will be set to 'ToBe' and you may follow up manually. 54 | 55 | Using the -OutCsv parameter, the runbook can store the output in an Azure Blob Storage container or Azure File Share. That way, you can keep a record of the lifecycle management actions performed by the runbook. 56 | The CSV file can be used for auditing purposes or to track the lifecycle management of Cloud Administrator accounts over time. 57 | You may also decide to use the runbook for reporting purposes only and use the CSV file to manually perform the lifecycle management actions as needed. 58 | 59 | .PARAMETER ReferralUserId 60 | Specifies the object ID of the primary user account to search for all associated cloud admin accounts. 61 | May be an array, or a comma-separated string of object IDs or user principal names. 62 | If not provided, the script will retrieve all cloud admin accounts. 63 | 64 | .PARAMETER Tier 65 | Specifies the security tier level of the cloud admin accounts to get. Must be a value between 0 and 2. 66 | If not provided, the script will search for all tiers. 67 | 68 | May be an array, or a comma-separated string of security tier levels. 69 | 70 | .PARAMETER OutJson 71 | Specifies whether to output the result as JSON. 72 | 73 | .PARAMETER OutCsv 74 | Specifies whether to output the result as CSV. 75 | The 'referralUserAccount' property will be expanded to include additional properties related to the primary user account. 76 | Also, the 'signInActivity' and 'accountLifecycle' properties are expanded into separate columns. 77 | 78 | If the AV_CloudAdmin_StorageUri variable is set in the Azure Automation account, the CSV file is stored in the specified Azure Blob Storage container or Azure File Share. 79 | The file name is prefixed with the current date and time in the format 'yyyyMMddTHHmmssfffZ'. 80 | Note that the managed identity of the Azure Automation account must have the necessary permissions to write to the specified storage account. 81 | That is, the managed identity must have the 'Storage Blob Data Contributor' role for a blob container or the 'Storage File Data SMB Share Contributor' role for a file share. 82 | Remember that general roles like 'Owner' or 'Contributor' do not grant write access to storage accounts. 83 | 84 | .PARAMETER OutText 85 | Specifies whether to output the result as text. 86 | This will only output the user principal name of the cloud admin accounts with action. 87 | 88 | .OUTPUTS 89 | Output may be requested in JSON, CSV, or text format by using one of the parameters -OutJson, -OutCsv, or -OutText. 90 | The output includes properties such as 'userPrincipalName', 'accountEnabled', 'deletedDateTime', 'lifecycleAction', 'lifecycleActionReason', 'lifecycleStatus', etc. 91 | 92 | If none of these parameters are used, the script returns an object array where each object represents a cloud admin account. 93 | and its associated primary user account in the 'referralUserAccount' property. 94 | #> 95 | 96 | [CmdletBinding()] 97 | Param ( 98 | [Array]$ReferralUserId, 99 | [Array]$Tier, 100 | [Boolean]$OutJson, 101 | [Boolean]$OutCsv, 102 | [Boolean]$OutText 103 | ) 104 | 105 | #region [COMMON] PARAMETER COUNT VALIDATION ------------------------------------ 106 | # Allow comma-separated values for ReferralUserId and Tier 107 | $ReferralUserId = if ([string]::IsNullOrEmpty($ReferralUserId)) { @() } else { 108 | @($ReferralUserId) | & { process { $_ -split '\s*,\s*' } } | & { process { if (-not [string]::IsNullOrEmpty($_)) { $_ } } } 109 | } 110 | $Tier = if ([string]::IsNullOrEmpty($Tier)) { @() } else { 111 | @($Tier) | & { process { $_ -split '\s*,\s*' } } | & { 112 | process { 113 | if (-not [string]::IsNullOrEmpty($_)) { 114 | try { 115 | [System.Convert]::ToInt32($_) 116 | if ($_ -lt 0 -or $_ -gt 2) { 117 | Throw 'Tier must be a value between 0 and 2.' 118 | } 119 | } 120 | catch { 121 | Throw "[InvokeCloudAdministratorAccountLifecycleManagement]: - Auto-converting of Tier string to Int32 failed: $_" 122 | } 123 | } 124 | } 125 | } 126 | } 127 | 128 | if ( 129 | ($ReferralUserId.Count -gt 1) -and 130 | ($ReferralUserId.Count -ne $Tier.Count) 131 | ) { 132 | Throw 'ReferralUserId and Tier must contain the same number of items for batch processing.' 133 | } 134 | #endregion --------------------------------------------------------------------- 135 | 136 | #region [COMMON] OPEN CONNECTIONS: Microsoft Graph ----------------------------- 137 | ./Common_0001__Connect-MgGraph.ps1 -Scopes @( 138 | # Read-only permissions 139 | 'AuditLog.Read.All' 140 | 'Directory.Read.All' 141 | 'Organization.Read.All' 142 | 143 | # Write permissions 144 | 'User.ReadWrite.All' 145 | ) 146 | #endregion --------------------------------------------------------------------- 147 | 148 | #region [COMMON] ENVIRONMENT --------------------------------------------------- 149 | ./Common_0002__Import-AzAutomationVariableToPSEnv.ps1 1> $null # Implicitly connects to Azure Cloud 150 | $Constants = ./CloudAdmin_0000__Common_0000__Get-ConfigurationConstants.ps1 151 | ./Common_0000__Convert-PSEnvToPSScriptVariable.ps1 -Variable $Constants 1> $null 152 | #endregion --------------------------------------------------------------------- 153 | 154 | #region [COMMON] INITIALIZE RETURN VARIABLES ----------------------------------- 155 | $returnOutput = [System.Collections.ArrayList]::new() 156 | $returnInformation = [System.Collections.ArrayList]::new() 157 | $returnWarning = [System.Collections.ArrayList]::new() 158 | $returnError = [System.Collections.ArrayList]::new() 159 | 160 | $LifecycleDelete = @{ 161 | 0 = $LifecycleDelete_Tier0 162 | 1 = $LifecycleDelete_Tier1 163 | 2 = $LifecycleDelete_Tier2 164 | } 165 | $LifecycleRestoreAfterDelete = @{ 166 | 0 = $LifecycleRestoreAfterDelete_Tier0 167 | 1 = $LifecycleRestoreAfterDelete_Tier1 168 | 2 = $LifecycleRestoreAfterDelete_Tier2 169 | } 170 | $LifecycleDisable = @{ 171 | 0 = $LifecycleDisable_Tier0 172 | 1 = $LifecycleDisable_Tier1 173 | 2 = $LifecycleDisable_Tier2 174 | } 175 | $LifecycleEnableAfterDisable = @{ 176 | 0 = $LifecycleEnableAfterDisable_Tier0 177 | 1 = $LifecycleEnableAfterDisable_Tier1 178 | 2 = $LifecycleEnableAfterDisable_Tier2 179 | } 180 | #endregion --------------------------------------------------------------------- 181 | 182 | #region [COMMON] CONCURRENT JOBS ----------------------------------------------- 183 | $concurrentJobsTimeoutError = $false 184 | $ConcurrentJobsWaitStartTime = [DateTime]::UtcNow 185 | if ((./Common_0002__Wait-AzAutomationConcurrentJob.ps1) -ne $true) { 186 | $concurrentJobsTimeoutError = $true 187 | [void] $script:returnError.Add(( ./Common_0000__Write-Error.ps1 @{ 188 | Message = "Maximum job runtime was reached." 189 | ErrorId = '504' 190 | Category = 'OperationTimeout' 191 | RecommendedAction = 'Try again later.' 192 | CategoryActivity = 'Job Concurrency Check' 193 | CategoryReason = "Maximum job runtime was reached." 194 | })) 195 | } 196 | $ConcurrentJobsWaitEndTime = [DateTime]::UtcNow 197 | $ConcurrentJobsTime = $ConcurrentJobsWaitEndTime - $ConcurrentJobsWaitStartTime 198 | #endregion --------------------------------------------------------------------- 199 | 200 | #region Required Microsoft Entra Directory Permissions Validation -------------- 201 | $AllowPrivilegedRoleAdministratorInAzureAutomation = $false 202 | $DirectoryPermissions = ./Common_0003__Confirm-MgDirectoryRoleActiveAssignment.ps1 -AllowPrivilegedRoleAdministratorInAzureAutomation:$AllowPrivilegedRoleAdministratorInAzureAutomation -Roles @( 203 | # Read user sign-in activity logs 204 | Write-Verbose '[RequiredMicrosoftEntraDirectoryPermissionsValidation]: - Require directory role: Reports Reader, Directory Scope: /' 205 | @{ 206 | DisplayName = 'Reports Reader' 207 | TemplateId = '4a5d8f65-41da-4de4-8968-e035b65339cf' 208 | } 209 | 210 | # Recover Cloud Admin Accounts 211 | Write-Verbose "[RequiredMicrosoftEntraDirectoryPermissionsValidation]: - Require directory role: User Administrator, Directory Scope: /" 212 | @{ 213 | DisplayName = 'User Administrator' 214 | TemplateId = 'fe930be7-5e62-47db-91af-98c3a49a38b1' 215 | DirectoryScopeId = '/' 216 | } 217 | @{ 218 | DisplayName = 'Privileged Authentication Administrator' 219 | TemplateId = '7be44c8a-adaf-4e2a-84d6-ab2649e08a13' 220 | DirectoryScopeId = '/' 221 | Justification = 'Perform sensitive actions: https://learn.microsoft.com/en-us/graph/api/resources/users?view=graph-rest-1.0#who-can-perform-sensitive-actions' 222 | } 223 | 224 | # Change existing Tier 0 Cloud Admin Accounts 225 | Write-Verbose "[RequiredMicrosoftEntraDirectoryPermissionsValidation]: - Require directory role (Tier 0): User Administrator, Directory Scope: $(if ($AccountRestrictedAdminUnitId_Tier0) { "/administrativeUnits/$AccountRestrictedAdminUnitId_Tier0" } else { '/' })" 226 | @{ 227 | DisplayName = 'User Administrator' 228 | TemplateId = 'fe930be7-5e62-47db-91af-98c3a49a38b1' 229 | DirectoryScopeId = if ($AccountRestrictedAdminUnitId_Tier0) { "/administrativeUnits/$AccountRestrictedAdminUnitId_Tier0" } else { '/' } 230 | } 231 | @{ 232 | DisplayName = 'Privileged Authentication Administrator' 233 | TemplateId = '7be44c8a-adaf-4e2a-84d6-ab2649e08a13' 234 | DirectoryScopeId = if ($AccountRestrictedAdminUnitId_Tier0) { "/administrativeUnits/$AccountRestrictedAdminUnitId_Tier0" } else { '/' } 235 | Justification = 'Perform sensitive actions: https://learn.microsoft.com/en-us/graph/api/resources/users?view=graph-rest-1.0#who-can-perform-sensitive-actions' 236 | } 237 | 238 | # Change existing Tier 1 Cloud Admin Accounts 239 | Write-Verbose "[RequiredMicrosoftEntraDirectoryPermissionsValidation]: - Require directory role (Tier 1): User Administrator, Directory Scope: $(if ($AccountAdminUnitId_Tier1) { "/administrativeUnits/$AccountAdminUnitId_Tier1" } else { '/' })" 240 | @{ 241 | DisplayName = 'User Administrator' 242 | TemplateId = 'fe930be7-5e62-47db-91af-98c3a49a38b1' 243 | DirectoryScopeId = if ($AccountAdminUnitId_Tier1) { "/administrativeUnits/$AccountAdminUnitId_Tier1" } else { '/' } 244 | } 245 | @{ 246 | DisplayName = 'Privileged Authentication Administrator' 247 | TemplateId = '7be44c8a-adaf-4e2a-84d6-ab2649e08a13' 248 | DirectoryScopeId = if ($AccountRestrictedAdminUnitId_Tier1) { "/administrativeUnits/$AccountRestrictedAdminUnitId_Tier1" } else { '/' } 249 | Justification = 'Perform sensitive actions: https://learn.microsoft.com/en-us/graph/api/resources/users?view=graph-rest-1.0#who-can-perform-sensitive-actions' 250 | } 251 | 252 | # Change existing Tier 2 Cloud Admin Accounts 253 | Write-Verbose "[RequiredMicrosoftEntraDirectoryPermissionsValidation]: - Require directory role (Tier 2): User Administrator, Directory Scope: $(if ($AccountAdminUnitId_Tier2) { "/administrativeUnits/$AccountAdminUnitId_Tier2" } else { '/' })" 254 | @{ 255 | DisplayName = 'User Administrator' 256 | TemplateId = 'fe930be7-5e62-47db-91af-98c3a49a38b1' 257 | DirectoryScopeId = if ($AccountAdminUnitId_Tier2) { "/administrativeUnits/$AccountAdminUnitId_Tier2" } else { '/' } 258 | } 259 | @{ 260 | DisplayName = 'Privileged Authentication Administrator' 261 | TemplateId = '7be44c8a-adaf-4e2a-84d6-ab2649e08a13' 262 | DirectoryScopeId = if ($AccountRestrictedAdminUnitId_Tier2) { "/administrativeUnits/$AccountRestrictedAdminUnitId_Tier2" } else { '/' } 263 | Justification = 'Perform sensitive actions: https://learn.microsoft.com/en-us/graph/api/resources/users?view=graph-rest-1.0#who-can-perform-sensitive-actions' 264 | } 265 | ) 266 | #endregion --------------------------------------------------------------------- 267 | 268 | #region [COMMON] INITIALIZE SCRIPT VARIABLES ----------------------------------- 269 | $return = @{ 270 | Job = ./Common_0002__Get-AzAutomationJobInfo.ps1 271 | } 272 | #endregion --------------------------------------------------------------------- 273 | 274 | #region Perform lifecycle management to cloud admin accounts ------------------- 275 | ./CloudAdmin_0000__Common_0001__Get-CloudAdminAccountsByPrimaryAccount.ps1 -ReferralUserId $ReferralUserId -Tier $Tier -ExpandReferralUserId $true -ErrorAction Stop | & { 276 | process { 277 | if ($null -eq $_.securityTierLevel -or [string]::IsNullOrEmpty($_.securityTierLevel)) { 278 | Write-Warning "$($_.userPrincipalName) ($($_.Id)): - Account has no security tier level. Skipping." 279 | return 280 | } 281 | 282 | Write-Verbose "[SyncAdminAccountStatus]: - Processing account: $($_.userPrincipalName) ($($_.Id)) with security tier level $($_.securityTierLevel)" 283 | 284 | #region Delete account 285 | if ( 286 | $null -eq $_.referralUserAccount -or 287 | [string]::IsNullOrEmpty($_.referralUserAccount.id) 288 | ) { 289 | if ($null -ne $_.deletedDateTime) { 290 | Write-Warning "$($_.userPrincipalName) ($($_.Id)): - Account has no existing referral account. Waiting for Microsoft Entra 30 days retention period to expire." 291 | $_.accountLifecycle = @{ 292 | action = 'None' 293 | actionReason = 'MissingReferralAccount' 294 | status = 'WaitForRetentionPeriod' 295 | } 296 | } 297 | elseif ($LifecycleDelete[$_.securityTierLevel] -eq $true) { 298 | Write-Verbose "[SyncAdminAccountStatus]: - Account has no existing referral account. Attempting to soft-delete account." 299 | $params = @{ 300 | Method = 'DELETE' 301 | Uri = "/v1.0/users/$($_.Id)" 302 | ErrorAction = 'Stop' 303 | Verbose = $false 304 | Debug = $false 305 | } 306 | try { 307 | $null = ./Common_0000__Invoke-MgGraphRequest.ps1 $params 308 | } 309 | catch { 310 | [void] $returnError.Add(( ./Common_0000__Write-Error.ps1 @{ 311 | Message = "Failed to delete account $($_.userPrincipalName) ($($_.Id))." 312 | ErrorId = '500' 313 | Category = 'InvalidOperation' 314 | RecommendedAction = 'Check the account and its referral account.' 315 | CategoryActivity = 'Account Status Sync' 316 | CategoryReason = "Failed to delete account $($_.userPrincipalName) ($($_.Id))." 317 | })) 318 | return 319 | } 320 | 321 | Write-Warning "$($_.userPrincipalName) ($($_.Id)): - Soft-Deleted account due to missing referral account." 322 | $_.accountLifecycle = @{ 323 | action = 'SoftDelete' 324 | actionReason = 'MissingReferralAccount' 325 | status = 'SoftDeleted' 326 | } 327 | $_.deletedDateTime = [DateTime]::UtcNow 328 | } 329 | else { 330 | Write-Warning "$($_.userPrincipalName) ($($_.Id)): - Account has no existing referral account and should be deleted, but no automatic deletion is performed." 331 | $_.accountLifecycle = @{ 332 | action = 'DeferredSoftDelete' 333 | actionReason = 'MissingReferralAccount' 334 | status = 'ToBeSoftDeleted' 335 | } 336 | } 337 | } 338 | elseif ($_.referralUserAccount.deletedDateTime) { 339 | if ($null -ne $_.deletedDateTime) { 340 | Write-Debug "[SyncAdminAccountStatus]: - Account is already soft-deleted. Skipping." 341 | return 342 | } 343 | elseif ($LifecycleDelete[$_.securityTierLevel] -eq $true) { 344 | Write-Verbose "[SyncAdminAccountStatus]: - Account should be soft-deleted to match referral account status. Attempting to soft-delete account." 345 | try { 346 | $null = ./Common_0000__Invoke-MgGraphRequest.ps1 $params 347 | } 348 | catch { 349 | [void] $returnError.Add(( ./Common_0000__Write-Error.ps1 @{ 350 | Message = "Failed to delete account $($_.userPrincipalName) ($($_.Id))." 351 | ErrorId = '500' 352 | Category = 'InvalidOperation' 353 | RecommendedAction = 'Check the account and its referral account.' 354 | CategoryActivity = 'Account Status Sync' 355 | CategoryReason = "Failed to delete account $($_.userPrincipalName) ($($_.Id))." 356 | })) 357 | return 358 | } 359 | 360 | Write-Warning "$($_.userPrincipalName) ($($_.Id)): - Soft-Deleted account due to soft-deleted referral account." 361 | $_.accountLifecycle = @{ 362 | Action = 'SoftDelete' 363 | Status = 'SoftDeleted' 364 | Reason = 'SoftDeletedReferralAccount' 365 | } 366 | $_.deletedDateTime = [DateTime]::UtcNow 367 | } 368 | else { 369 | Write-Warning "$($_.userPrincipalName) ($($_.Id)): - Account has no existing referral account and should be soft-deleted, but no automatic soft-deletion is performed." 370 | $_.accountLifecycle = @{ 371 | action = 'DeferredSoftDelete' 372 | actionReason = 'SoftDeletedReferralAccount' 373 | status = 'ToBeSoftDeleted' 374 | } 375 | } 376 | } 377 | #endregion 378 | 379 | #region Restore account 380 | elseif ($null -eq $_.deletedDateTime) { 381 | Write-Debug "[SyncAdminAccountStatus]: - Account does not need to be restored. Skipping." 382 | } 383 | elseif ($LifecycleRestoreAfterDelete[$_.securityTierLevel] -eq $true) { 384 | Write-Verbose "[SyncAdminAccountStatus]: - Account should be restored to match referral account status. Attempting to restore account." 385 | $params = @{ 386 | Method = 'POST' 387 | Uri = "/v1.0/directory/deletedItems/microsoft.graph.user/$($_.Id)/restore" 388 | ErrorAction = 'Stop' 389 | Verbose = $false 390 | Debug = $false 391 | } 392 | try { 393 | $null = ./Common_0000__Invoke-MgGraphRequest.ps1 $params 394 | } 395 | catch { 396 | [void] $returnError.Add(( ./Common_0000__Write-Error.ps1 @{ 397 | Message = "Failed to restore account $($_.userPrincipalName) ($($_.Id))." 398 | ErrorId = '500' 399 | Category = 'InvalidOperation' 400 | RecommendedAction = 'Check the account and its referral account.' 401 | CategoryActivity = 'Account Status Sync' 402 | CategoryReason = "Failed to restore account $($_.userPrincipalName) ($($_.Id))." 403 | })) 404 | return 405 | } 406 | 407 | Write-Warning "$($_.userPrincipalName) ($($_.Id)): - Restored account due to existing referral account." 408 | $_.accountLifecycle = @{ 409 | action = 'Restore' 410 | actionReason = 'ExistingReferralAccount' 411 | status = 'Restored' 412 | } 413 | $_.deletedDateTime = [DateTime]::UtcNow 414 | } 415 | else { 416 | Write-Warning "$($_.userPrincipalName) ($($_.Id)): - Account has existing referral account and should be restored, but no automatic restore is performed." 417 | $_.accountLifecycle = @{ 418 | action = 'DeferredRestore' 419 | actionReason = 'ExistingReferralAccount' 420 | status = 'ToBeSoftRestored' 421 | } 422 | } 423 | #endregion 424 | 425 | #region Disable account 426 | if ($_.referralUserAccount.AccountEnabled -eq $false) { 427 | if ($_.AccountEnabled -eq $false) { 428 | Write-Debug "[SyncAdminAccountStatus]: - Account does not need to be disabled. Skipping." 429 | return 430 | } 431 | elseif ($LifecycleDisable[$_.securityTierLevel] -eq $true) { 432 | Write-Verbose "[SyncAdminAccountStatus]: - $($_.userPrincipalName) - Account is enabled and referral account is disabled. Attempting to disable account." 433 | 434 | $params = @{ 435 | Method = 'PATCH' 436 | Uri = "/v1.0/users/$($_.Id)" 437 | Body = @{ 438 | AccountEnabled = $false 439 | } 440 | ErrorAction = 'Stop' 441 | Verbose = $false 442 | Debug = $false 443 | } 444 | 445 | try { 446 | $null = ./Common_0000__Invoke-MgGraphRequest.ps1 $params 447 | } 448 | catch { 449 | [void] $returnError.Add(( ./Common_0000__Write-Error.ps1 @{ 450 | Message = "Failed to disable account $($_.userPrincipalName) ($($_.Id)) to match referral account." 451 | ErrorId = '500' 452 | Category = 'InvalidOperation' 453 | RecommendedAction = 'Check the account and its referral account.' 454 | CategoryActivity = 'Account Status Sync' 455 | CategoryReason = "Failed to disable account $($_.userPrincipalName) ($($_.Id)) to match referral account." 456 | })) 457 | return 458 | } 459 | 460 | Write-Warning "$($_.userPrincipalName) ($($_.Id)): - Disabled account to match referral account." 461 | $_.AccountEnabled = $false 462 | $_.accountLifecycle = @{ 463 | action = 'Disable' 464 | actionReason = 'DisabledReferralAccount' 465 | status = 'Disabled' 466 | } 467 | } 468 | else { 469 | Write-Warning "$($_.userPrincipalName) ($($_.Id)): - Account is enabled and referral account is disabled, but no automatic disabling is performed." 470 | $_.accountLifecycle = @{ 471 | action = 'DeferredDisable' 472 | actionReason = 'DisabledReferralAccount' 473 | status = 'ToBeDisabled' 474 | } 475 | } 476 | } 477 | #endregion 478 | 479 | #region Enable account 480 | if ($_.referralUserAccount.AccountEnabled -eq $true) { 481 | if ($_.AccountEnabled -eq $true) { 482 | Write-Debug "[SyncAdminAccountStatus]: - Account does not need to be enabled. Skipping." 483 | return 484 | } 485 | elseif ($LifecycleEnableAfterDisable[$_.securityTierLevel] -eq $true) { 486 | Write-Verbose "[SyncAdminAccountStatus]: - $($_.userPrincipalName) - Account is disabled and referral account is enabled. Attempting to enable account." 487 | 488 | $params = @{ 489 | Method = 'PATCH' 490 | Uri = "/v1.0/users/$($_.Id)" 491 | Body = @{ 492 | AccountEnabled = $true 493 | } 494 | ErrorAction = 'Stop' 495 | Verbose = $false 496 | Debug = $false 497 | } 498 | 499 | try { 500 | $null = ./Common_0000__Invoke-MgGraphRequest.ps1 $params 501 | } 502 | catch { 503 | [void] $returnError.Add(( ./Common_0000__Write-Error.ps1 @{ 504 | Message = "Failed to enable account $($_.userPrincipalName) ($($_.Id)) to match referral account." 505 | ErrorId = '500' 506 | Category = 'InvalidOperation' 507 | RecommendedAction = 'Check the account and its referral account.' 508 | CategoryActivity = 'Account Status Sync' 509 | CategoryReason = "Failed to enable account $($_.userPrincipalName) ($($_.Id)) to match referral account." 510 | })) 511 | return 512 | } 513 | 514 | Write-Warning "$($_.userPrincipalName) ($($_.Id)): - Enabled account to match referral account." 515 | $_.AccountEnabled = $true 516 | $_.accountLifecycle = @{ 517 | action = 'Enable' 518 | actionReason = 'EnabledReferralAccount' 519 | status = 'Enabled' 520 | } 521 | } 522 | else { 523 | Write-Warning "$($_.userPrincipalName) ($($_.Id)): - Account is disabled and referral account is enabled, but no automatic enabling is performed." 524 | $_.accountLifecycle = @{ 525 | action = 'DeferredEnable' 526 | actionReason = 'EnabledReferralAccount' 527 | status = 'ToBeEnabled' 528 | } 529 | } 530 | } 531 | #endregion 532 | 533 | if ($null -ne $_.accountLifecycle) { 534 | [void] $returnOutput.Add($_) 535 | } 536 | } 537 | } 538 | #endregion --------------------------------------------------------------------- 539 | 540 | #region Output Return Data ----------------------------------------------------- 541 | $return.Output = $returnOutput 542 | $return.Information = $returnInformation 543 | $return.Warning = $returnWarning 544 | $return.Error = $returnError 545 | if ($returnError.Count -eq 0) { $return.Success = $true } else { $return.Success = $false } 546 | $return.Job.EndTime = [DateTime]::UtcNow 547 | $return.Job.Runtime = $return.Job.EndTime - $return.Job.StartTime 548 | $return.Job.Waittime = $return.Job.StartTime - $return.Job.CreationTime 549 | 550 | Write-Verbose "Total Waittime: $([math]::Floor($return.Job.Waittime.TotalSeconds)) sec ($([math]::Round($return.Job.Waittime.TotalMinutes, 1)) min)" 551 | Write-Verbose "Total ConcurrentJobsTime: $([math]::Floor($return.Job.ConcurrentJobsTime.TotalSeconds)) sec ($([math]::Round($return.Job.ConcurrentJobsTime.TotalMinutes, 1)) min)" 552 | Write-Verbose "Total Runtime: $([math]::Floor($return.Job.Runtime.TotalSeconds)) sec ($([math]::Round($return.Job.Runtime.TotalMinutes, 1)) min)" 553 | 554 | if ( 555 | ($OutText -eq $true) -or 556 | (($PSBoundParameters.Keys -contains 'OutJson') -and ($OutJson -eq $false)) -or 557 | (($PSBoundParameters.Keys -contains 'OutCsv') -and ($OutCsv -eq $false)) 558 | ) { 559 | if ($concurrentJobsTimeoutError) { Throw 'Concurrent jobs timeout error detected. Please try again later.' } 560 | Write-Output $return.Output.userPrincipalName 561 | return 562 | } 563 | 564 | if ($OutJson) { ./Common_0000__Write-JsonOutput.ps1 $return; if ($concurrentJobsTimeoutError) { Throw 'Concurrent jobs timeout error detected. Please try again later.' }; return } 565 | 566 | if ($OutCsv) { 567 | if ($return.Output.Count -eq 0) { return } 568 | 569 | $properties = [ordered] @{ 570 | 'lastSuccessfulSignInDateTime' = 'signInActivity.lastSuccessfulSignInDateTime' 571 | 'lifecycleAction' = 'accountLifecycle.action' 572 | 'lifecycleActionReason' = 'accountLifecycle.actionReason' 573 | 'lifecycleStatus' = 'accountLifecycle.status' 574 | 575 | 'refDisplayName' = 'referralUserAccount.displayName' 576 | 'refUserPrincipalName' = 'referralUserAccount.userPrincipalName' 577 | 'refOnPremisesSamAccountName' = 'referralUserAccount.onPremisesSamAccountName' 578 | 'refId' = 'referralUserAccount.id' 579 | 'refAccountEnabled' = 'referralUserAccount.accountEnabled' 580 | 'refCreatedDateTime' = 'referralUserAccount.createdDateTime' 581 | 'refDeletedDateTime' = 'referralUserAccount.deletedDateTime' 582 | 'refMail' = 'referralUserAccount.mail' 583 | 'refLastSuccessfulSignInDateTime' = 'referralUserAccount.signInActivity.lastSuccessfulSignInDateTime' 584 | 585 | 'managerDisplayName' = 'referralUserAccount.manager.displayName' 586 | 'managerUserPrincipalName' = 'referralUserAccount.manager.userPrincipalName' 587 | 'managerOnPremisesSamAccountName' = 'referralUserAccount.manager.onPremisesSamAccountName' 588 | 'managerId' = 'referralUserAccount.manager.id' 589 | 'managerAccountEnabled' = 'referralUserAccount.manager.accountEnabled' 590 | 'managerMail' = 'referralUserAccount.manager.mail' 591 | } 592 | 593 | ./Common_0000__Write-CsvOutput.ps1 -InputObject ( 594 | $return.Output | & { 595 | process { 596 | foreach ($property in $properties.GetEnumerator()) { 597 | $nestedPropertyPath = $property.Value -split '\.' 598 | if ($nestedPropertyPath.count -eq 3) { 599 | $_.$($property.Key) = $_.$($nestedPropertyPath[0]).$($nestedPropertyPath[1]).$($nestedPropertyPath[2]) 600 | } 601 | elseif ($nestedPropertyPath.count -eq 2) { 602 | $_.$($property.Key) = $_.$($nestedPropertyPath[0]).$($nestedPropertyPath[1]) 603 | } 604 | else { 605 | Throw "Invalid nested property path: $($property.Value)" 606 | } 607 | } 608 | 609 | $_.Remove('signInActivity') 610 | $_.Remove('onPremisesExtensionAttributes') 611 | $_.Remove('referralUserAccount') 612 | $_.Remove('accountLifecycle') 613 | 614 | # Return the hashtable to the pipeline 615 | $_ 616 | } 617 | } 618 | ) -StorageUri $( 619 | if (-not [string]::IsNullOrEmpty($StorageUri)) { 620 | $baseUri = ($uri = [System.Uri]$StorageUri).GetLeftPart([System.UriPartial]::Path) 621 | $baseUri + '/' + [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssfffZ') + '_Invoke-Scheduled-CloudAdministrator-AccountLifecycleManagement.csv' + $uri.Query 622 | } 623 | ) -Metadata $( 624 | $JobInfo = ./Common_0002__Get-AzAutomationJobInfo.ps1 625 | $Metadata = [ordered] @{ 626 | RunbookName = $JobInfo.Runbook.Name 627 | RunbookScriptVersion = $JobInfo.Runbook.ScriptVersion 628 | RunbookScriptGuid = $JobInfo.Runbook.ScriptGuid 629 | CreatedAt = $JobInfo.StartTime 630 | } 631 | $commonParameters = 'OutCsv', 'Verbose', 'Debug', 'ErrorAction', 'WarningAction', 'InformationAction', 'ErrorVariable', 'WarningVariable', 'InformationVariable', 'OutVariable', 'OutBuffer', 'PipelineVariable' 632 | $PSBoundParameters.Keys | Sort-Object | ForEach-Object { 633 | if ($_ -in $commonParameters) { return } 634 | $Metadata["ExportParameter_$_"] = $PSBoundParameters[$_] 635 | } 636 | if (-not ($Metadata.Keys -like 'ExportParameter_*')) { 637 | $Metadata['ExportParameters'] = 'None' 638 | } 639 | [pscustomobject] $Metadata 640 | ) 641 | return 642 | } 643 | 644 | if ($VerbosePreference -ne 'Continue') { Write-Output "Success = $($return.Success)" } 645 | if ($concurrentJobsTimeoutError) { Throw 'Concurrent jobs timeout error detected. Please try again later.' } 646 | #endregion --------------------------------------------------------------------- 647 | -------------------------------------------------------------------------------- /Runbooks/CloudAdmin_0000__Common_0000__Get-ConfigurationConstants.ps1: -------------------------------------------------------------------------------- 1 | <#PSScriptInfo 2 | .VERSION 1.2.0 3 | .GUID 42b14e9d-de1d-4a82-ae38-9d8c33dd56fe 4 | .AUTHOR Julian Pawlowski 5 | .COMPANYNAME Workoho GmbH 6 | .COPYRIGHT © 2024 Workoho GmbH 7 | .TAGS 8 | .LICENSEURI https://github.com/workoho/Entra-Tiering-Security-Model/blob/main/LICENSE.txt 9 | .PROJECTURI https://github.com/workoho/Entra-Tiering-Security-Model 10 | .ICONURI 11 | .EXTERNALMODULEDEPENDENCIES 12 | .REQUIREDSCRIPTS 13 | .EXTERNALSCRIPTDEPENDENCIES https://github.com/workoho/AzAuto-Common-Runbook-FW 14 | .RELEASENOTES 15 | Version 1.2.0 (2024-05-18) 16 | - Removed AccountTypeExtensionAttributeSuffix settings because it cannot be used properly as Microsoft Graph filter. 17 | #> 18 | 19 | <# 20 | .SYNOPSIS 21 | Returns an array of Constants that are shared between all Cloud Administrator scripts. 22 | 23 | .DESCRIPTION 24 | These constants are transformed using Common_0000__Convert-PSEnvToPSScriptVariable.ps1. 25 | Values come from local environment variables and are validated against the Regex or Type property, 26 | otherwise a default value is used. 27 | Script variables that are already set (e.g. via script parameters) may take higher priority using 28 | the respectScriptParameter property. 29 | 30 | In Azure Automation sandbox, environment variables are synchronzed with Azure Automation Variables 31 | before. See Common_0002__Import-AzAutomationVariableToPSEnv.ps1 for more details. 32 | 33 | That way, flexible configuration can be provided with easy control as part of the Azure Automation Account. 34 | Also, script runtime parameters can be reduced to the absolute minimum to improve security. 35 | 36 | .NOTES 37 | CUSTOM CONFIGURATION SETTINGS 38 | ============================= 39 | 40 | Variables for custom configuration settings, either from $env:, 41 | or Azure Automation Account Variables, whose will automatically be published in $env when running in Azure Automation. 42 | 43 | ******************************************************************************************************** 44 | * Please note that in the variable name must be replaced by the intended Tier level 0, 1, or 2. * 45 | * For example: AV_CloudAdminTier0_GroupId, AV_CloudAdminTier1_GroupId, AV_CloudAdminTier2_GroupId * 46 | ******************************************************************************************************** 47 | 48 | AV_CloudAdmin_RestrictedAdminUnitId - [String] - Default Value: $null 49 | ... 50 | 51 | AV_CloudAdmin_AccountTypeExtensionAttribute - [Integer] - Default Value: 15 52 | Save user account type information in this extension attribute. Content from the referral user will be copied and the Cloud Administrator 53 | information is added either as prefix or suffix (see AV_CloudAdminTier_ExtensionAttribute* settings below). 54 | 55 | AV_CloudAdmin_AccountTypeEmployeeType - [Boolean] - Default Value: $true 56 | ... 57 | 58 | AV_CloudAdmin_ReferenceExtensionAttribute - [Integer] - Default Value: 14 59 | ... 60 | 61 | AV_CloudAdmin_ReferenceManager - [Boolean] - Default Value: $false 62 | ... 63 | 64 | AV_CloudAdmin_Webhook - [String] - Default Value: $null 65 | Send return data in JSON format as POST to this webhook URL. 66 | 67 | AV_CloudAdminTier0_AccountRestrictedAdminUnitId 68 | ... 69 | 70 | AV_CloudAdminTier_AccountAdminUnitId 71 | Tier 1 and 2 only, see AV_CloudAdminTier0_AccountRestrictedAdminUnitId for Tier 0. 72 | 73 | AV_CloudAdminTier_UserPhotoUrl - [String] - Default Value: 74 | Default value for script parameter UserPhotoUrl. If no parameter was provided, this value will be used instead. 75 | If no value was provided at all, the tenant's square logo will be used. 76 | 77 | AV_CloudAdminTier_LicenseSkuPartNumber - [String] - Default Value: EXCHANGEDESKLESS 78 | License assigned to the dedicated admin user account. The license SKU part number must contain an Exchange Online service plan to generate a mailbox 79 | for the user (see https://learn.microsoft.com/en-us/entra/identity/users/licensing-service-plan-reference). 80 | Multiple licenses may be assigned using a whitespace delimiter. 81 | For the license containing the Exchange Online service plan, only that service plan is enabled for the user, any other service plan within that license will be disabled. 82 | If GroupId is also provided, group-based licensing is implied and Exchange Online service plan activation will only be monitored before continuing. 83 | 84 | AV_CloudAdminTier_GroupId - [String] - Default Value: 85 | Entra Group Object ID where the user shall be added. If the group is dynamic, group membership update will only be monitored before continuing. 86 | 87 | AV_CloudAdminTier_DedicatedAccount - [String] - Default Value: 'Require' for Tier 0, 'Optional' for Tier 1, 'None' for Tier 2 88 | ... 89 | 90 | AV_CloudAdminTier_AccountDomain - [String] - Default Value: onmicrosoft.com 91 | ... 92 | 93 | AV_CloudAdminTier_AccountTypeEmployeeTypePrefix - [String] - Default Value: 94 | ... 95 | 96 | AV_CloudAdminTier_AccountTypeEmployeeTypePrefixSeparator - [String] - Default Value: 97 | ... 98 | 99 | AV_CloudAdminTier_AccountTypeEmployeeTypeSuffix - [String] - Default Value: 100 | ... 101 | 102 | AV_CloudAdminTier_AccountTypeEmployeeTypeSuffixSeparator - [String] - Default Value: 103 | ... 104 | 105 | AV_CloudAdminTier_AccountTypeExtensionAttributePrefix - [String] - Default Value: 106 | ... 107 | 108 | AV_CloudAdminTier_AccountTypeExtensionAttributePrefixSeparator - [String] - Default Value: 109 | ... 110 | 111 | AV_CloudAdminTier_UserDisplayNamePrefix - [String] - Default Value: 112 | ... 113 | 114 | AV_CloudAdminTier_UserDisplayNamePrefixSeparator - [String] - Default Value: 115 | ... 116 | 117 | AV_CloudAdminTier_UserDisplayNamePrefixInsertPoint - [String] - Default Value: 118 | ... 119 | 120 | AV_CloudAdminTier_UserDisplayNameSuffix - [String] - Default Value: 121 | ... 122 | 123 | AV_CloudAdminTier_UserDisplayNameSuffixSeparator - [String] - Default Value: 124 | ... 125 | 126 | AV_CloudAdminTier_UserDisplayNameSuffixInsertPoint - [String] - Default Value: 127 | ... 128 | 129 | AV_CloudAdminTier_GivenNamePrefix - [String] - Default Value: 130 | ... 131 | 132 | AV_CloudAdminTier_GivenNamePrefixSeparator - [String] - Default Value: 133 | ... 134 | 135 | AV_CloudAdminTier_GivenNameSuffix - [String] - Default Value: 136 | ... 137 | 138 | AV_CloudAdminTier_GivenNameSuffixSeparator - [String] - Default Value: 139 | ... 140 | 141 | AV_CloudAdminTier_UserPrincipalNameUsesSamAccountName - [Boolean] - Default Value: $false 142 | ... 143 | 144 | AV_CloudAdminTier_UserPrincipalNamePrefix - [String] - Default Value: 145 | ... 146 | 147 | AV_CloudAdminTier_UserPrincipalNamePrefixSeparator - [String] - Default Value: 148 | ... 149 | 150 | AV_CloudAdminTier_UserPrincipalNameSuffix - [String] - Default Value: 151 | ... 152 | 153 | AV_CloudAdminTier_UserPrincipalNameSuffixSeparator - [String] - Default Value: 154 | ... 155 | 156 | #> 157 | 158 | [OutputType([array])] 159 | param() 160 | 161 | if (-Not $PSCommandPath) { Write-Error 'This runbook is used by other runbooks and must not be run directly.' -ErrorAction Stop; exit } 162 | Write-Verbose "---START of $((Get-Item $PSCommandPath).Name), $((Test-ScriptFileInfo $PSCommandPath | Select-Object -Property Version, Guid | & { process{$_.PSObject.Properties | & { process{$_.Name + ': ' + $_.Value} }} }) -join ', ') ---" 163 | 164 | $Constants = [array] @( 165 | #region General Constants 166 | @{ 167 | sourceName = "AV_CloudAdmin_RestrictedAdminUnitId" 168 | mapToVariable = 'CloudAdminRestrictedAdminUnitId' 169 | defaultValue = $null 170 | Regex = '^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$' 171 | } 172 | @{ 173 | sourceName = "AV_CloudAdmin_AccountTypeExtensionAttribute" 174 | mapToVariable = 'AccountTypeExtensionAttribute' 175 | defaultValue = 15 176 | Regex = '^([1-9]|1[012345])$' 177 | } 178 | @{ 179 | sourceName = "AV_CloudAdmin_AccountTypeExtensionAttributeOverwrite" 180 | mapToVariable = 'AccountTypeExtensionAttributeOverwrite' 181 | defaultValue = $false 182 | } 183 | @{ 184 | sourceName = "AV_CloudAdmin_AccountTypeEmployeeType" 185 | mapToVariable = 'AccountTypeEmployeeType' 186 | defaultValue = $true 187 | } 188 | @{ 189 | sourceName = "AV_CloudAdmin_ReferenceExtensionAttribute" 190 | mapToVariable = 'ReferenceExtensionAttribute' 191 | defaultValue = 14 192 | Regex = '^([1-9]|1[012345])$' 193 | } 194 | @{ 195 | sourceName = "AV_CloudAdmin_ReferenceExtensionAttributeOverwrite" 196 | mapToVariable = 'ReferenceExtensionAttributeOverwrite' 197 | defaultValue = $false 198 | } 199 | @{ 200 | sourceName = "AV_CloudAdmin_ReferenceManager" 201 | mapToVariable = 'ReferenceManager' 202 | defaultValue = $false 203 | } 204 | @{ 205 | sourceName = "AV_CloudAdmin_InternalReferenceAccountLastSignInMinDaysBefore" 206 | mapToVariable = 'InternalReferenceAccountLastSignInMinDaysBefore' 207 | defaultValue = '14' 208 | Regex = '^-?\d+$' 209 | } 210 | @{ 211 | sourceName = "AV_CloudAdmin_ExternalReferenceAccountLastSignInMinDaysBefore" 212 | mapToVariable = 'ExternalReferenceAccountLastSignInMinDaysBefore' 213 | defaultValue = '30' 214 | Regex = '^-?\d+$' 215 | } 216 | @{ 217 | sourceName = "AV_CloudAdmin_EmployeeLeaveDateTimeMinDaysBefore" 218 | mapToVariable = 'EmployeeLeaveDateTimeMinDaysBefore' 219 | defaultValue = '45' 220 | Regex = '^-?\d+$' 221 | } 222 | @{ 223 | sourceName = "AV_CloudAdmin_StorageUri" 224 | mapToVariable = 'StorageUri' 225 | defaultValue = $null 226 | Regex = '^https:\/\/[a-z0-9]+\.(blob|file)\.core\.windows\.net\/[a-z0-9-]+(\/[a-z0-9-_.\s]+)*(\?.*)?$' 227 | } 228 | @{ 229 | sourceName = "AV_CloudAdmin_Webhook" 230 | mapToVariable = 'Webhook' 231 | defaultValue = $null 232 | Regex = '^https:\/\/.+$' 233 | } 234 | @{ 235 | sourceName = "AV_CloudAdmin_WelcomeMailLogo" 236 | mapToVariable = 'WelcomeMailLogo' 237 | defaultValue = $null 238 | Regex = '^((?:data|https?):.+)$' 239 | } 240 | @{ 241 | sourceName = "AV_CloudAdmin_WelcomeMailSender" 242 | mapToVariable = 'WelcomeMailSender' 243 | defaultValue = $null 244 | Regex = '^[^\s]+@[^\s]+\.[^\s]+$|^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$' 245 | } 246 | #endregion 247 | 248 | #region Tier 0 Constants 249 | @{ 250 | sourceName = "AV_CloudAdminTier0_AccountRestrictedAdminUnitId" 251 | mapToVariable = 'AccountRestrictedAdminUnitId_Tier0' 252 | defaultValue = $null 253 | Regex = '^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$' 254 | } 255 | @{ 256 | sourceName = "AV_CloudAdminTier0_LicenseGroupId" 257 | mapToVariable = 'LicenseGroupId_Tier0' 258 | defaultValue = $null 259 | Regex = '^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$' 260 | } 261 | @{ 262 | sourceName = "AV_CloudAdminTier0_LicenseSkuPartNumber" 263 | mapToVariable = 'LicenseSkuPartNumber_Tier0' 264 | defaultValue = 'EXCHANGEDESKLESS' 265 | Regex = '^[A-Z0-9][A-Z0-9_ ]+[A-Z0-9]$' 266 | } 267 | @{ 268 | sourceName = "AV_CloudAdminTier0_GroupId" 269 | mapToVariable = 'GroupId_Tier0' 270 | defaultValue = $null 271 | Regex = '^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$' 272 | } 273 | @{ 274 | sourceName = "AV_CloudAdminTier0_UserPhotoUrl" 275 | respectScriptParameter = 'UserPhotoUrl' 276 | mapToVariable = 'PhotoUrl_Tier0' 277 | defaultValue = $null 278 | Regex = '^https:\/\/.+(?:\.png|\.jpg|\.jpeg|\?.+)$' 279 | } 280 | @{ 281 | sourceName = "AV_CloudAdminTier0_DedicatedAccount" 282 | mapToVariable = 'DedicatedAccount_Tier0' 283 | defaultValue = 'Require' 284 | Regex = '^(?:Require|Optional|None)$' 285 | } 286 | @{ 287 | sourceName = "AV_CloudAdminTier0_AllowedGuestOrExternalForDedicatedAccount" 288 | mapToVariable = 'AllowedGuestOrExternalForDedicatedAccount_Tier0' 289 | defaultValue = $false 290 | } 291 | @{ 292 | sourceName = "AV_CloudAdminTier0_AllowedGuestOrExternalUserTypes" 293 | mapToVariable = 'AllowedGuestOrExternalUserTypes_Tier0' 294 | defaultValue = $null 295 | Regex = '(?:internalGuest|b2bCollaborationGuest|b2bCollaborationMember|otherExternalUser)(\s|$)+' 296 | } 297 | @{ 298 | sourceName = "AV_CloudAdminTier0_AllowFacebookAccount" 299 | mapToVariable = 'AllowFacebookAccount_Tier0' 300 | defaultValue = $false 301 | } 302 | @{ 303 | sourceName = "AV_CloudAdminTier0_AllowGoogleAccount" 304 | mapToVariable = 'AllowGoogleAccount_Tier0' 305 | defaultValue = $false 306 | } 307 | @{ 308 | sourceName = "AV_CloudAdminTier0_AllowMicrosoftAccount" 309 | mapToVariable = 'AllowMicrosoftAccount_Tier0' 310 | defaultValue = $false 311 | } 312 | @{ 313 | sourceName = "AV_CloudAdminTier0_AllowExternalEntraAccount" 314 | mapToVariable = 'AllowExternalEntraAccount_Tier0' 315 | defaultValue = $false 316 | } 317 | @{ 318 | sourceName = "AV_CloudAdminTier0_AllowFederatedAccount" 319 | mapToVariable = 'AllowFederatedAccount_Tier0' 320 | defaultValue = $false 321 | } 322 | @{ 323 | sourceName = "AV_CloudAdminTier0_AccountDomain" 324 | mapToVariable = 'AccountDomain_Tier0' 325 | defaultValue = 'onmicrosoft.com' 326 | Regex = '^(?=^.{1,253}$)(([a-z\d]([a-z\d-]{0,62}[a-z\d])*[\.]){1,3}[a-z]{1,61})$' 327 | } 328 | @{ 329 | sourceName = "AV_CloudAdminTier0_AllowSameDomainForReferralUser" 330 | mapToVariable = 'AllowSameDomainForReferralUser_Tier0' 331 | defaultValue = $false 332 | } 333 | @{ 334 | sourceName = "AV_CloudAdminTier0_AccountTypeEmployeeTypePrefix" 335 | mapToVariable = 'AccountTypeEmployeeTypePrefix_Tier0' 336 | defaultValue = 'Tier 0 Cloud Administrator' 337 | Regex = '^[^\s].*[^\s]$|^.$' 338 | } 339 | @{ 340 | sourceName = "AV_CloudAdminTier0_AccountTypeEmployeeTypePrefixSeparator" 341 | mapToVariable = 'AccountTypeEmployeeTypePrefixSeparator_Tier0' 342 | defaultValue = ' (' 343 | Regex = '^.{1,2}$' 344 | } 345 | @{ 346 | sourceName = "AV_CloudAdminTier0_AccountTypeEmployeeTypeSuffix" 347 | mapToVariable = 'AccountTypeEmployeeTypeSuffix_Tier0' 348 | defaultValue = ')' 349 | Regex = '^[^\s].*[^\s]$|^.$' 350 | } 351 | @{ 352 | sourceName = "AV_CloudAdminTier0_AccountTypeEmployeeTypeSuffixSeparator" 353 | mapToVariable = 'AccountTypeEmployeeTypeSuffixSeparator_Tier0' 354 | defaultValue = '' 355 | Regex = '^.{1,2}$' 356 | } 357 | @{ 358 | sourceName = "AV_CloudAdminTier0_AccountTypeExtensionAttributePrefix" 359 | mapToVariable = 'AccountTypeExtensionAttributePrefix_Tier0' 360 | defaultValue = 'A0C' 361 | Regex = '^[^\s].*[^\s]$|^.$' 362 | } 363 | @{ 364 | sourceName = "AV_CloudAdminTier0_AccountTypeExtensionAttributePrefixSeparator" 365 | mapToVariable = 'AccountTypeExtensionAttributePrefixSeparator_Tier0' 366 | defaultValue = '__' 367 | Regex = '^.{1,2}$' 368 | } 369 | @{ 370 | sourceName = "AV_CloudAdminTier0_UserDisplayNamePrefix" 371 | mapToVariable = 'UserDisplayNamePrefix_Tier0' 372 | defaultValue = $null 373 | Regex = '^[^\s].*[^\s]$|^.$' 374 | } 375 | @{ 376 | sourceName = "AV_CloudAdminTier0_UserDisplayNamePrefixSeparator" 377 | mapToVariable = 'UserDisplayNamePrefixSeparator_Tier0' 378 | defaultValue = '-' 379 | Regex = '^.{1,2}$' 380 | } 381 | @{ 382 | sourceName = "AV_CloudAdminTier0_UserDisplayNamePrefixInsertPoint" 383 | mapToVariable = 'UserDisplayNamePrefixInsertPoint_Tier0' 384 | defaultValue = $null 385 | Regex = '.+' 386 | } 387 | @{ 388 | sourceName = "AV_CloudAdminTier0_UserDisplayNameSuffix" 389 | mapToVariable = 'UserDisplayNameSuffix_Tier0' 390 | defaultValue = '(A0C)' 391 | Regex = '^[^\s].*[^\s]$|^.$' 392 | } 393 | @{ 394 | sourceName = "AV_CloudAdminTier0_UserDisplayNameSuffixSeparator" 395 | mapToVariable = 'UserDisplayNameSuffixSeparator_Tier0' 396 | defaultValue = ' ' 397 | Regex = '^.{1,2}$' 398 | } 399 | @{ 400 | sourceName = "AV_CloudAdminTier0_UserDisplayNameSuffixInsertPoint" 401 | mapToVariable = 'UserDisplayNameSuffixInsertPoint_Tier0' 402 | defaultValue = '( \([^\(\)]+\)\s*$| \[[^\[\]]+\]\s*$)' 403 | Regex = '.+' 404 | } 405 | @{ 406 | sourceName = "AV_CloudAdminTier0_GivenNamePrefix" 407 | mapToVariable = 'GivenNamePrefix_Tier0' 408 | defaultValue = 'A0C' 409 | Regex = '^[^\s].*[^\s]$|^.$' 410 | } 411 | @{ 412 | sourceName = "AV_CloudAdminTier0_GivenNamePrefixSeparator" 413 | mapToVariable = 'GivenNamePrefixSeparator_Tier0' 414 | defaultValue = '-' 415 | Regex = '^.{1,2}$' 416 | } 417 | @{ 418 | sourceName = "AV_CloudAdminTier0_GivenNameSuffix" 419 | mapToVariable = 'GivenNameSuffix_Tier0' 420 | defaultValue = $null 421 | Regex = '^[^\s].*[^\s]$|^.$' 422 | } 423 | @{ 424 | sourceName = "AV_CloudAdminTier0_GivenNameSuffixSeparator" 425 | mapToVariable = 'GivenNameSuffixSeparator_Tier0' 426 | defaultValue = '-' 427 | Regex = '^.{1,2}$' 428 | } 429 | @{ 430 | sourceName = "AV_CloudAdminTier0_UserPrincipalNameUsesSamAccountName" 431 | mapToVariable = 'UserPrincipalNameUsesSamAccountName_Tier0' 432 | defaultValue = $false 433 | } 434 | @{ 435 | sourceName = "AV_CloudAdminTier0_UserPrincipalNamePrefix" 436 | mapToVariable = 'UserPrincipalNamePrefix_Tier0' 437 | defaultValue = 'A0C' 438 | Regex = '^[^\s].*[^\s]$|^.$' 439 | } 440 | @{ 441 | sourceName = "AV_CloudAdminTier0_UserPrincipalNamePrefixSeparator" 442 | mapToVariable = 'UserPrincipalNamePrefixSeparator_Tier0' 443 | defaultValue = '-' 444 | Regex = '^.{1,2}$' 445 | } 446 | @{ 447 | sourceName = "AV_CloudAdminTier0_UserPrincipalNameSuffix" 448 | mapToVariable = 'UserPrincipalNameSuffix_Tier0' 449 | defaultValue = $null 450 | Regex = '^[^\s].*[^\s]$|^.$' 451 | } 452 | @{ 453 | sourceName = "AV_CloudAdminTier0_UserPrincipalNameSuffixSeparator" 454 | mapToVariable = 'UserPrincipalNameSuffixSeparator_Tier0' 455 | defaultValue = '-' 456 | Regex = '^.{1,2}$' 457 | } 458 | @{ 459 | sourceName = "AV_CloudAdminTier0_LifecycleDelete" 460 | mapToVariable = 'LifecycleDelete_Tier0' 461 | defaultValue = $false 462 | } 463 | @{ 464 | sourceName = "AV_CloudAdminTier0_LifecycleRestoreAfterDelete" 465 | mapToVariable = 'LifecycleRestoreAfterDelete_Tier0' 466 | defaultValue = $false 467 | } 468 | @{ 469 | sourceName = "AV_CloudAdminTier0_LifecycleDisable" 470 | mapToVariable = 'LifecycleDisable_Tier0' 471 | defaultValue = $false 472 | } 473 | @{ 474 | sourceName = "AV_CloudAdminTier0_LifecycleEnableAfterDisable" 475 | mapToVariable = 'LifecycleEnableAfterDisable_Tier0' 476 | defaultValue = $false 477 | } 478 | #endregion 479 | 480 | #region Tier 1 Constants 481 | @{ 482 | sourceName = "AV_CloudAdminTier1_AccountAdminUnitId" 483 | mapToVariable = 'AccountAdminUnitId_Tier1' 484 | defaultValue = $null 485 | Regex = '^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$' 486 | } 487 | @{ 488 | sourceName = "AV_CloudAdminTier1_LicenseGroupId" 489 | mapToVariable = 'LicenseGroupId_Tier1' 490 | defaultValue = $null 491 | Regex = '^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$' 492 | } 493 | @{ 494 | sourceName = "AV_CloudAdminTier1_LicenseSkuPartNumber" 495 | mapToVariable = 'LicenseSkuPartNumber_Tier1' 496 | defaultValue = 'EXCHANGEDESKLESS' 497 | Regex = '^[A-Z0-9][A-Z0-9_ ]+[A-Z0-9]$' 498 | } 499 | @{ 500 | sourceName = "AV_CloudAdminTier1_GroupId" 501 | mapToVariable = 'GroupId_Tier1' 502 | defaultValue = $null 503 | Regex = '^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$' 504 | } 505 | @{ 506 | sourceName = "AV_CloudAdminTier1_UserPhotoUrl" 507 | respectScriptParameter = 'UserPhotoUrl' 508 | mapToVariable = 'PhotoUrl_Tier1' 509 | defaultValue = $null 510 | Regex = '^https:\/\/.+(?:\.png|\.jpg|\.jpeg|\?.+)$' 511 | } 512 | @{ 513 | sourceName = "AV_CloudAdminTier1_DedicatedAccount" 514 | mapToVariable = 'DedicatedAccount_Tier1' 515 | defaultValue = 'Optional' 516 | Regex = '^(?:Require|Optional)$' 517 | } 518 | @{ 519 | sourceName = "AV_CloudAdminTier1_AllowedGuestOrExternalForDedicatedAccount" 520 | mapToVariable = 'AllowedGuestOrExternalForDedicatedAccount_Tier1' 521 | defaultValue = $false 522 | } 523 | @{ 524 | sourceName = "AV_CloudAdminTier1_AllowedGuestOrExternalUserTypes" 525 | mapToVariable = 'AllowedGuestOrExternalUserTypes_Tier1' 526 | defaultValue = $null 527 | Regex = '(?:internalGuest|b2bCollaborationGuest|b2bCollaborationMember|otherExternalUser)(\s|$)+' 528 | } 529 | @{ 530 | sourceName = "AV_CloudAdminTier1_AllowFacebookAccount" 531 | mapToVariable = 'AllowFacebookAccount_Tier1' 532 | defaultValue = $false 533 | } 534 | @{ 535 | sourceName = "AV_CloudAdminTier1_AllowGoogleAccount" 536 | mapToVariable = 'AllowGoogleAccount_Tier1' 537 | defaultValue = $false 538 | } 539 | @{ 540 | sourceName = "AV_CloudAdminTier1_AllowMicrosoftAccount" 541 | mapToVariable = 'AllowMicrosoftAccount_Tier1' 542 | defaultValue = $false 543 | } 544 | @{ 545 | sourceName = "AV_CloudAdminTier1_AllowExternalEntraAccount" 546 | mapToVariable = 'AllowExternalEntraAccount_Tier1' 547 | defaultValue = $false 548 | } 549 | @{ 550 | sourceName = "AV_CloudAdminTier1_AllowFederatedAccount" 551 | mapToVariable = 'AllowFederatedAccount_Tier1' 552 | defaultValue = $false 553 | } 554 | @{ 555 | sourceName = "AV_CloudAdminTier1_AccountDomain" 556 | mapToVariable = 'AccountDomain_Tier1' 557 | defaultValue = 'onmicrosoft.com' 558 | Regex = '^(?=^.{1,253}$)(([a-z\d]([a-z\d-]{0,62}[a-z\d])*[\.]){1,3}[a-z]{1,61})$' 559 | } 560 | @{ 561 | sourceName = "AV_CloudAdminTier1_AllowSameDomainForReferralUser" 562 | mapToVariable = 'AllowSameDomainForReferralUser_Tier1' 563 | defaultValue = $false 564 | } 565 | @{ 566 | sourceName = "AV_CloudAdminTier1_AccountTypeEmployeeTypePrefix" 567 | mapToVariable = 'AccountTypeEmployeeTypePrefix_Tier1' 568 | defaultValue = 'Tier 1 Cloud Administrator' 569 | Regex = '^[^\s].*[^\s]$|^.$' 570 | } 571 | @{ 572 | sourceName = "AV_CloudAdminTier1_AccountTypeEmployeeTypePrefixSeparator" 573 | mapToVariable = 'AccountTypeEmployeeTypePrefixSeparator_Tier1' 574 | defaultValue = ' (' 575 | Regex = '^.{1,2}$' 576 | } 577 | @{ 578 | sourceName = "AV_CloudAdminTier1_AccountTypeEmployeeTypeSuffix" 579 | mapToVariable = 'AccountTypeEmployeeTypeSuffix_Tier1' 580 | defaultValue = ')' 581 | Regex = '^[^\s].*[^\s]$|^.$' 582 | } 583 | @{ 584 | sourceName = "AV_CloudAdminTier1_AccountTypeEmployeeTypeSuffixSeparator" 585 | mapToVariable = 'AccountTypeEmployeeTypeSuffixSeparator_Tier1' 586 | defaultValue = '' 587 | Regex = '^.{1,2}$' 588 | } 589 | @{ 590 | sourceName = "AV_CloudAdminTier1_AccountTypeExtensionAttributePrefix" 591 | mapToVariable = 'AccountTypeExtensionAttributePrefix_Tier1' 592 | defaultValue = 'A1C' 593 | Regex = '^[^\s].*[^\s]$|^.$' 594 | } 595 | @{ 596 | sourceName = "AV_CloudAdminTier1_AccountTypeExtensionAttributePrefixSeparator" 597 | mapToVariable = 'AccountTypeExtensionAttributePrefixSeparator_Tier1' 598 | defaultValue = '__' 599 | Regex = '^.{1,2}$' 600 | } 601 | @{ 602 | sourceName = "AV_CloudAdminTier1_UserDisplayNamePrefix" 603 | mapToVariable = 'UserDisplayNamePrefix_Tier1' 604 | defaultValue = $null 605 | Regex = '^[^\s].*[^\s]$|^.$' 606 | } 607 | @{ 608 | sourceName = "AV_CloudAdminTier1_UserDisplayNamePrefixSeparator" 609 | mapToVariable = 'UserDisplayNamePrefixSeparator_Tier1' 610 | defaultValue = '-' 611 | Regex = '^.{1,2}$' 612 | } 613 | @{ 614 | sourceName = "AV_CloudAdminTier1_UserDisplayNamePrefixInsertPoint" 615 | mapToVariable = 'UserDisplayNamePrefixInsertPoint_Tier1' 616 | defaultValue = $null 617 | Regex = '.+' 618 | } 619 | @{ 620 | sourceName = "AV_CloudAdminTier1_UserDisplayNameSuffix" 621 | mapToVariable = 'UserDisplayNameSuffix_Tier1' 622 | defaultValue = '(A1C)' 623 | Regex = '^[^\s].*[^\s]$|^.$' 624 | } 625 | @{ 626 | sourceName = "AV_CloudAdminTier1_UserDisplayNameSuffixSeparator" 627 | mapToVariable = 'UserDisplayNameSuffixSeparator_Tier1' 628 | defaultValue = ' ' 629 | Regex = '^.{1,2}$' 630 | } 631 | @{ 632 | sourceName = "AV_CloudAdminTier1_UserDisplayNameSuffixInsertPoint" 633 | mapToVariable = 'UserDisplayNameSuffixInsertPoint_Tier1' 634 | defaultValue = '( \([^\(\)]+\)\s*$| \[[^\[\]]+\]\s*$)' 635 | Regex = '.+' 636 | } 637 | @{ 638 | sourceName = "AV_CloudAdminTier1_GivenNamePrefix" 639 | mapToVariable = 'GivenNamePrefix_Tier1' 640 | defaultValue = 'A1C' 641 | Regex = '^[^\s].*[^\s]$|^.$' 642 | } 643 | @{ 644 | sourceName = "AV_CloudAdminTier1_GivenNamePrefixSeparator" 645 | mapToVariable = 'GivenNamePrefixSeparator_Tier1' 646 | defaultValue = '-' 647 | Regex = '^.{1,2}$' 648 | } 649 | @{ 650 | sourceName = "AV_CloudAdminTier1_GivenNameSuffix" 651 | mapToVariable = 'GivenNameSuffix_Tier1' 652 | defaultValue = $null 653 | Regex = '^[^\s].*[^\s]$|^.$' 654 | } 655 | @{ 656 | sourceName = "AV_CloudAdminTier1_GivenNameSuffixSeparator" 657 | mapToVariable = 'GivenNameSuffixSeparator_Tier1' 658 | defaultValue = '-' 659 | Regex = '^.{1,2}$' 660 | } 661 | @{ 662 | sourceName = "AV_CloudAdminTier1_UserPrincipalNameUsesSamAccountName" 663 | mapToVariable = 'UserPrincipalNameUsesSamAccountName_Tier1' 664 | defaultValue = $false 665 | } 666 | @{ 667 | sourceName = "AV_CloudAdminTier1_UserPrincipalNamePrefix" 668 | mapToVariable = 'UserPrincipalNamePrefix_Tier1' 669 | defaultValue = 'A1C' 670 | Regex = '^[^\s].*[^\s]$|^.$' 671 | } 672 | @{ 673 | sourceName = "AV_CloudAdminTier1_UserPrincipalNamePrefixSeparator" 674 | mapToVariable = 'UserPrincipalNamePrefixSeparator_Tier1' 675 | defaultValue = '-' 676 | Regex = '^.{1,2}$' 677 | } 678 | @{ 679 | sourceName = "AV_CloudAdminTier1_UserPrincipalNameSuffix" 680 | mapToVariable = 'UserPrincipalNameSuffix_Tier1' 681 | defaultValue = $null 682 | Regex = '^[^\s].*[^\s]$|^.$' 683 | } 684 | @{ 685 | sourceName = "AV_CloudAdminTier1_UserPrincipalNameSuffixSeparator" 686 | mapToVariable = 'UserPrincipalNameSuffixSeparator_Tier1' 687 | defaultValue = '-' 688 | Regex = '^.{1,2}$' 689 | } 690 | @{ 691 | sourceName = "AV_CloudAdminTier0_LifecycleDelete" 692 | mapToVariable = 'LifecycleDelete_Tier1' 693 | defaultValue = $false 694 | } 695 | @{ 696 | sourceName = "AV_CloudAdminTier0_LifecycleRestoreAfterDelete" 697 | mapToVariable = 'LifecycleRestoreAfterDelete_Tier1' 698 | defaultValue = $false 699 | } 700 | @{ 701 | sourceName = "AV_CloudAdminTier0_LifecycleDisable" 702 | mapToVariable = 'LifecycleDisable_Tier1' 703 | defaultValue = $false 704 | } 705 | @{ 706 | sourceName = "AV_CloudAdminTier0_LifecycleEnableAfterDisable" 707 | mapToVariable = 'LifecycleEnableAfterDisable_Tier1' 708 | defaultValue = $false 709 | } 710 | #endregion 711 | 712 | #region Tier 2 Constants 713 | @{ 714 | sourceName = "AV_CloudAdminTier2_AccountAdminUnitId" 715 | mapToVariable = 'AccountAdminUnitId_Tier2' 716 | defaultValue = $null 717 | Regex = '^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$' 718 | } 719 | @{ 720 | sourceName = "AV_CloudAdminTier2_LicenseGroupId" 721 | mapToVariable = 'LicenseGroupId_Tier2' 722 | defaultValue = $null 723 | Regex = '^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$' 724 | } 725 | @{ 726 | sourceName = "AV_CloudAdminTier2_LicenseSkuPartNumber" 727 | mapToVariable = 'LicenseSkuPartNumber_Tier2' 728 | defaultValue = '' 729 | Regex = '^[A-Z0-9][A-Z0-9_ ]+[A-Z0-9]$' 730 | } 731 | @{ 732 | sourceName = "AV_CloudAdminTier2_GroupId" 733 | mapToVariable = 'GroupId_Tier2' 734 | defaultValue = $null 735 | Regex = '^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$' 736 | } 737 | @{ 738 | sourceName = "AV_CloudAdminTier2_UserPhotoUrl" 739 | respectScriptParameter = 'UserPhotoUrl' 740 | mapToVariable = 'PhotoUrl_Tier2' 741 | defaultValue = $null 742 | Regex = '^https:\/\/.+(?:\.png|\.jpg|\.jpeg|\?.+)$' 743 | } 744 | @{ 745 | sourceName = "AV_CloudAdminTier2_DedicatedAccount" 746 | mapToVariable = 'DedicatedAccount_Tier2' 747 | defaultValue = 'Optional' 748 | Regex = '^(?:Require|Optional|None)$' 749 | } 750 | @{ 751 | sourceName = "AV_CloudAdminTier2_AllowedGuestOrExternalForDedicatedAccount" 752 | mapToVariable = 'AllowedGuestOrExternalForDedicatedAccount_Tier2' 753 | defaultValue = $false 754 | } 755 | @{ 756 | sourceName = "AV_CloudAdminTier2_AllowedGuestOrExternalUserTypes" 757 | mapToVariable = 'AllowedGuestOrExternalUserTypes_Tier2' 758 | defaultValue = 'internalGuest b2bCollaborationGuest b2bCollaborationMember' 759 | Regex = '(?:internalGuest|b2bCollaborationGuest|b2bCollaborationMember|otherExternalUser)(\s|$)+' 760 | } 761 | @{ 762 | sourceName = "AV_CloudAdminTier2_AllowFacebookAccount" 763 | mapToVariable = 'AllowFacebookAccount_Tier2' 764 | defaultValue = $false 765 | } 766 | @{ 767 | sourceName = "AV_CloudAdminTier2_AllowGoogleAccount" 768 | mapToVariable = 'AllowGoogleAccount_Tier2' 769 | defaultValue = $false 770 | } 771 | @{ 772 | sourceName = "AV_CloudAdminTier2_AllowMicrosoftAccount" 773 | mapToVariable = 'AllowMicrosoftAccount_Tier2' 774 | defaultValue = $false 775 | } 776 | @{ 777 | sourceName = "AV_CloudAdminTier2_AllowExternalEntraAccount" 778 | mapToVariable = 'AllowExternalEntraAccount_Tier2' 779 | defaultValue = $true 780 | } 781 | @{ 782 | sourceName = "AV_CloudAdminTier2_AllowFederatedAccount" 783 | mapToVariable = 'AllowFederatedAccount_Tier2' 784 | defaultValue = $false 785 | } 786 | @{ 787 | sourceName = "AV_CloudAdminTier2_AccountDomain" 788 | mapToVariable = 'AccountDomain_Tier2' 789 | defaultValue = 'onmicrosoft.com' 790 | Regex = '^(?=^.{1,253}$)(([a-z\d]([a-z\d-]{0,62}[a-z\d])*[\.]){1,3}[a-z]{1,61})$' 791 | } 792 | @{ 793 | sourceName = "AV_CloudAdminTier2_AllowSameDomainForReferralUser" 794 | mapToVariable = 'AllowSameDomainForReferralUser_Tier2' 795 | defaultValue = $false 796 | } 797 | @{ 798 | sourceName = "AV_CloudAdminTier2_AccountTypeEmployeeTypePrefix" 799 | mapToVariable = 'AccountTypeEmployeeTypePrefix_Tier2' 800 | defaultValue = 'Tier 2 Cloud Administrator' 801 | Regex = '^[^\s].*[^\s]$|^.$' 802 | } 803 | @{ 804 | sourceName = "AV_CloudAdminTier2_AccountTypeEmployeeTypePrefixSeparator" 805 | mapToVariable = 'AccountTypeEmployeeTypePrefixSeparator_Tier2' 806 | defaultValue = ' (' 807 | Regex = '^.{1,2}$' 808 | } 809 | @{ 810 | sourceName = "AV_CloudAdminTier2_AccountTypeEmployeeTypeSuffix" 811 | mapToVariable = 'AccountTypeEmployeeTypeSuffix_Tier2' 812 | defaultValue = ')' 813 | Regex = '^[^\s].*[^\s]$|^.$' 814 | } 815 | @{ 816 | sourceName = "AV_CloudAdminTier2_AccountTypeEmployeeTypeSuffixSeparator" 817 | mapToVariable = 'AccountTypeEmployeeTypeSuffixSeparator_Tier2' 818 | defaultValue = '' 819 | Regex = '^.{1,2}$' 820 | } 821 | @{ 822 | sourceName = "AV_CloudAdminTier2_AccountTypeExtensionAttributePrefix" 823 | mapToVariable = 'AccountTypeExtensionAttributePrefix_Tier2' 824 | defaultValue = 'A2C' 825 | Regex = '^[^\s].*[^\s]$|^.$' 826 | } 827 | @{ 828 | sourceName = "AV_CloudAdminTier2_AccountTypeExtensionAttributePrefixSeparator" 829 | mapToVariable = 'AccountTypeExtensionAttributePrefixSeparator_Tier2' 830 | defaultValue = '__' 831 | Regex = '^.{1,2}$' 832 | } 833 | @{ 834 | sourceName = "AV_CloudAdminTier2_UserDisplayNamePrefix" 835 | mapToVariable = 'UserDisplayNamePrefix_Tier2' 836 | defaultValue = $null 837 | Regex = '^[^\s].*[^\s]$|^.$' 838 | } 839 | @{ 840 | sourceName = "AV_CloudAdminTier2_UserDisplayNamePrefixSeparator" 841 | mapToVariable = 'UserDisplayNamePrefixSeparator_Tier2' 842 | defaultValue = '-' 843 | Regex = '^.{1,2}$' 844 | } 845 | @{ 846 | sourceName = "AV_CloudAdminTier2_UserDisplayNamePrefixInsertPoint" 847 | mapToVariable = 'UserDisplayNamePrefixInsertPoint_Tier2' 848 | defaultValue = $null 849 | Regex = '.+' 850 | } 851 | @{ 852 | sourceName = "AV_CloudAdminTier2_UserDisplayNameSuffix" 853 | mapToVariable = 'UserDisplayNameSuffix_Tier2' 854 | defaultValue = '(A2C)' 855 | Regex = '^[^\s].*[^\s]$|^.$' 856 | } 857 | @{ 858 | sourceName = "AV_CloudAdminTier2_UserDisplayNameSuffixSeparator" 859 | mapToVariable = 'UserDisplayNameSuffixSeparator_Tier2' 860 | defaultValue = ' ' 861 | Regex = '^.{1,2}$' 862 | } 863 | @{ 864 | sourceName = "AV_CloudAdminTier2_UserDisplayNameSuffixInsertPoint" 865 | mapToVariable = 'UserDisplayNameSuffixInsertPoint_Tier2' 866 | defaultValue = '( \([^\(\)]+\)\s*$| \[[^\[\]]+\]\s*$)' 867 | Regex = '.+' 868 | } 869 | @{ 870 | sourceName = "AV_CloudAdminTier2_GivenNamePrefix" 871 | mapToVariable = 'GivenNamePrefix_Tier2' 872 | defaultValue = 'A2C' 873 | Regex = '^[^\s].*[^\s]$|^.$' 874 | } 875 | @{ 876 | sourceName = "AV_CloudAdminTier2_GivenNamePrefixSeparator" 877 | mapToVariable = 'GivenNamePrefixSeparator_Tier2' 878 | defaultValue = '-' 879 | Regex = '^.{1,2}$' 880 | } 881 | @{ 882 | sourceName = "AV_CloudAdminTier2_GivenNameSuffix" 883 | mapToVariable = 'GivenNameSuffix_Tier2' 884 | defaultValue = $null 885 | Regex = '^[^\s].*[^\s]$|^.$' 886 | } 887 | @{ 888 | sourceName = "AV_CloudAdminTier2_GivenNameSuffixSeparator" 889 | mapToVariable = 'GivenNameSuffixSeparator_Tier2' 890 | defaultValue = '-' 891 | Regex = '^.{1,2}$' 892 | } 893 | @{ 894 | sourceName = "AV_CloudAdminTier2_UserPrincipalNameUsesSamAccountName" 895 | mapToVariable = 'UserPrincipalNameUsesSamAccountName_Tier2' 896 | defaultValue = $false 897 | } 898 | @{ 899 | sourceName = "AV_CloudAdminTier2_UserPrincipalNamePrefix" 900 | mapToVariable = 'UserPrincipalNamePrefix_Tier2' 901 | defaultValue = 'A2C' 902 | Regex = '^[^\s].*[^\s]$|^.$' 903 | } 904 | @{ 905 | sourceName = "AV_CloudAdminTier2_UserPrincipalNamePrefixSeparator" 906 | mapToVariable = 'UserPrincipalNamePrefixSeparator_Tier2' 907 | defaultValue = '-' 908 | Regex = '^.{1,2}$' 909 | } 910 | @{ 911 | sourceName = "AV_CloudAdminTier2_UserPrincipalNameSuffix" 912 | mapToVariable = 'UserPrincipalNameSuffix_Tier2' 913 | defaultValue = $null 914 | Regex = '^[^\s].*[^\s]$|^.$' 915 | } 916 | @{ 917 | sourceName = "AV_CloudAdminTier2_UserPrincipalNameSuffixSeparator" 918 | mapToVariable = 'UserPrincipalNameSuffixSeparator_Tier2' 919 | defaultValue = '-' 920 | Regex = '^.{1,2}$' 921 | } 922 | @{ 923 | sourceName = "AV_CloudAdminTier0_LifecycleDelete" 924 | mapToVariable = 'LifecycleDelete_Tier2' 925 | defaultValue = $false 926 | } 927 | @{ 928 | sourceName = "AV_CloudAdminTier0_LifecycleRestoreAfterDelete" 929 | mapToVariable = 'LifecycleRestoreAfterDelete_Tier2' 930 | defaultValue = $false 931 | } 932 | @{ 933 | sourceName = "AV_CloudAdminTier0_LifecycleDisable" 934 | mapToVariable = 'LifecycleDisable_Tier2' 935 | defaultValue = $false 936 | } 937 | @{ 938 | sourceName = "AV_CloudAdminTier0_LifecycleEnableAfterDisable" 939 | mapToVariable = 'LifecycleEnableAfterDisable_Tier2' 940 | defaultValue = $false 941 | } 942 | #endregion 943 | ) 944 | 945 | Write-Verbose "-----END of $((Get-Item $PSCommandPath).Name) ---" 946 | return $Constants 947 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 13 |
14 | 15 | [![Contributors][contributors-shield]][contributors-url] 16 | [![Forks][forks-shield]][forks-url] 17 | [![Stargazers][stars-shield]][stars-url] 18 | [![Issues][issues-shield]][issues-url] 19 | [![MIT License][license-shield]][license-url] 20 | [![Workoho][Workoho]][Workoho-url] 21 | 22 |
23 | 24 | 25 |
26 |
27 | 28 | Logo 29 | 30 | 31 |

Cloud Administration Tiering Security Model for Microsoft Entra

32 | 33 |

34 | Implement a powerful Tiering Security Model in Microsoft Entra for your Cloud Administrator identities using Azure Automation. 35 |
36 | Explore the docs » 37 |
38 | 39 | [![Open template in GitHub Codespaces](https://img.shields.io/badge/Open%20in-GitHub%20Codespaces-blue?logo=github)](https://codespaces.new/Workoho/Entra-Tiering-Security-Model) 40 |     41 | [![View template online in Visual Studio Code](https://img.shields.io/badge/View%20Online%20in-Visual%20Studio%20Code-blue?logo=visual-studio-code)](https://vscode.dev/github/Workoho/Entra-Tiering-Security-Model) 42 |
43 | Report Bug 44 | · 45 | Request Feature 46 | 47 |

48 |
49 | 50 | 51 |
52 | Table of Contents 53 |
    54 |
  1. 55 | About The Project 56 | 59 |
  2. 60 |
  3. 61 | Getting Started 62 | 68 |
  4. 69 |
  5. Usage
  6. 70 |
  7. Contributing
  8. 71 |
  9. License
  10. 72 |
  11. Maintainers
  12. 73 |
74 |
75 | 76 | 77 | 78 | ## About The Project 79 | 80 | This project provides you with a template for implementing a basic security model for tier separation in your Microsoft cloud environment. 81 | This significantly helps to separate the security of your on-premises and cloud environment and [protect Microsoft 365 from on-premises attacks](https://learn.microsoft.com/en-us/entra/architecture/protect-m365-from-on-premises-attacks). 82 | 83 | The implementation and maintenance effort can be uge and requires deep knowledge in multiple Microsoft technologies and security principles. Using this project as a template, it helps to set up highly protected security groups using [Management Restricted Administrative Units](https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/admin-units-restricted-management) and by delegation of maintenance and provisioning tasks for dedicated, cloud-native Cloud Administrator accounts to a locked [Azure Automation account](https://learn.microsoft.com/en-us/azure/automation/overview) with [System-Assigned Managed Identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview). 84 | 85 | These highly protected security groups form the basis for the implementation of Microsoft Entra security measures in Conditional Access, such as [Authentication Contexts](https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-cloud-apps#authentication-context) for the use of [Activation Rules in Privileged Identity Management](https://learn.microsoft.com/en-us/graph/identity-governance-pim-rules-overview#activation-rules) and [Protected Actions](https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/protected-actions-overview). 86 | 87 | The following graphic illustrates the tier separation concept at a high level: 88 | 89 | [![How the Tier separation works](images/Entra-Tiering-Security-Model-Cloud%20Account%20Tier%20Separation.png "Cloud Administrator Account Tier Separation")](documents/Entra-Tiering-Security-Model-Cloud%20Account%20Tier%20Separation.pdf) 90 | 91 | The lifecycle management of dedicated, cloud-native Cloud Administrator accounts is tied to a primary user account. The properties are automatically copied from this account and can be updated regularly to keep them synchronized. 92 | 93 | Preferably, the primary account is a company account or can even be an external guest account (not implemented yet). This ties the lifecycle management of the dedicated Cloud Administrator account to the existing lifecycle of the users. 94 | 95 | If your Microsoft Entra tenant was configured for [Hybrid Identities](https://learn.microsoft.com/en-us/entra/identity/hybrid/whatis-hybrid-identity), only synchronized identitied from your on-premises Active Directory are eligible to create a cloud administration account. 96 | 97 | > :information_source: In case of using guest accounts, we strongly recommend to implement an appropriate lifecycle management first, for example with [EasyLife 365 Collaboration](https://easylife365.cloud/products/collaboration/). 98 | > 99 | > _Workoho_ is an official EasyLife partner. [Contact us](https://workoho.com/kontakt/) if you need help managing your guest identities. 100 | 101 | The following graphic illustrates the lifecycle concept at a high level: 102 | 103 | [![How lifecycle management for dedicated Cloud Administrator account works](images/Entra-Tiering-Security-Model-Cloud%20Admin%20Lifecycle.png "Connection between Dedicated Cloud Administrator and Primary Account")](documents/Entra-Tiering-Security-Model-Cloud%20Admin%20Lifecycle.pdf) 104 | 105 | ### Built With 106 | 107 |
108 | 109 | [![Azure Automation Framework][AzAutoFW]][AzAutoFW-url] 110 | [![GitHub Codespaces][GitHubCodespaces]][GitHubCodespaces-url] 111 | [![Visual Studio Code][VScode]][VScode-url] 112 | [![PowerShell][PowerShell]][PowerShell-url] 113 | 114 |
115 | 116 |

(back to top)

117 | 118 | 119 | 120 | ## Getting Started 121 | 122 | The entire setup is fully automatic (thanks to the amazing [Azure Automation Common Runbook Framework](https://github.com/workoho/AzAuto-Common-Runbook-FW)), but requires some preparation and decision making to start. 123 | 124 | [![asciicast](https://asciinema.org/a/646552.svg)](https://asciinema.org/a/646552) 125 | _Preview of the setup procedure_ 126 | 127 | ### Prerequisites 128 | 129 | Your Microsoft Entra ID tenant must be enabled and licensed for [Privileged Identity Management](https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-configure), which is a premium feature and part of the Microsoft Entra ID P2 licensing plan. Note that this could also be part of your Microsoft 365 E5 or Enterprise Mobility & Security E5 license. Visit [Sign up for Microsoft Entra ID P1 or P2 editions 130 | ](https://learn.microsoft.com/en-us/entra/fundamentals/get-started-premium) on Microsoft Learn for further information. 131 | 132 | You must also have free Exchange Online licenses for each dedicated Cloud Administrator account to enable email forwarding. _Exchange Online Kiosk_ is a good and cost-effective solution, but any other license with an Exchange service plan is also suitable. 133 | 134 | Furthermore, [emergency access admin accounts](https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/security-emergency-access) must be in place to have them available for _"break glass"_ scenarios. 135 | 136 | We assume that the following requirements for your local Windows, macOS, or Linux machine are already met and do not describe them in detail here: 137 | 138 | 1. [Git](https://learn.microsoft.com/en-us/devops/develop/git/install-and-set-up-git) is installed. 139 | 2. [PowerShell](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell) is installed. 140 | 141 | We recommend using the latest version of PowerShell Core, but Windows PowerShell should do as well. 142 | 143 | 3. Optional: [7-Zip](https://www.7-zip.org/download.html) is installed. This may be used to encrypt configuration files if you would like to protect their content, but still check them into your Git repository. 144 | 145 | 4. Following PowerShell modules are installed: 146 | 147 | - `Az` 148 | - `Microsoft.Graph` 149 | 150 | For local run and/or development, further modules are required: 151 | 152 | - `ExchangeOnlineManagement` 153 | 154 | 5. Might be optional, but _highly recommended_: [Visual Studio Code](https://code.visualstudio.com/docs/setup/setup-overview) is installed. 155 | 156 | 6. On Windows, it is preferred to have [SeCreateSymbolicLinkPrivilege](https://learn.microsoft.com/en-us/windows/security/threat-protection/security-policy-settings/create-symbolic-links) enabled for your local user account. However, this is not a fixed requirement. 157 | 158 | > :heart_decoration: Or: You forget about all of these dependencies and start right away with our prepared build and runtime environment using GitHub Codespaces: No time to setup your local machine, no policy restrictions - simply start effortless :sunglasses: 159 | > 160 | > [![Open template in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/Workoho/Entra-Tiering-Security-Model) 161 | > 162 | > Alternatively, you may use any type of [development container](https://code.visualstudio.com/docs/devcontainers/containers) with your local Visual Studio Code setup. We provide pre-configured Docker containers for Windows Subsystem for Linux (Intel x64), Linux (Intel x64 and ARM64), and macOS (Intel x64 and Apple Silicon). 163 | > 164 | > For further information, read [Choosing your development environment](https://code.visualstudio.com/docs/containers/choosing-dev-environment) at Visual Studio Code Docs. 165 | 166 | ### Installation 167 | 168 | The following steps only need to be performed once to get you started: 169 | 170 | 1. Open a PowerShell command line prompt. 171 | 2. Make a local clone of this repository and change to the directory afterwards: 172 | 173 | ```powershell 174 | $CORP='CORP' 175 | mkdir $CORP 176 | cd $CORP 177 | git clone https://github.com/workoho/Entra-Tiering-Security-Model.git $CORP.Entra-Tiering-Security-Model 178 | cd $CORP.Entra-Tiering-Security-Model 179 | 180 | # Rename the Visual Studio Code workspace 181 | Rename-Item ./Entra-Tiering-Security-Model.code-workspace $CORP.Entra-Tiering-Security-Model.code-workspace 182 | 183 | # Remove the remote tracking and rename the remote from 'origin' to 'base' for future updating 184 | git branch --unset-upstream main 185 | git remote rename origin base 186 | ``` 187 | 188 | You should set `$CORP` to your company name code to indicate that this is containing your own setup. 189 | 190 | Note that we are also creating a dedicated folder for our project. In step 3, we are going to clone a second repository containing the Azure Automation Common Runbook Framework code that lives in parallel to our project repository. In case you are dealing with different environments, this ensures each environment uses its own copy of the framework and can check out their version of the framework without interfering your other environment. 191 | 192 | - _Optional:_ You may add your own remote respository to upload your local changes, for example to an empty (private) repository you have created in your own GitHub organization: 193 | 194 | ```powershell 195 | git remote add origin git@github.com:username/CORP.Entra-Tiering-Security-Model.git 196 | git push --set-upstream origin main 197 | git push --tags origin 198 | ``` 199 | 200 | Note that the URL depends on how you authenticate and access your remote repository and may look different. 201 | For example, if you prefer to access your repository via HTTPS instead of SSH, the command would look like this: 202 | 203 | ```powershell 204 | git remote add origin https://github.com/username/CORP.Entra-Tiering-Security-Model.git 205 | git push --set-upstream origin main 206 | git push --tags origin 207 | ``` 208 | 209 | 3. Trigger downloading the upstream Azure Automation Common Runbook Framework: 210 | 211 | ```powershell 212 | ./scripts/AzAutoFWProject/Update-AzAutoFWProject.ps1 213 | ``` 214 | 215 | This will automatically clone the upstream framework into a directory that is parallel to your project repository. 216 | It will automatically check out the desired version of the framework, based on your settings in `./config/AzAutoFWProject/AzAutoFWProject.psd1`. The default is a static reference to the latest stable version at the time you cloned the repository. 217 | 218 | The resulting folder structure now should look like this: 219 | 220 | ```plaintext 221 | PS> dir .. 222 | 223 | Directory: /Users/me/Developer/CORP 224 | 225 | UnixMode User Group LastWriteTime Size Name 226 | ---------- ---- ----- ---------------- ---- ---- 227 | drwxr-xr-x me staff 06.03.2024 10:20 640 AzAuto-Common-Runbook-FW 228 | drwxr-xr-x me staff 06.03.2024 10:20 672 CORP.Entra-Tiering-Security-Model 229 | ``` 230 | 231 | Also, shared resources like script and runbooks will either be copied into your project repository, or symlinked (depending on your operating system and its settings). For example, on **macOS** and **Linux** (including Windows Subsystem for Linux), symlinking shared resources is the default behaviour to ensure they will always be in sync. 232 | 233 | On **Windows**, symlinking may only be used if the [SeCreateSymbolicLinkPrivilege](https://learn.microsoft.com/en-us/windows/security/threat-protection/security-policy-settings/create-symbolic-links) permission was enabled for your local user account. Otherwise, the resources will be copied. If you would like to update the shared resources, you may execute the `./scripts/AzAutoFWProject/Update-AzAutoFWProject.ps1` update script at any time. 234 | 235 | **Good to know:** When you open the project repository in _Visual Studio Code_, an [automated task](https://code.visualstudio.com/docs/editor/tasks) will run the update script so you don't have to. 236 | 237 | 4. The configuration is splitted into two separate files: 238 | 239 | 1. Local configuration in `./config/AzAutoFWProject/AzAutoFWProject.local.psd1`: 240 | 241 | This file typically exists on your local machine only. It may contain parts of the configuration that you consider to be confidential for your company. 242 | 243 | The `Update-AzAutoFWProject.ps1` script you ran before already should have created a copy for you but you may also do this manually: 244 | 245 | Copy the configuration template from `./config/AzAutoFwProject/AzAutoFWProject.local.template.psd1` to `./config/AzAutoFwProject/AzAutoFWProject.local.psd1`: 246 | 247 | ```powershell 248 | copy ./config/AzAutoFWProject/AzAutoFWProject.local.template.psd1 ./config/AzAutoFWProject/AzAutoFWProject.local.psd1 249 | ``` 250 | 251 | Please note that by default, this configuration file is ignored by the Git repository to avoid accidential leaks of potential sensitive information. 252 | 253 | 2. Public configuration in `./config/AzAutoFWProject/AzAutoFWProject.psd1`: 254 | 255 | This file is part of your Git project repository and subject to tracking of changes. Essential parts of the configuration are done in this file where the general information is not considered a secret and its content is to be shared with everyone with access to your Git repository. 256 | 257 | The `Update-AzAutoFWProject.ps1` script you ran before already should have created a copy for you but you may also do this manually: 258 | 259 | Copy the configuration template from `./config/AzAutoFwProject/AzAutoFWProject.template.psd1` to `./config/AzAutoFwProject/AzAutoFWProject.psd1`: 260 | 261 | ```powershell 262 | copy ./config/AzAutoFWProject/AzAutoFWProject.template.psd1 ./config/AzAutoFWProject/AzAutoFWProject.psd1 263 | ``` 264 | 265 | **Important:** Some parts of the configuration may be moved between the two files. However, it is not a general concept and is only supported where it is explicitly explained. 266 | 267 | 5. Open `./config/AzAutoFWProject/AzAutoFWProject.local.psd1` in your favorite editor. 268 | 269 | This project template provides a pre-configured build and development environment for _Visual Studio Code_, which is why we recommend using this to edit configuration files. You will also be able to collapse and extend longer regions and sections to maneuver through it. 270 | 271 | In this configuration file, you will need to enter details like: 272 | 273 | - `Name` 274 | 275 | Name your Automation Account to whatever you like, e.g. `prodsub-germanywestcentral-aa001`. 276 | 277 | Note that you will not be able to change the account name after the Automation Account was created. 278 | 279 | - `TenantId` 280 | 281 | The UUID of your Microsoft Entra ID tenant, e.g. `e83262cb-7b0b-4d13-9496-455b378896e4`. 282 | 283 | - `SubscriptionId` 284 | 285 | The UUID of your Azure Subscription, e.g. `f47626a6-2970-41a8-b44c-a4a14ccff181`. 286 | 287 | Please note that the subscription **must** be associated to the `TenantId`. 288 | 289 | - `ResourceGroupName` 290 | 291 | The name of the resource group where you want your Azure Automation Account to live. 292 | 293 | Please note that a pre-existing resource group **must** be in the `SubscriptionId` you entered before. 294 | If this resource group does not exist yet, it will automatically be created in the given subscription. You must have at least `Contributor` role assigned for the subscription for this to work (also see minimum permissions for [1. Azure roles](#1-setup-azure-roles) below). 295 | 296 | As mentioned earliert, this file is by default not tracked in the Git repository. Here are a few options how you can backup this file: 297 | 298 | - _Option 1 (Preferred):_ Store on a local server and keep it separate from the Git repository. 299 | 300 | This is the safest option, but might not be easy to handle during daily operations when you need to find the file later. 301 | 302 | - _Option 2:_ Encrypted ZIP archive using 7-zip. 303 | 304 | As a compromise, you may add the file to an encrypted ZIP archive and add this file to the repository: 305 | 306 | ```powershell 307 | cd ./config/AzAutoFWProject/ 308 | 7z a -p -tzip AzAutoFWProject.local.psd1.zip AzAutoFWProject.local.psd1 309 | git add --force AzAutoFWProject.local.psd1.zip 310 | git commit -m "Add ZIP-encrypted file of AzAutoFWProject.local.psd1" 311 | git push 312 | cd - 313 | ``` 314 | 315 | Attention: Make sure that you are actually adding the `.zip` file, _not_ the plain `.psd1` file (fans using [tab completion](https://learn.microsoft.com/en-us/powershell/scripting/learn/shell/tab-completion) might know what I mean). 316 | 317 | Of course, you must remember the password. We recommend using a password manager to generate a long random password. 318 | If you ever want to use this file on a different machine, you will need this password to unpack the file. 319 | 320 | - _Option 3:_ Force adding it to the Git repository. 321 | 322 | If you decide to add it to your repository, it is strongly recommended to ensure only selected persons have read access to the repository to keep this information safe: 323 | 324 | ```powershell 325 | git add --force ./config/AzAutoFWProject/AzAutoFWProject.local.psd1 326 | git commit -m "Forcibly add AzAutoFWProject.local.psd1" 327 | git push 328 | ``` 329 | 330 | 6. Review and adjust default settings in `./config/AzAutoFWProject/AzAutoFWProject.psd1` to your liking. 331 | 332 | You want to pay special attention to these three sections: 333 | 334 | 1. `ManagedIdentity` 335 | 336 | Review the permissions that will permanently assigned to the System-Assigned Managed Identity of the Automation Account. A short justification information is provided in the configuration file so you can understand what the permissions are used for. Removing any of them will result in runbooks not working anymore. 337 | 338 | 2. `AdministrativeUnit` 339 | 340 | Review the `DisplayName` for the Administrative Units to be be created and the desired permissions that the administrator account you are using for the setup will receive. 341 | 342 | You may also pay attention at the specific settings that are not quite common, like enabling [Restricted Management](https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/admin-units-restricted-management), or restricting the membership visibility. Note that these two settings can only be set during creation and not changed afterwards. 343 | 344 | The `MembershipRule` setting for dynamic membership is also something to look at. If you want to change the default naming schema for your dedicated Cloud Administrator accounts, you will need to adjust the rules accordingly. 345 | Please note that in that case you will also need to add additional Automation Variables for the `CloudAdmin_0100__New-CloudAdministrator-Account.ps1` runbook to know about your desired prefix and suffix preferences. 346 | 347 | 3. `Group` 348 | 349 | Review the `DisplayName` for the cloud security groups to be created. 350 | 351 | You should also look at the `InitialLicenseAssignment` to validate the license assignment setup for the group, or remove that part if you prefer to handle this manually after the groups were created. Essentially, an Exchange Online mailbox is mandatory for each dedicated Cloud Administrator account that the `CloudAdmin_0100__New-CloudAdministrator-Account.ps1` runbook will create. The example provides details to assign Exchange Online Kiosk license and only enable Exchange Online service plan of it. 352 | 353 | > :information_source: You may need to add additional licensing for Microsoft Entra ID P2 to follow corporate compliance policies. If the referring primary user account is already licensed for it, and you can ensure that the dedicated Cloud Administrator account is used exclusively by the same natural person, you are generally entitled to use the features on both accounts, unless it is technically necessary to assign a license to both accounts. This assumption is based on the [Microsoft Universal License Terms for Online Services](https://www.microsoft.com/licensing/terms/product/ForOnlineServices/all) (search for _"Customer must acquire and assign the appropriate subscription licenses ..."_ in the _Licensing the Online Services_ section). **We recommend to validate this with your Microsoft sales representative for your special situation.** We do not provide any legal advice at this point. 354 | > 355 | > To add licenses to the configuration, you find the required `SkuPartNumber` and `ServicePlanName` details on the Microsoft Learn page about [Product names and service plan identifiers for licensing](https://learn.microsoft.com/en-us/entra/identity/users/licensing-service-plan-reference). Some products may have multiple SKU Part Numbers (String IDs). To ensure you are using the correct `SkuPartNumber`, you may use [`Get-MgSubscribedSku`](https://learn.microsoft.com/en-us/powershell/module/microsoft.graph.identity.directorymanagement/get-mgsubscribedsku) to validate this for your tenant: 356 | > 357 | > ```powershell 358 | > Connect-MgGraph -ContextScope Process -Scopes Organization.Read.All 359 | > Get-MgSubscribedSku | Where-Object SkuPartNumber -like '*M365_F1*' 360 | > ``` 361 | 362 | The `MembershipRule` setting for dynamic membership is again something to look at here as well. If you want to change the default naming schema for your dedicated cloud-native Cloud Administrator accounts, you will need to adjust the rules accordingly. 363 | Please note that in that case you will also need to add additional Automation Variables for the `CloudAdmin_0100__New-CloudAdministrator-Account.ps1` to know about your desired prefix and suffix settings. 364 | 365 | 7. Start the initial setup. 366 | 367 | The setup script will actively validate and confirm if the correct roles are active for the current admin user and will give explicit guidance about any missing permissions for the setup to be successful. 368 | 369 | See [Minimum permissions for the administrator account during the initial interactive setup session](#minimum-permissions-for-the-administrator-account-during-the-initial-interactive-setup-session) if you want to prepare this upfront. 370 | 371 | When you are ready, choose one of the setup options below: 372 | 373 | - _Option 1 (Preferred):_ Run the all-at-once script: 374 | 375 | ```powershell 376 | ./setup/AzAutoFWProject/Invoke-Setup.ps1 377 | ``` 378 | 379 | Dont't worry, the setup script will ask for approval for each change it will perform with detailled information about what it will do afterwards. If you want, you may also use the `-WhatIf` parameter for a dry-run. 380 | 381 | It is also good to know that you may always start the setup script again, for example if there are still missing permissions for it to continue with the setup. 382 | 383 | - _Option 2:_ Go through the setup step-by-step: 384 | 385 | If you prefer to split the setup in parts, for example because you are working together with different teams, you may do so by only starting the part that you desire. However, it is best to keep the order right, which is shown below: 386 | 387 | ```powershell 388 | # More general parts 389 | ./setup/AzAutoFWProject/Set-AzAutomationAccount.ps1 # Create the Automation Account 390 | ./setup/AzAutoFWProject/Set-AzAutomationRuntimeEnvironment.ps1 # Install PowerShell modules in the runtime environment of the Automation Account 391 | ./setup/AzAutoFWProject/Set-AzAutomationRunbook.ps1 # Upload all common runbooks to the Automation Account in the correct sorting order 392 | 393 | # Critical parts 394 | ./setup/AzAutoFWProject/Set-EntraAdministrativeUnit.ps1 # Create Restricted Administrative Units 395 | ./setup/AzAutoFWProject/Set-EntraGroup.ps1 # Create groups inside the Restricted Administrative Units that were created before 396 | ./setup/AzAutoFWProject/Set-AzAutomationManagedIdentity.ps1 # Enable System-Assigned Managed Identity and assign desired permissions to it 397 | 398 | # Set configuration 399 | ./setup/AzAutoFWProject/Set-AzAutomationVariable.ps1 # Create Automation Variables in the Automation Account 400 | ``` 401 | 402 | 8. Update configuration in `./config/AzAutoFWProject/AzAutoFWProject.psd1`: 403 | 404 | Now that all parts are created, you need to update some of the configurations and add the unique object IDs. Essentially we need to update the Automation Variables that the CloudAdmin runbooks use for their configuration to know what Microsoft Entra Groups and Administrative Units we are using. There are two options to do so: 405 | 406 | - _Option 1 (Preferred):_ Add object IDs to the `AdministrativeUnit` and `Group` section: 407 | 408 | The setup script output gave you information about the object ID's for the _Administrative Units_ and _Groups_. Look for their section in the configuration file and add the object ID to the empty `Id` property. 409 | 410 | - _Option 2:_ Update `AutomationVariable` configuration directly: 411 | 412 | The defintion of the Automation Variables uses an internal reference to the definitions of Administrative Units and Group so that when you followed _Option 1_ from above, their IDs are automatically used. If for any reason you would like to set the object IDs in the Automation Variables directly, you may look to the `AutomationVariable` section in the configuration file and change the `Value` attribute accordingly. Note that in this case you must also remove the `ValueReferenceTo` property. 413 | 414 | Afterwards, re-run the `./setup/AzAutoFWProject/Set-AzAutomationVariable.ps1` script to update the Automation Variables (use with parameter `-UpdateVariableValue`). You may control if their values were updated successfully in the Azure Portal (find the Automation Account and navigate to _Variables_ under _Shared Resources_ in the left navigation). 415 | 416 | 9. Check your initial configuration into the Git repository. 417 | 418 | After successfully performing the initial setup, it is a good idea now to officially commit your initial setup details to the Git repository and upload them to the remote server: 419 | 420 | ```powershell 421 | git commit --all --message "Add configuration details after initial setup" 422 | git push 423 | ``` 424 | 425 | 10. Cleanup permissions that are no longer necessary for your own administrator account. 426 | 427 | It is **strongly recommended to review the permissions** you have assigned to your administrator account for the setup, as it may now be time to remove some of them. See [Minimum permissions after the setup of the Azure Automation Account](#minimum-permissions-after-the-setup-of-the-azure-automation-account) to give you some guidance. 428 | 429 |

(back to top)

430 | 431 | ### Minimum permissions for the administrator account during the initial interactive setup session 432 | 433 | The setup script `./setup/AzAutoFWProject/Invoke-Setup.ps1` and its sub-scripts require a couple of different roles to perform the setup steps. 434 | The setup script will actively validate and confirm if the correct roles are active for the current admin user and will give explicit guidance about any missing permissions for the setup to be successful. 435 | 436 | For the setup procedure to be as simple as possible, you might want to prepare for the following permissions to be available for your admin user: 437 | 438 | **1. Azure roles** (see [Azure built-in roles](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles)): 439 | 440 | While you could run the setup script with Azure _Owner_ permissions to fulfill all needs, we recommend following the [principle of least privilege](https://learn.microsoft.com/en-us/entra/identity-platform/secure-least-privileged-access) and only have the following Azure roles active for your administrator account: 441 | 442 | Either at _Subscription_ or _Resource Group_ level: 443 | 444 | - `Contributor` 445 | 446 | _Justification:_ Create new resource group (if required), new Azure Automation Account and upload new Automation Runbooks. 447 | 448 | - `User Access Administrator` _(optional condition: Constrain to Azure `Reader` role)_ 449 | 450 | _Justification:_ Delegate Azure `Reader` role to System-Assigned Managed Identity of the Automation Account. 451 | 452 | To learn more about Azure role assignments with conditions, visit [Delegate Azure role assignment management to others with conditions](https://learn.microsoft.com/en-us/azure/role-based-access-control/delegate-role-assignments-portal) on Microsoft Learn. 453 | 454 | **Important:** Make sure your Azure roles are activated before starting the setup. See [Activate my Azure resource roles in Privileged Identity Management](https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-resource-roles-activate-your-roles) if you need help. 455 | 456 | **2. Microsoft Entra directory roles** (see [Microsoft Entra built-in roles](https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference)): 457 | 458 | While you could run the setup script with _Global Administrator_ permissions to fulfill all needs, we recommend following the [principle of least privilege](https://learn.microsoft.com/en-us/entra/identity-platform/secure-least-privileged-access) and only have the following Entra directory roles active for your administrator account: 459 | 460 | - `Cloud Application Administrator` 461 | 462 | _Justification:_ Assign app permissions to the System-Assigned Managed Identity of the Automation Account. Also, allow to perform one-time admin consent for scopes (application roles) of the _Microsoft Graph Command Line Tools_ application (see [3. Microsoft Graph Command Line Tools](#3-setup-microsoft-graph-scopes)). 463 | 464 | - `Privileged Role Administrator` 465 | 466 | _Justification:_ Assign Entra directory roles to System-Assigned Managed Identity of the Azure Automation Account. Also, create required Administrative Units in Microsoft Entra. 467 | This role is also required to supplement the `Cloud Application Administrator` when assigning app permissions to highly-privileged applications like Microsoft Graph. 468 | 469 | For Management Restricted Administrative Units, the setup script will automatically assign [scoped roles](https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/custom-overview#scope) as [eligible](https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-configure#terminology) and also activated for 2 hours for the admin running the setup script: 470 | 471 | - `User Administrator` (scoped to the respective Administrative Unit) 472 | 473 | _Justification:_ Allow to manage the dedicated cloud-native Cloud Administrator accounts in the Administrative Unit. 474 | 475 | - `Groups Administrator` (scoped to the respective Administrative Unit) 476 | 477 | _Justification:_ Allow to create and manage the groups belonging to the Cloud Administration Tiering Model. 478 | 479 | - `License Administrator` (scoped to the respective Administrative Unit) 480 | 481 | _Justification:_ Allow to manage group-based licensing for the groups belonging to the Cloud Administration Tiering Model. 482 | 483 | After 2 hours, the activation will automatically expire. The admin user is eligible for the next 3 months to activate the roles again if needed. You may also manually re-configure role assignments for the Restricted Management Administrative Units as you desire. It is **strongly recommended to limit access to Tier 0 Cloud Administrator accounts** only after you completed your migration to the new security model. 484 | 485 | **Important:** Make sure your Azure roles are activated before starting the setup. See [Activate a Microsoft Entra role in Privileged Identity Management](https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-how-to-activate-role) if you need help. 486 | 487 | **3. Microsoft Graph Command Line Tools** (see [Microsoft Graph PowerShell](https://learn.microsoft.com/en-us/powershell/microsoftgraph/) and [Microsoft Graph permissions reference](https://learn.microsoft.com/en-us/graph/permissions-reference)): 488 | 489 | _User consent_: 490 | 491 | - `AdministrativeUnit.Read.All` 492 | - `AdministrativeUnit.ReadWrite.All` 493 | - `Application.Read.All` 494 | - `AppRoleAssignment.ReadWrite.All` 495 | - `Directory.Read.All` 496 | - `Group.Read.All` 497 | - `Group.ReadWrite.All` 498 | - `Organization.Read.All` 499 | - `RoleManagement.Read.Directory` 500 | - `RoleManagement.ReadWrite.Directory` 501 | 502 | Due to the `Cloud Application Administrator` directory role mentioned in [2. Microsoft Entra directory roles](#2-setup-entra-roles), the admin user will be able to perform required admin consents right away. 503 | 504 | If you would like to learn more about user and admin consent in Microsoft Entra ID, visit [this page on Microsoft Learn](https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/user-admin-consent-overview). 505 | 506 | > :information_source: To pre-approve all required scopes at once, you may run the following command: 507 | > 508 | > ```powershell 509 | > Connect-MgGraph -ContextScope Process -Scopes "AdministrativeUnit.Read.All AdministrativeUnit.ReadWrite.All Application.Read.All AppRoleAssignment.ReadWrite.All Directory.Read.All Group.Read.All Group.ReadWrite.All Organization.Read.All RoleManagement.Read.Directory RoleManagement.ReadWrite.Directory" 510 | > ``` 511 | > 512 | > Please note that it is highly recommended to refrain from selecting _"Consent on behalf of your organization"_, also known as [admin consent](https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/user-admin-consent-overview#admin-consent). Instead, you should work with user consent for each individual user to contribute to the [principle of least privilege](https://learn.microsoft.com/en-us/entra/identity-platform/secure-least-privileged-access). 513 | > 514 | > Note that the appearance of this option depends on your current directory role, for example _Cloud Application Administrator_. If you are presented with an error message, you might need to activate that role first, or [configure the admin consent workflow](https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/configure-admin-consent-workflow) so that the consent request can be send to the admin team for approval. 515 | > 516 | > **If you receive an error message or warning in your terminal after you have successfully consented in the browser, it is best to execute the command again to check whether consent has been successfully granted. This is usually successful on the second or third attempt.** 517 | 518 |

(back to top)

519 | 520 | ### Minimum permissions after the setup of the Azure Automation Account 521 | 522 | After the setup was completed, it is **strongly recommended to reduce the permissions** of the admin user to a minimum. 523 | Also note that this level of access shall be exclusive to Tier 0 Cloud Administrators only as soon as you have finished your migration to the new security model. This is to protect the Automation Account and the System-Assigned Managed Identity as good as possible, due to its high privileges to manage the Cloud Administrator accounts for you. 524 | 525 | **1. Azure roles** (see [Azure built-in roles](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles)): 526 | 527 | Either at _Resource Group_ or _Automation Account_ level: 528 | 529 | - `Contributor` 530 | 531 | _Justification:_ Maintenance of Azure Automation Account and Automation Runbooks. 532 | 533 | Note that for the _Automation Account_ level, you may also use the `Automation Contributor` role instead. 534 | 535 | **Important:** It is considered a high risk to grant privileges at the subscription or even management group level to a wider public. 536 | If you decide to grant privileges at these levels, we strongly recommend limiting this access to very few people. 537 | 538 | **2. Microsoft Entra directory roles** (see [Microsoft Entra built-in roles](https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference)): 539 | 540 | For the Management Restricted Administrative Units used for Cloud Administration, [scoped roles](https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/custom-overview#scope) can be assigned as [eligible](https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-configure#terminology) for selected Tier 0 administrators that will provide support to other Cloud Administrators: 541 | 542 | - `User Administrator` (scoped to the respective Administrative Unit for Tier 0, 1, or 2) 543 | 544 | _Justification:_ Allow to manage the dedicated cloud-native Cloud Administrator accounts of the respective Tier. 545 | 546 | - `Groups Administrator` (scoped to the respective Administrative Unit) 547 | 548 | _Justification:_ Allow to manage the groups belonging to the Cloud Administration Tiering Model. 549 | 550 | **Important:** It is considered a high risk to grant access to these groups. Getting access to these groups will allow to manage Cloud Administrator access outside of the Automation Account and without the restrictions and checks the `CloudAdmin_0100__New-CloudAdministrator-Account.ps1` runbook enforces for you. Be aware that this might lead to a security breach if handled in the wrong way! 551 | 552 | **3. Microsoft Graph Command Line Tools**: 553 | 554 | The _delegated_ permissions you might have added for the _Microsoft Graph Command Line Tools_ during the setup session may be kept. 555 | 556 | The nature of delegated permissions is that they also require the respective privileges in Microsoft Entra to be effective. 557 | Visit [Understanding delegated access](https://learn.microsoft.com/en-us/entra/identity-platform/delegated-access-primer) on Microsoft Learn for further details. 558 | 559 | Note that revoking _user consent_ permissions currently is only possible using Microsoft Graph, which we don't explicitly describe here. 560 | 561 | A good alternative is to restrict access to the _Microsoft Graph Command Line Tools_ enterprise application to selected accounts only. These can even be just your managed cloud administrator accounts if you wish. See [Requiring user assignment for an app](https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/what-is-access-management#requiring-user-assignment-for-an-app) and [Manage users and groups assignment to an application](https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/assign-user-or-group-access-portal) to learn more. 562 | 563 | 564 | 565 | ## Usage 566 | 567 | This section requires further attention. :-) 568 | 569 | In general, you may have a look to the inline documentation of the runbooks if you would like to start with an idea. 570 | 571 |

(back to top)

572 | 573 | 574 | 575 | ## Contributing 576 | 577 | Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. 578 | 579 | If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement". 580 | Don't forget to give the project a star! Thanks again! 581 | 582 | 1. Fork the Project 583 | 2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) 584 | 3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) 585 | 4. Push to the Branch (`git push origin feature/AmazingFeature`) 586 | 5. Open a Pull Request 587 | 588 |

(back to top)

589 | 590 | 591 | 592 | ## License 593 | 594 | Distributed under the MIT License. See `LICENSE.txt` for more information. 595 | 596 |

(back to top)

597 | 598 | 599 | 600 | ## Maintainers 601 | 602 | - Julian Pawlowski - [@jpawlowski](https://github.com/jpawlowski) 603 | 604 | Project Link: [https://github.com/workoho/Entra-Tiering-Security-Model](https://github.com/workoho/Entra-Tiering-Security-Model) 605 | 606 |

(back to top)

607 | 608 | 609 | 610 | 611 | [contributors-shield]: https://img.shields.io/github/contributors/Workoho/Entra-Tiering-Security-Model.svg?style=for-the-badge 612 | [contributors-url]: https://github.com/workoho/Entra-Tiering-Security-Model/graphs/contributors 613 | [forks-shield]: https://img.shields.io/github/forks/Workoho/Entra-Tiering-Security-Model.svg?style=for-the-badge 614 | [forks-url]: https://github.com/workoho/Entra-Tiering-Security-Model/network/members 615 | [stars-shield]: https://img.shields.io/github/stars/Workoho/Entra-Tiering-Security-Model.svg?style=for-the-badge 616 | [stars-url]: https://github.com/workoho/Entra-Tiering-Security-Model/stargazers 617 | [issues-shield]: https://img.shields.io/github/issues/Workoho/Entra-Tiering-Security-Model.svg?style=for-the-badge 618 | [issues-url]: https://github.com/workoho/Entra-Tiering-Security-Model/issues 619 | [license-shield]: https://img.shields.io/github/license/Workoho/Entra-Tiering-Security-Model.svg?style=for-the-badge 620 | [license-url]: https://github.com/workoho/Entra-Tiering-Security-Model/blob/master/LICENSE.txt 621 | [AzAutoFW]: https://img.shields.io/badge/Azure_Automation_Framework-1F4386?style=for-the-badge&logo=microsoftazure&logoColor=white 622 | [AzAutoFW-url]: https://github.com/workoho/AzAuto-Common-Runbook-FW 623 | [GitHubCodespaces]: https://img.shields.io/badge/GitHub_Codespaces-09091E?style=for-the-badge&logo=github&logoColor=white 624 | [GitHubCodespaces-url]: https://github.com/features/codespaces 625 | [VScode]: https://img.shields.io/badge/Visual_Studio_Code-2C2C32?style=for-the-badge&logo=visualstudiocode&logoColor=3063B4 626 | [VScode-url]: https://code.visualstudio.com/ 627 | [PowerShell]: https://img.shields.io/badge/PowerShell-2C3C57?style=for-the-badge&logo=powershell&logoColor=white 628 | [PowerShell-url]: https://microsoft.com/PowerShell 629 | [Workoho]: https://img.shields.io/badge/Workoho.com-00B3CE?style=for-the-badge&logo=data:image/svg%2bxml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+Cjxzdmcgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgdmlld0JveD0iMCAwIDEzNDggOTEzIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHhtbDpzcGFjZT0icHJlc2VydmUiIHhtbG5zOnNlcmlmPSJodHRwOi8vd3d3LnNlcmlmLmNvbS8iIHN0eWxlPSJmaWxsLXJ1bGU6ZXZlbm9kZDtjbGlwLXJ1bGU6ZXZlbm9kZDtzdHJva2UtbGluZWpvaW46cm91bmQ7c3Ryb2tlLW1pdGVybGltaXQ6MjsiPgogICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoNC4wNzQ3OCwwLDAsMy45NjAzOCwtNzQzMS4xNSwtNDYzNC44OCkiPgogICAgICAgIDxnPgogICAgICAgICAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCg4LjY2MzI2ZS0xOCwwLjE0MTQ4MiwtMC4xNDE0ODIsOC42NjMyNmUtMTgsNDA1Ny43MiwtNDI5LjM1KSI+CiAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTI3MjYsMTM0NTIuN0wxMjc2Mi4zLDEzNDUyLjdMMTI5MzUuOCwxNDE2Ni40TDEyODk2LjEsMTQxNjYuNEwxMjcyNi44LDEzOTQ2LjRMMTI1NDMsMTM4OTAuM0wxMjcyNiwxMzQ1Mi43WiIvPgogICAgICAgICAgICA8L2c+CiAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDAuMDg0NDk1NCwwLDAsMC4wODQ0OTU0LDE5NDEuOCwxMTA4LjU1KSI+CiAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTUxOC42MSwyOTk1LjQzTDExOTEuNDksMjk5NS40M0wxMDIyLjcsMzExMS40NkwxMDIyLjcsMzIxNS4wMUwxMjk3LjcsMzIxNS4wMUwxNTE4LjYxLDMwNjIuMDVMMTUxOC42MSwyOTk1LjQzWiIvPgogICAgICAgICAgICA8L2c+CiAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDAuMDg0NDk1NCwwLDAsMC4wODQ0OTU0LDE4MTQuMDMsMTEwOC41NSkiPgogICAgICAgICAgICAgICAgPHBhdGggZD0iTTE5MTMuNDIsMjk5NS40M0wxMTkxLjQ5LDI5OTUuNDNMMTAyMi43LDMxMTEuNDZMMTAyMi43LDMyMTUuMDFMMTY5Mi41MiwzMjE1LjAxTDE5MTMuNDIsMzA2Mi4wNUwxOTEzLjQyLDI5OTUuNDNaIi8+CiAgICAgICAgICAgIDwvZz4KICAgICAgICA8L2c+CiAgICAgICAgPGc+CiAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDAuMDg0NDk1NCwwLDAsMC4wODQ0OTU0LDE3MzEuODUsOTE3LjIxKSI+CiAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTkxMy40MiwyOTk1LjQzTDEyNTUuNzQsMjk5NS40M0wxMDg2Ljk0LDMxMTEuNDZMMTA4Ni45NCwzMjE1LjAxTDE2OTIuNTIsMzIxNS4wMUwxOTEzLjQyLDMwNjIuMDVMMTkxMy40MiwyOTk1LjQzWiIgc3R5bGU9ImZpbGw6d2hpdGU7Ii8+CiAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoMC45Mzc1NTgsMCwwLDAuOTM3NTU4LC00NzYwLjU0LC00MDgzLjg4KSI+CiAgICAgICAgICAgICAgICA8cGF0aCBkPSJNNzIwMi4xMiw1NjcxLjYzTDcyMDIuMTIsNTY4MC45TDcxNjMuNiw1NzIzLjM0TDcyNDAuODksNTgxOC43Mkw3MjQwLjg5LDU4MjcuOTlMNzIxMy40Myw1ODI3Ljk5TDcxMzkuNTMsNTczNy4wN0w3MTA0LjYxLDU3MzcuMDdMNzEwNC42MSw1ODI3Ljk5TDcwNzcuMzIsNTgyNy45OUw3MDc3LjMyLDU2MjMuOTFMNzEwNC42MSw1NjIzLjkxTDcxMDQuNjEsNTcwOS42MUw3MTM5LjUzLDU3MDkuNjFMNzE3NC42Niw1NjcxLjYzTDcyMDIuMTIsNTY3MS42M1oiIHN0eWxlPSJmaWxsOndoaXRlO2ZpbGwtcnVsZTpub256ZXJvOyIvPgogICAgICAgICAgICA8L2c+CiAgICAgICAgPC9nPgogICAgPC9nPgo8L3N2Zz4K 630 | [Workoho-url]: https://workoho.com/ 631 | --------------------------------------------------------------------------------