[{"data":1,"prerenderedAt":9316},["ShallowReactive",2],{"navigation":3,"search":42,"tag-posts-linux":561},[4],{"title":5,"path":6,"stem":7,"children":8,"page":41},"Blog","/blog","blog",[9,13,17,21,25,29,33,37],{"title":10,"path":11,"stem":12},"Azure Privileged Identity Management as code","/blog/azure-privileged-identity-management-as-code","blog/azure-privileged-identity-management-as-code",{"title":14,"path":15,"stem":16},"Branch Manager: A Web UI for Cleaning Up Stale Azure DevOps Branches","/blog/branch-manager-azure-devops","blog/branch-manager-azure-devops",{"title":18,"path":19,"stem":20},"Azure Conditional Role Assignments with Bicep!","/blog/conditional-role-assignments","blog/conditional-role-assignments",{"title":22,"path":23,"stem":24},"Centralizing Password Policy Management in Multi-Tenant Entra ID Environments","/blog/entraid-banned-password-list","blog/entraid-banned-password-list",{"title":26,"path":27,"stem":28},"From Hugo to Nuxt: Why I Switched to Vibe Code My Blog","/blog/from-hugo-to-nuxt-vibe-coding","blog/from-hugo-to-nuxt-vibe-coding",{"title":30,"path":31,"stem":32},"Intune & Ubuntu 24.04","/blog/intune-ubuntu-24-04","blog/intune-ubuntu-24-04",{"title":34,"path":35,"stem":36},"PIM + Conditional Role Assignments: Secure Autonomy for Azure Landing Zones","/blog/pim-conditional-role-assignments","blog/pim-conditional-role-assignments",{"title":38,"path":39,"stem":40},"My Ultimate Self-Hosted AI Chat Stack","/blog/ultimate-selfhosted-ai-chat","blog/ultimate-selfhosted-ai-chat",false,[43,47,53,58,63,68,73,78,83,89,94,100,105,110,115,118,123,128,133,138,143,148,153,158,163,166,171,176,180,185,190,195,200,205,210,215,220,225,230,233,238,242,247,251,256,261,266,271,276,281,286,291,296,301,305,308,313,318,323,328,333,338,341,346,351,356,361,366,371,376,381,386,391,396,400,403,408,413,418,423,428,433,438,441,445,450,455,460,465,470,474,479,484,489,494,498,503,508,511,516,521,526,531,536,541,546,551,556],{"id":11,"title":10,"titles":44,"content":45,"level":46},[],"Configure PIM Eligible Role Assignments on Azure subscriptions using the ARM API in PowerShell, including role policies, approvers, and eligible role creation. In this guide, we will delve into the intricacies of configuring Privileged Identity Management (PIM) Eligible Role Assignments on Azure subscriptions using the ARM API in PowerShell. As seasoned professionals, we recognize that leveraging PIM in Azure is a strategic imperative. However, as DevOps Engineers, we also acknowledge the challenges posed by incorporating Eligible role assignments into deployments. In this post I will expose all the intricacies concerning this piece of automation. Microsoft provides Microsoft Graph cmdlets for Entra ID PIM, but for Azure PIM Role Assignments you must use the Azure Resource Manager (ARM) API. Before we dive into the details, I want to give a shout-out to my colleague Bjorn Peters. He did the refactoring of the JSON-body into a PowerShell object so it's easier to manipulate the code. This provides with a cleaner looking script. Also check out his blog for interesting articles about Azure, DevOps and automation.",1,{"id":48,"title":49,"titles":50,"content":51,"level":52},"/blog/azure-privileged-identity-management-as-code#arm-api","ARM API",[10],"As mentioned, Microsoft provides us with the Azure Resource Manager (ARM) API for configuring PIM assignments. While this API offers unparalleled flexibility, deciphering the precise configuration details can sometimes be like unraveling a labyrinth. In this blogpost I will provide code snippets, making automation of PIM eligible roles in your environment a breeze. As mentioned before, we will do everything in PowerShell including the body. Hopefully this will provide you with more insights on how to use the code snippets in your automation.",2,{"id":54,"title":55,"titles":56,"content":57,"level":52},"/blog/azure-privileged-identity-management-as-code#the-requirements","The requirements",[10],"An IDE (such as Visual Studio Code)Proficiency in PowerShell\nAzure PowerShell moduleMicrosoft Graph moduleEntra ID Premium license for PIMoptional: Custom role definition",{"id":59,"title":60,"titles":61,"content":62,"level":52},"/blog/azure-privileged-identity-management-as-code#configuration-steps","Configuration steps",[10],"PIM configuration exists of two steps: Define Role Settings: These settings determine when role activation occurs. Think of them as your compass. Create Eligible Role Assignments: This step associates roles with users or groups, allowing temporary permission elevation using PIM.",{"id":64,"title":65,"titles":66,"content":67,"level":52},"/blog/azure-privileged-identity-management-as-code#custom-role-definitions","Custom Role Definitions",[10],"Scope Matters: When creating custom role definitions, consider the scope. If you intend to target multiple subscriptions, create the custom role definition at the highest possible level. Why? Because each role definition has its own unique GUID. If you create a new GUID for every subscription, your automation complexity increases significantly. Example Scenario: Imagine an enterprise aiming to restrict developer access. They define a custom role called Developer with GUID 3a9c9b30-bb02-43c2-a487-0d5aff050fec. Now, they want to assign this custom role via PIM to different security groups across various subscriptions. In this case, it's prudent to create the role definition at a higher level, ensuring consistent GUIDs for role assignments across different levels. Remember these principles as you want to use custom role definitions within PIM. It will streamline your development processes. Visualization of PIM a custom role definition:",{"id":69,"title":70,"titles":71,"content":72,"level":52},"/blog/azure-privileged-identity-management-as-code#tasks","Tasks",[10],"In this section we are going to execute the following tasks: #Task1.Connect to the environment2.Connect with MG Graph. Used for the creation of groups3.Create 2 new security groups. 1 for PIM requests, 1 for approval of requests4.Create a basic function to obtain headers. Used for making API calls5.Store recurring values in objects6.Update role policy with a custom role settings7.Assign the eligible role In the end we are also going to test our setup.",{"id":74,"title":75,"titles":76,"content":77,"level":52},"/blog/azure-privileged-identity-management-as-code#getting-started","Getting started",[10],"Connect with your Azure environment Connect-AzAccount to get started.Connect with MG Graph with at least Group read/write permissions: Connect-MgGraph -Scopes \"Group.ReadWrite.All\" Create a new Security Group that will be assigned the PIM Eligible role: $pimRequestorGroup = New-MgGroup -DisplayName 'pim-requestor-sg' -MailEnabled:$False  -MailNickName 'pim-test-sg' -SecurityEnabled\n$pimApproverGroup = New-MgGroup -DisplayName 'pim-approver-sg' -MailEnabled:$False  -MailNickName 'pim-test-sg' -SecurityEnabled Obtain headers. To be able to execute calls to the ARM API we need to obtain the correct headers. We will wrap this in a function for easier use throughout the codebase: Function Get-Headers {\n    Param (\n        [Parameter(Mandatory)]\n        [Array]$Context\n    )\n    $azProfile = [Microsoft.Azure.Commands.Common.Authentication.Abstractions.AzureRmProfileProvider]::Instance.Profile\n    $profileClient = New-Object -TypeName Microsoft.Azure.Commands.ResourceManager.Common.RMProfileClient -ArgumentList ($azProfile)\n    $token = $profileClient.AcquireAccessToken($Context.Subscription.TenantId)\n    $authHeader = @{\n        'Content-Type'  = 'application/json'\n        'Authorization' = 'Bearer ' + $token.AccessToken\n    }\n    return $authHeader\n} If we call the function and save the output in an object we can reuse the headers with every API call, like so: $headers = Get-Headers -Context (Get-AzContext) Store reusable values in objects to use later and switch to the correct context. Find the role definition IDs here. $subscription = Get-AzSubscription -SubscriptionId 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' # > replace this with your own subscription ID\n# Switch to the target subscription\nSet-AzContext -Subscription $subscription\n\n$eligibleAssignmentDetails = [PSCustomObject]@{\n    Id               = $pimRequestorGroup.Id # Here we enter the pimRequestorGroup ID set earlier\n    DisplayName      = $pimRequestorGroup.DisplayName # Here we enter the pimGroup DisplayName set earlier\n    EligibleRole     = 'Contributor'\n}\n\n$contributorRoleId = 'b24988ac-6180-42a0-ab88-20f7382dd24c' # This is the targeted role Update the role policy so it requires an approval on activation. # First, get the current role policy. We do this because you are only allowed to update role policies:\n$getRolePolicyUri = \"https://management.azure.com/providers/Microsoft.Subscription/subscriptions/{0}/providers/Microsoft.Authorization/roleManagementPolicies?api-version=2020-10-01&`$filter=roleDefinitionId%20eq%20'subscriptions/{0}/providers/Microsoft.Authorization/roleDefinitions/{1}'\" -f $subscription.Id, $contributorRoleId\n$rolePolicy = (Invoke-RestMethod -Uri $getRolePolicyUri -Method Get -Headers $headers).value Now that we obtained the role policy, we need to create a body to update the policy to our liking. For reference checkout the docs. The body in PowerShell gives us more flexibility if we want to loop through multiple roles/groups/approvers etc. Here goes: # Assemble body for this request\n$body = [PSCustomObject]@{\n    properties = [PSCustomObject]@{\n        rules = @(\n            # Enter the basics\n            [PSCustomObject]@{\n                isExpirationRequired = $true\n                maximumDuration      = 'PT8H' #Role can be activated for a maximum of 8 hours\n                id                   = 'Expiration_EndUser_Assignment'\n                ruleType             = 'RoleManagementPolicyExpirationRule'\n                target               = @{\n                    caller     = 'EndUser'\n                    operations = @(\n                        'All'\n                    )\n                }\n                level                = 'Assignment'\n            },\n            [PSCustomObject]@{\n                enabledRules = @(\n                    'Justification', # Requires the user to add a justification in their request\n                    'MultiFactorAuthentication' # Requires MFA authentication for the request\n                )\n                id           = 'Enablement_EndUser_Assignment'\n                ruleType     = 'RoleManagementPolicyEnablementRule'\n                target       = @{\n                    caller     = 'EndUser'\n                    operations = @(\n                        'All'\n                    )\n                    level      = 'Assignment'\n                }\n            },\n            [PSCustomObject]@{\n                isExpirationRequired = $false # Makes the role permanently eligible\n                maximumDuration      = 'P365D' # Maximum duration of eligible role assignment\n                id                   = 'Expiration_Admin_Eligibility'\n                ruleType             = 'RoleManagementPolicyExpirationRule'\n                target               = @{\n                    caller     = 'Admin'\n                    operations = @(\n                        'All'\n                    )\n                    level      = 'Eligibility'\n                }\n            },\n            # The next section adds Approvers to the body object. If you don't want/need approvers you can leave this part out of your script:\n            [PSCustomObject]@{\n                setting  = [PSCustomObject]@{\n                    isApprovalRequired               = $true # Makes approval required for the request on this role\n                    isApprovalRequiredForExtension   = $false\n                    isRequestorJustificationRequired = $true\n                    approvalMode                     = 'SingleStage'\n                    approvalStages                   = @(\n                        @{\n                            approvalStageTimeOutInDays      = 1\n                            isApproverJustificationRequired = $true\n                            escalationTimeInMinutes         = 0\n                            isEscalationEnabled             = $false\n                            primaryApprovers                = @(\n                                [PSCustomObject]@{\n                                    id          = $pimApproverGroup.Id # Reference to the Security Group that was created earlier\n                                    description = $null\n                                    isBackup    = $false\n                                    userType    = \"Group\"\n                                }\n                            )\n                        }\n                    )\n                }\n                id       = 'Approval_EndUser_Assignment'\n                ruleType = 'RoleManagementPolicyApprovalRule'\n                target   = @{\n                    caller     = 'EndUser'\n                    operations = @(\n                        'All'\n                    )\n                    level      = 'Assignment'\n                }\n            }\n        )\n    }\n} We have created the body, now it's time to update the role policy! First we construct the URI by inserting the ID we got from obtaining the role policy. Secondly we update the role policy by calling the API with a PATCH method. If all goes well the role policy should be updated. $patchRolePolicyUri = \"https://management.azure.com{0}?api-version=2020-10-01\" -f $rolePolicy.id\n$patchPolicyRequest = Invoke-RestMethod -Uri $patchRolePolicyUri -Method Patch -Headers $headers -Body ($body | ConvertTo-Json -Depth 10) Before: After: Assign the Eligible role to the pimRequestorGroup Security Group: # Create the Eligible role with a custom GUID\n# Create body\n$body = @{\n    Properties = @{\n        RoleDefinitionID = \"/subscriptions/$Subscription.Id/providers/Microsoft.Authorization/roleDefinitions/$contributorRoleId\"\n        PrincipalId      = $pimRequestorGroup.Id\n        RequestType      = 'AdminAssign'\n        ScheduleInfo     = @{\n            Expiration = @{\n                Type = 'NoExpiration'\n            }\n        }\n    }\n}\n$guid = [guid]::NewGuid()\n# Construct Uri with subscription Id and new GUID\n$createEligibleRoleUri = \"https://management.azure.com/providers/Microsoft.Subscription/subscriptions/{0}/providers/Microsoft.Authorization/roleEligibilityScheduleRequests/{1}?api-version=2020-10-01\" -f $Subscription.Id, $guid\n\n# Call the API with PUT to assign the role to the targeted Security Group\nInvoke-RestMethod -Uri $createEligibleRoleUri -Method Put -Headers $headers -Body ($body | ConvertTo-Json -Depth 10) Result:",{"id":79,"title":80,"titles":81,"content":82,"level":52},"/blog/azure-privileged-identity-management-as-code#testing","Testing!",[10],"All is now in place to test the setup. We followed the steps to create 2 security groups, update a role policy, assign the eligible role and finally we need to test this configuration:",{"id":84,"title":85,"titles":86,"content":87,"level":88},"/blog/azure-privileged-identity-management-as-code#group-membership","Group Membership",[10,80],"Add members to the created groups: Remember, users cannot approve their own requests. pimRequestorGroup: Users who can request activation of the Contributor role. pimApprovalGroup: Approvers responsible for granting or denying requests.",3,{"id":90,"title":91,"titles":92,"content":93,"level":88},"/blog/azure-privileged-identity-management-as-code#test-the-workflow","Test the workflow",[10,80],"Test the setup with the separate accounts:",{"id":95,"title":96,"titles":97,"content":98,"level":99},"/blog/azure-privileged-identity-management-as-code#requestor","Requestor",[10,80,91],"Make a PIM request: Fill in the justification and hit 'Submit':",4,{"id":101,"title":102,"titles":103,"content":104,"level":99},"/blog/azure-privileged-identity-management-as-code#approver","Approver",[10,80,91],"As an approver, start the approval of the PIM request by selecting the request and clicking 'Approve': Finally, check the request and if it meets requirements approve it with a justification:",{"id":106,"title":107,"titles":108,"content":109,"level":99},"/blog/azure-privileged-identity-management-as-code#validate","Validate",[10,80,91],"Validate if the requesting user has an active role assignment under 'My Roles' in Privileged Identity Management:",{"id":111,"title":112,"titles":113,"content":114,"level":52},"/blog/azure-privileged-identity-management-as-code#conclusion","Conclusion",[10],"That concludes this blog about configuring PIM via the ARM API with PowerShell. We have successfully: ✅ Created 2 new security groups. 1 for PIM requests, 1 for approval of requests✅ Wrote a basic function to obtain headers that we used for making API calls✅ Updated a role policy with custom role settings✅ Assigned the eligible role to our Security Group✅ Successfully tested the workflow 🔐 With PIM now in place, your organization gains precise control over permissions, enhancing security and compliance. Hopefully this post provided you with some guidance on how to automate PIM in your environment. Good luck and feel free to reach out if you have any questions! 🚀 html pre.shiki code .suiK_, html code.shiki .suiK_{--shiki-light:#005CC5;--shiki-default:#79B8FF;--shiki-dark:#79B8FF}html pre.shiki code .so5gQ, html code.shiki .so5gQ{--shiki-light:#D73A49;--shiki-default:#F97583;--shiki-dark:#F97583}html pre.shiki code .slsVL, html code.shiki .slsVL{--shiki-light:#24292E;--shiki-default:#E1E4E8;--shiki-dark:#E1E4E8}html pre.shiki code .sfrk1, html code.shiki .sfrk1{--shiki-light:#032F62;--shiki-default:#9ECBFF;--shiki-dark:#9ECBFF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .shcOC, html code.shiki .shcOC{--shiki-light:#6F42C1;--shiki-default:#B392F0;--shiki-dark:#B392F0}html pre.shiki code .sQHwn, html code.shiki .sQHwn{--shiki-light:#E36209;--shiki-default:#FFAB70;--shiki-dark:#FFAB70}html pre.shiki code .sCsY4, html code.shiki .sCsY4{--shiki-light:#6A737D;--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"id":15,"title":14,"titles":116,"content":117,"level":46},[],"I built a self-hosted web tool to filter, review, and bulk-delete stale branches across all repositories in an Azure DevOps project, because the portal was never designed for this.",{"id":119,"title":120,"titles":121,"content":122,"level":46},"/blog/branch-manager-azure-devops#introduction","Introduction",[],"Every team I have worked with has the same problem at some point. You open Azure DevOps, navigate to a repository, and there are 200 branches listed. Half of them are from features that shipped two years ago. A handful are from developers who left the company. A few have names like test-fix-final-v3 and nobody knows what they were for. Some commit message are cryptic as well. What would xyz or * mean? The Azure DevOps portal is excellent for many things. Branch cleanup is not one of them. You can delete branches one at a time from the repository view. A tedious process if you need vigorous cleaning. There is no easy way to filter branches by age across all repos in a project, select a batch, and remove them in one go. If you are managing more than a handful of repositories, the manual process gets old quickly. I kept meaning to write a script for it. I never quite did. Then I decided to build something slightly more permanent.",{"id":124,"title":125,"titles":126,"content":127,"level":52},"/blog/branch-manager-azure-devops#the-problem-with-branch-clutter","The Problem with Branch Clutter",[120],"Stale branches are not just an aesthetic issue. They create real noise. When a developer runs git branch -r or opens the branch selector in a PR, they are scrolling past dozens of dead ends. It slows down onboarding, because new team members cannot tell which branches are active and which are relics. It complicates repository hygiene at scale, especially when you have tens of repositories in a project. The other problem is safety. You do not want to bulk-delete branches without knowing what you are removing. Some branches have active pipelines. Some protect long-running release tracks. Any bulk cleanup tool needs to handle that clearly.",{"id":129,"title":130,"titles":131,"content":132,"level":52},"/blog/branch-manager-azure-devops#presenting","Presenting...",[120],"Branch Manager! And even in dark mode! Branch Manager is a self-hosted web application. You run it locally or host it on Azure App Service, point it at your Azure DevOps organization, sign in, and get a filterable table of every branch across all repositories in a project. From there you can: Filter by repository, branch name, and age so you can target feature/ branches older than 90 days, for exampleSort by last commit date or authorSee who last touched a branch and what the last commit message wasProtect branches automatically: any branch with an Azure DevOps policy attached to it is highlighted and locked from deletion, so you cannot accidentally remove a protected default branchAdd custom protection patterns, useful for protecting release/, hotfix/, or any prefix your team usesSelect and delete in bulk, with a confirmation dialog that shows you exactly what is about to go",{"id":134,"title":135,"titles":136,"content":137,"level":52},"/blog/branch-manager-azure-devops#authentication-two-modes","Authentication: Two Modes",[120],"Branch Manager supports two ways to authenticate against Azure DevOps. The first is a Personal Access Token. This is the quickest option if you are running it for yourself. No app registration needed. You enter your organization name and a PAT with Code.ReadWrite permissions, and you are in. The second is Microsoft Entra ID. This is the recommended option if you want to host Branch Manager for a team. You register a single-page application in Entra, grant it the user_impersonation permission on Azure DevOps, and your colleagues can sign in with their work account through the standard Microsoft login flow. This prevents the use of shared secrets and avoids using PATs altogether. Because everyone signs in with their own account you have an audit trail as well. One important note: Entra ID authentication for Azure DevOps requires a work or school account. Personal Microsoft accounts do not work here. That is a Microsoft restriction, not something Branch Manager can change.",{"id":139,"title":140,"titles":141,"content":142,"level":52},"/blog/branch-manager-azure-devops#how-it-was-built","How It Was Built",[120],"I must admin: this is a vibe coding project. The first version was a PowerShell script and although it worked -barely- it was 335 lines of something I did not want to maintain. So I let Github Copilot rebuilt it as a proper Node.js web app. The backend is Express. It proxies requests between the browser and the Azure DevOps REST API and handles authentication, rate limiting, and the branch lookup and delete operations. The frontend is plain HTML, CSS, and vanilla JavaScript without a framework. There is no build step and no bundler on the client side because I wanted a lightweight application. It is simple a new GUI for the DevOps API.",{"id":144,"title":145,"titles":146,"content":147,"level":88},"/blog/branch-manager-azure-devops#lessons-learned","Lessons learned",[120,140],"That sounds nice and all, but my git history tells a different story. Let me share my 6 biggest bumps in the road: I had to rewrite the Authentication part twice. The first attempt used MSAL Node on the server side, which meant managing the OAuth code flow server-side and dealing with session state. How it worked? I don't know because I yolo'd Copilot to do it. Soon I discovered iot worked in theory but added too much complexity for a personal tool. I scrapped it and started over with msal-browser, which acquires the Entra ID access token entirely in the browser using PKCE. The server never sees a client secret and never stores a token. Much simpler. And with examples!Azure DevOps does not return 401 errors when a token is rejected. It returns a 302 redirect to a sign-in page. That sounds like a minor detail but it completely changes how you detect auth failures. A normal response.ok check passes on a 302. You get back an HTML login page instead of JSON and the error surfaces somewhere downstream in a confusing way. I had to add explicit handling for all redirect status codes and map them to a useful error message.Helmet's Content Security Policy blocked MSAL's CDN. Helmet ships with a default CSP that locks down most external script sources. MSAL Browser loads from alcdn.msauth.net, makes token requests to login.microsoftonline.com, and needs those origins in scriptSrc and connectSrc respectively. None of those are in Helmet's defaults. Easy to fix once you understand what is happening, but the browser console errors were not immediately obvious about which policy rule was blocking what.Helmet's crossOriginOpenerPolicy breaks popup window communication. This one took longer. The default value same-origin prevents the opener page from reading the popup's location after it navigates. That is exactly the mechanism MSAL popup flow depends on. Setting it to same-origin-allow-popups fixed it, but it is not a setting you would think to check first.Tokens were appearing in request logs. The Express request logger I added for troubleshooting was faithfully printing every URL, including OAuth redirects that carry authorization codes and access tokens as query parameters. I added a sanitization step that redacts those parameters before logging. It is a small thing but it matters if logs end up in any kind of monitoring system.The Azure DevOps REST API surface for branches is fairly large. The refs endpoint, the commit details endpoint, the branch stats endpoint, and the batch delete operation all behave slightly differently and the documentation has some gaps. Copilot was genuinely useful here. It could reason about the response shapes and suggest the right request format for things like the batch delete, which expects an array of ref update objects with newObjectId set to forty zeros to signal deletion. That is not something I would have guessed, but Copilot brought me the answers.",{"id":149,"title":150,"titles":151,"content":152,"level":52},"/blog/branch-manager-azure-devops#getting-started","Getting Started",[120],"Alright, let's get to the interesting part: Installation! You need Node.js 18 or higher and (of course) an Azure DevOps organization. Clone the repo, install dependencies, and start the server: git clone https://github.com/jdgoeij/BranchManager.git\ncd BranchManager/server\nnpm install\nnpm start The app opens at http://localhost:8080. For PAT authentication you are ready to go immediately. Just generate a Code Read and Write PAT and use is. For Entra ID, follow the configuration steps in the README to register the app and add your credentials to server/.env.",{"id":154,"title":155,"titles":156,"content":157,"level":52},"/blog/branch-manager-azure-devops#hosting-it-for-your-team","Hosting It for Your Team",[120],"If you want to make Branch Manager available to your whole team, Azure App Service is the simplest option. The server/ folder is a self-contained Node.js app and deploys directly. The README covers three paths: Azure CLI for the fastest setup, the VS Code Azure App Service extension if you prefer a UI, and a GitHub Actions workflow if you want automated deployments on every push to main. Make sure you add your App Service URL as a redirect URI in your Entra app registration and set REDIRECT_URI as an environment variable on the App Service. Without this, the OAuth redirect after sign-in will not work. The README walks through exactly what to set.",{"id":159,"title":160,"titles":161,"content":162,"level":52},"/blog/branch-manager-azure-devops#what-is-next","What Is Next",[120],"A few things are on my list. The branch table currently loads one project at a time. I want to add a cross-project view so you can see stale branches across your entire organization in one pass. This is a larger API surface but the foundation is already there. I noticed there is a /api/all-branches endpoint on the server that does exactly this. I also want to add a CSV export. Sometimes the right action is not deletion but a review with the team first. Being able to export the filtered branch list with last commit info and committer makes that conversation easier. If you run into something that does not work or have a feature in mind, open an issue on GitHub. The codebase is straightforward enough that contributions are very welcome. Happy to answer questions. Find me on LinkedIn. html pre.shiki code .shcOC, html code.shiki .shcOC{--shiki-light:#6F42C1;--shiki-default:#B392F0;--shiki-dark:#B392F0}html pre.shiki code .sfrk1, html code.shiki .sfrk1{--shiki-light:#032F62;--shiki-default:#9ECBFF;--shiki-dark:#9ECBFF}html pre.shiki code .suiK_, html code.shiki .suiK_{--shiki-light:#005CC5;--shiki-default:#79B8FF;--shiki-dark:#79B8FF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"id":19,"title":18,"titles":164,"content":165,"level":46},[],"Implement secure workload autonomy using Azure conditional role assignments with Bicep and Azure Verified Modules. In modern cloud environments, finding the right balance between workload autonomy and security control is crucial. While development teams need extensive permissions to manage their resources effectively, security teams must ensure these privileges don't compromise the organization's security posture. Azure's conditional role assignments provide an elegant solution to this challenge, allowing us to grant broad permissions while maintaining strict security boundaries.",{"id":167,"title":168,"titles":169,"content":170,"level":52},"/blog/conditional-role-assignments#the-challenge","The Challenge",[18],"Traditional role-based access control (RBAC) often forces organizations to choose between two suboptimal approaches: Granting full Owner rights, risking security by allowing teams to escalate privilegesImplementing restrictive custom roles, creating operational overhead and potential bottlenecks Conditional role assignments offer a middle ground, enabling us to grant Owner permissions while preventing specific high-risk actions through conditions.",{"id":172,"title":173,"titles":174,"content":175,"level":52},"/blog/conditional-role-assignments#understanding-role-assignment-conditions","Understanding Role Assignment Conditions",[18],"Role assignment conditions in Azure add an extra layer of security by allowing us to specify when and how permissions can be used. These conditions are evaluated at runtime and can reference various attributes of the request context, including: The target resource's propertiesThe type of action being performedThe principal's claimsThe environment context The power of conditions lies in their ability to create fine-grained access controls without sacrificing operational efficiency.",{"id":177,"title":55,"titles":178,"content":179,"level":52},"/blog/conditional-role-assignments#the-requirements",[18],"Before implementing conditional role assignments, ensure you have: Access to Azure with permissions to manage role assignmentsUnderstanding of Azure RBAC and built-in rolesFamiliarity with Bicep or ARM templatesAzure CLI or Azure PowerShell installedBicep installed",{"id":181,"title":182,"titles":183,"content":184,"level":52},"/blog/conditional-role-assignments#implementation-strategy","Implementation Strategy",[18],"In this guide, we'll focus on implementing a secure workload autonomy pattern with the following objectives: Grant Owner permissions to workload teamsPrevent privilege escalation by blocking critical role assignmentsMaintain audit capabilitiesImplement the solution using Infrastructure as Code Let's dive into the technical implementation of these requirements.",{"id":186,"title":187,"titles":188,"content":189,"level":52},"/blog/conditional-role-assignments#role-assignment-configuration","Role Assignment Configuration",[18],"Here's the critical part: we want to grant Owner permissions and at the same time prevent the assignment of privileged roles. We'll achieve this by creating a role assignment with conditions that explicitly block assignments of the following roles: RoleRole Definition IDOwner8e3af657-a8ff-443c-a75c-2fe8c4bcb635User Access Administrator18d7d88d-d35e-4fb5-a5c3-7773c20a72d9Role Based Access Control Administratorf58310d9-a9f6-439a-9e8d-f62e7b41a168",{"id":191,"title":192,"titles":193,"content":194,"level":88},"/blog/conditional-role-assignments#condition-syntax","Condition Syntax",[18,187],"The condition checks whether the action the user performs is allowed. In this case, we prevent the user from creating (write) or deleting role assignments with the Role Definition IDs of the above roles. All other roles are allowed to be assigned. You can play around and add other roles as well, or simply turn it around to only allow roles you define. I use the triple quote to define a multi-line string ('''): condition: '''\n      ((!(ActionMatches{'Microsoft.Authorization/roleAssignments/write'})) OR\n      (@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAllValues:GuidNotEquals {8e3af657-a8ff-443c-a75c-2fe8c4bcb635, 18d7d88d-d35e-4fb5-a5c3-7773c20a72d9, f58310d9-a9f6-439a-9e8d-f62e7b41a168})) AND\n      ((!(ActionMatches{'Microsoft.Authorization/roleAssignments/delete'})) OR\n      (@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAllValues:GuidNotEquals {8e3af657-a8ff-443c-a75c-2fe8c4bcb635, 18d7d88d-d35e-4fb5-a5c3-7773c20a72d9, f58310d9-a9f6-439a-9e8d-f62e7b41a168}))\n      ''' This condition ensures that even with Owner permissions, the user cannot grant or delete these privileged roles to/from others.",{"id":196,"title":197,"titles":198,"content":199,"level":88},"/blog/conditional-role-assignments#bicep-implementation","Bicep Implementation",[18,187],"Here's how we implement this in Bicep: param principalId string = '00000000-0000-0000-0000-000000000000' // Replace with the actual principal ID\n\nresource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {\n  name: guid(subscription().id, principalId, 'owner-no-privesc')\n  properties: {\n    principalId: principalId\n    roleDefinitionId: '/subscriptions/${subscription().subscriptionId}/providers/Microsoft.Authorization/roleDefinitions/8e3af657-a8ff-443c-a75c-2fe8c4bcb635' // Owner\n    condition: '''\n      ((!(ActionMatches{'Microsoft.Authorization/roleAssignments/write'})) OR\n      (@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAllValues:GuidNotEquals {8e3af657-a8ff-443c-a75c-2fe8c4bcb635, 18d7d88d-d35e-4fb5-a5c3-7773c20a72d9, f58310d9-a9f6-439a-9e8d-f62e7b41a168})) AND\n      ((!(ActionMatches{'Microsoft.Authorization/roleAssignments/delete'})) OR\n      (@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAllValues:GuidNotEquals {8e3af657-a8ff-443c-a75c-2fe8c4bcb635, 18d7d88d-d35e-4fb5-a5c3-7773c20a72d9, f58310d9-a9f6-439a-9e8d-f62e7b41a168}))\n      '''\n    conditionVersion: '2.0'\n  }\n}",{"id":201,"title":202,"titles":203,"content":204,"level":52},"/blog/conditional-role-assignments#permissions-at-scale","Permissions at Scale",[18],"When managing many subscriptions, resource groups, or resources, manual RBAC assignments become unmanageable. You need a repeatable, auditable, and secure way to grant and manage access ideally with the ability to: Assign roles to users, groups, or managed identitiesApply conditions for least privilegeTrack and review assignments over time",{"id":206,"title":207,"titles":208,"content":209,"level":52},"/blog/conditional-role-assignments#enter-azure-verified-modules-avm","Enter Azure Verified Modules (AVM)",[18],"The AVM Role Assignment module lets you declaratively manage role assignments at any scope. Let's look at a practical example for assigning the Owner role at the subscription level, with a condition to prevent privilege escalation. The Bicep will look almost the same: targetScope = 'managementGroup'\n\nparam principalId string = ''\n\nparam location string = 'swedencentral'\n\nparam subscriptionId string = '00000000-0000-0000-0000-000000000000' // Default subscription ID\n\nmodule roleAssignment 'br/public:avm/ptn/authorization/role-assignment:0.2.2' = {\n  name: 'roleAssignmentDeployment'\n  params: {\n    // Required parameters\n    principalId: principalId\n    roleDefinitionIdOrName: 'Reader'\n    // Non-required parameters\n    description: 'Role Assignment (subscription scope)'\n    location: location\n    subscriptionId: subscriptionId\n  }\n}",{"id":211,"title":212,"titles":213,"content":214,"level":52},"/blog/conditional-role-assignments#using-the-module-for-scaled-operations","Using the module for scaled operations",[18],"With the module, you can easily assign roles with conditions to multiple principals or scopes using a for loop in Bicep. This approach reduces duplication and minimizes the risk of errors. For example, to assign the Owner role with the privilege escalation prevention condition to several user or group IDs you can pass an array of principals to process. The array will reside in a separate .bicepparam file. targetScope = 'managementGroup'\n\nparam ownerPrincipals array = []\n\nmodule OwnerRoleAssignments 'br/public:avm/ptn/authorization/role-assignment:0.2.2' = [\n  for principal in ownerPrincipals: {\n    name: guid(principal.id, 'owner-no-privesc')\n    params: {\n      principalId: principal.id\n      roleDefinitionIdOrName: 'Owner'\n      condition: principal.condition ?? ''\n      conditionVersion: '2.0'\n      subscriptionId: principal.subscriptionId\n    }\n  }\n] And the .bicepparam file will have the configuration for each principal you want to assign the permissions to. Remember you can always change the other parameters to include them in the bicepparam, like the assigned role etc. The way you can maximize your scaled operations! using 'main.bicep'\n\nparam ownerPrincipals = [\n  {\n    id: '00000000-0000-0000-0000-000000000002'\n    subscriptionId: '00000000-0000-0000-0000-000000000002'\n    condition: '''\n            ((!(ActionMatches{'Microsoft.Authorization/roleAssignments/write'})) OR\n            (@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAllValues:GuidNotEquals {8e3af657-a8ff-443c-a75c-2fe8c4bcb635, 18d7d88d-d35e-4fb5-a5c3-7773c20a72d9, f58310d9-a9f6-439a-9e8d-f62e7b41a168})) AND\n            ((!(ActionMatches{'Microsoft.Authorization/roleAssignments/delete'})) OR\n            (@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAllValues:GuidNotEquals {8e3af657-a8ff-443c-a75c-2fe8c4bcb635, 18d7d88d-d35e-4fb5-a5c3-7773c20a72d9, f58310d9-a9f6-439a-9e8d-f62e7b41a168}))\n        '''\n  }\n] This pattern ensures each principal receives the correct assignment, and you only need to update the ownerPrincipals array to manage access at scale.",{"id":216,"title":217,"titles":218,"content":219,"level":52},"/blog/conditional-role-assignments#testing-and-verification","Testing and verification",[18],"I deployed the AVM Bicep module with: az deployment mg create -m 'MyManagementGroupName' --location westeurope --parameters .\\parameters.bicepparam Hint: you should use a pipeline for that, but that's not part of this blog. In the portal, I went to my subscription and checked the role assignments: After clicking View/Edit I saw there was a configuration: Then I checked the conditions:",{"id":221,"title":222,"titles":223,"content":224,"level":52},"/blog/conditional-role-assignments#benefits-and-considerations","Benefits and Considerations",[18],"This approach offers several advantages: Operational Efficiency: Teams are empowered to manage their own resources independently, reducing the need to request additional permissions from administrators.Enhanced Security: Sensitive role assignments are protected, minimizing the risk of privilege escalation and unauthorized access.Simplified Management: There's no longer a need to create and maintain complex custom roles, streamlining access control.Scalable Solution: This approach can be easily implemented across many subscriptions, making it suitable for organizations of any size. However, keep in mind that conditions add complexity to role assignments. Testing is crucial to ensure conditions work as expected. And always keep monitoring and auditing!",{"id":226,"title":227,"titles":228,"content":229,"level":52},"/blog/conditional-role-assignments#compliance","Compliance",[18],"So next time you need to report which privileged roles are assigned, you could simply use your codebase as proof! This multi-layered approach ensures both security and operational efficiency. As always, leave a comment on LinkedIn if you have any questions. Happy coding! ☕ html pre.shiki code .slsVL, html code.shiki .slsVL{--shiki-light:#24292E;--shiki-default:#E1E4E8;--shiki-dark:#E1E4E8}html pre.shiki code .sfrk1, html code.shiki .sfrk1{--shiki-light:#032F62;--shiki-default:#9ECBFF;--shiki-dark:#9ECBFF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .so5gQ, html code.shiki .so5gQ{--shiki-light:#D73A49;--shiki-default:#F97583;--shiki-dark:#F97583}html pre.shiki code .sCsY4, html code.shiki .sCsY4{--shiki-light:#6A737D;--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .shcOC, html code.shiki .shcOC{--shiki-light:#6F42C1;--shiki-default:#B392F0;--shiki-dark:#B392F0}html pre.shiki code .suiK_, html code.shiki .suiK_{--shiki-light:#005CC5;--shiki-default:#79B8FF;--shiki-dark:#79B8FF}",{"id":23,"title":22,"titles":231,"content":232,"level":46},[],"Automate banned password list management across multiple Entra ID tenants using Microsoft Graph API, PowerShell and Azure DevOps. In today's complex cloud environments, managing password security across multiple tenants is a critical challenge for IT administrators. Microsoft Entra ID provides powerful mechanisms to implement centralized password policies, but effective implementation requires careful planning and robust automation.",{"id":234,"title":235,"titles":236,"content":237,"level":52},"/blog/entraid-banned-password-list#graph-api","Graph API",[22],"Microsoft Graph API revolutionizes Entra ID tenant management by providing a powerful automation framework that simplifies complex multi-tenant configurations. With this API, you can effortlessly streamline identity and security management through comprehensive bulk operations, including: Automated user creationEfficient group managementPrivileged Identity Management (PIM) role assignmentsGranular password policy enforcement The API's finely tuned permission model lets you stay in control with precision, ensuring every automation script aligns with the principle of least privilege. This not only keeps your operations smooth but also fortifies your identity infrastructure's security. By embracing Microsoft Graph API, you can shift from time-consuming manual processes to efficient, repeatable workflows that boost consistency and cut down on administrative overhead. Sounds like a win, right? Let's explore how to make it happen.",{"id":239,"title":55,"titles":240,"content":241,"level":52},"/blog/entraid-banned-password-list#the-requirements",[22],"Before diving into the implementation, ensure you have: An IDE (such as Visual Studio Code)Entra ID Premium licenseAzure DevOps access with sufficient permissionsService connection(s) that have the Authentication Policy Administrator permissionProficiency in PowerShell\nAzure PowerShell moduleMicrosoft Graph module With these prerequisites ready, you're all set to start building automated solutions that simplify your tenant management.",{"id":243,"title":244,"titles":245,"content":246,"level":52},"/blog/entraid-banned-password-list#scope","Scope",[22],"Today we will focus on the following subjects: Centralizing banned password managementSupporting multi-tenant password restrictionsAutomating policy deployment through Azure DevOps To prevent the blog from becoming too long, a few topics are out-of-scope: Multi-tenant deployment strategySetting up Service Connections in Azure DevOpsCreating branch policies in Azure DevOpsVarious testing/error handling",{"id":248,"title":60,"titles":249,"content":250,"level":52},"/blog/entraid-banned-password-list#configuration-steps",[22],"Before we dive into the code, I will summarize the order of what we will do: Create/prepare the required payload (body) files for the APIDraft a PowerShell script to update the settings in Entra IDCreate a YAML-pipeline for Azure DevOps that runs automatically In the end you will have a basic automated way to update Banned Password Lists in multiple Entra ID tenants!",{"id":252,"title":253,"titles":254,"content":255,"level":52},"/blog/entraid-banned-password-list#code-implementation","Code Implementation",[22],"The code for this blog is hosted on my public Github repository.",{"id":257,"title":258,"titles":259,"content":260,"level":88},"/blog/entraid-banned-password-list#folder-structure","Folder structure",[22,253],"To be able to create multi-tenant deployments, we are going to parameterize the banned password list settings per tenant. Therefore, we need a consistent folder structure to support this type of deployment. bannedPasswords\n├── code\n│   ├── Set-PasswordSettings.ps1\n├── parameters\n│   ├── passwordSettings.json\n│   ├── bannedPasswords-tenantA.json\n│   └── bannedPasswords-tenantB.json\n├── pipelines\n│   ├── set-password-settings.yaml",{"id":262,"title":263,"titles":264,"content":265,"level":88},"/blog/entraid-banned-password-list#uri-background","Uri background",[22,253],"To set the banned password list, we need to update the Entra ID setting 'Password Rule Settings'. This one is currently in beta only, so as always be aware things may change in the future. See the docs for more information. The URI is: URI https://graph.microsoft.com/beta/settings Getting the settings returns the following setting types: id          : xxxxxxxx-d947-4d19-a028-xxxxxxxxxxxx\ndisplayName : Group.Unified\ntemplateId  : 62375ab9-6b52-47ed-826b-58e47e0e304b\nvalues      : {…}\n\nid          : xxxxxxxx-7701-4e25-8c81-xxxxxxxxxxxx\ndisplayName : Password Rule Settings\ntemplateId  : 5cf42378-d67d-4f36-ba46-e8b86229381d\nvalues      : {…} The API URI targets 'settings', which manages multiple Entra ID settings. Therefore, we target 'Password Rule Settings' to validate we use the correct template ID to use in our URI. This will become clear in the PowerShell code snippet below.",{"id":267,"title":268,"titles":269,"content":270,"level":88},"/blog/entraid-banned-password-list#createprepare-the-required-payload-body-files-for-the-api","Create/prepare the required payload (body) files for the API",[22,253],"The Password Rule Settings expects a JSON-file body (passwordSettings.json) that contains the settings to update. The file has the following structure: {\n  \"templateId\": \"5cf42378-d67d-4f36-ba46-e8b86229381d\",\n  \"values\": [\n    {\n      \"name\": \"BannedPasswordCheckOnPremisesMode\",\n      \"value\": \"Enforce\"\n    },\n    {\n      \"name\": \"EnableBannedPasswordCheckOnPremises\",\n      \"value\": \"True\"\n    },\n    {\n      \"name\": \"EnableBannedPasswordCheck\",\n      \"value\": \"True\"\n    },\n    {\n      \"name\": \"LockoutDurationInSeconds\",\n      \"value\": \"60\"\n    },\n    {\n      \"name\": \"LockoutThreshold\",\n      \"value\": \"10\"\n    },\n    {\n      \"name\": \"BannedPasswordList\",\n      \"value\": \"placeholder\"\n    }\n  ]\n} You may be thinking: why don't I add all the banned passwords in this file? A valid point, however since we want to support multiple tenants with the ability to differentiate, I chose to use separate files containing the banned passwords for each tenant.",{"id":272,"title":273,"titles":274,"content":275,"level":99},"/blog/entraid-banned-password-list#banned-passwords","Banned passwords",[22,253,268],"Each tenant will have a JSON-file containing the list of banned passwords. The file will be merged with the main parameter file and the merged file will be added as body to the request. bannedPasswords-tenantA.json: [\n  \"secret\",\n  \"123456\",\n  \"password\",\n  \"qwerty123\",\n  \"qwerty1\",\n  \"123456789\",\n  \"password1\",\n  \"12345678\",\n  \"12345\",\n  \"abc123\",\n  \"qwerty\",\n  \"iloveyou\",\n  \"Password\",\n  \"baseball\",\n  \"1234567\",\n  \"111111\",\n  \"princess\",\n  \"football\",\n  \"monkey\",\n  \"sunshine\"\n]",{"id":277,"title":278,"titles":279,"content":280,"level":99},"/blog/entraid-banned-password-list#quirky-body","Quirky body",[22,253,268],"What do I mean with 'quirky' body? The fact that the property BannedPasswordList expects tab-separated values instead of comma-separated values. This will need to be taken into account in the PowerShell script.",{"id":282,"title":283,"titles":284,"content":285,"level":88},"/blog/entraid-banned-password-list#powershell-script","PowerShell script",[22,253],"I will break down the PowerShell script in the following steps: Create a function to execute API operations on the 'settings' endpoint using an access tokenCombine the banned password list with the parameter fileUpdate the settings in Entra ID Let's initialize the script: [CmdLetBinding()]\nParam (\n    [Parameter(Mandatory,\n        HelpMessage = \"Enter the path of the parameter folder of authentication methods setting.\")]\n    [String]$ParameterFolderPath,\n    [Parameter(Mandatory,\n        HelpMessage = \"Enter the file path of the banned password list.\")]\n    [String]$TenantBannedPasswordsFilePath\n) Now we declare the function to update the Entra ID settings via the beta endpoint: function Set-EntraIdSetting {\n    param (\n        [Parameter(Mandatory,\n            HelpMessage = \"Provide the name of the settings to create/update.\")]\n        [Object]$TargetSettingName,\n        [Parameter(Mandatory,\n            HelpMessage = \"Provide the file path of the settings to create/update.\")]\n        [Object]$SettingFilePath\n    )\n    # Get the access token for the Microsoft Graph API\n    $settingsUri = \"https://graph.microsoft.com/beta/settings\"\n\n    Write-Output \"##[command]Get access token for the Microsoft Graph API\"\n    $accessToken = (Get-AzAccessToken -ResourceTypeName MSGraph -AsSecureString).Token\n    # set the params needed for the REST API requests\n    $params = @{\n        Method         = 'Get'\n        Uri            = $settingsUri\n        Authentication = 'Bearer'\n        Token          = $accessToken\n        ContentType    = 'application/json'\n    }\n    # Wrap the request in a try catch to ensure stopping errors\n    try {\n        $request = (Invoke-RestMethod @params).value\n    }\n    catch {\n        Throw $_\n    }\n    # Check if the request variable has a value\n    if ($request) {\n        Write-Output \"##[command]Found settings. Checking for setting '$TargetSettingName'\"\n        $targetSettingObject = $request | Where-Object { $_.displayName -eq $TargetSettingName }\n    }\n    # Continue checking if we have targeted the correct settings, and update the params accordingly\n    if ($targetSettingObject) {\n        Write-Output \"##[command]Found existing $TargetSettingName. Updating setting according to provided config.\"\n        $passwordSettingsUri = $settingsUri + '/' + $targetSettingObject.id\n        $params.Uri = $passwordSettingsUri\n        $params.Method = 'Patch'\n        $body = Get-Content -Path $SettingFilePath | ConvertFrom-Json -Depth 10\n        $body.PSObject.properties.remove('templateId')\n        $jsonBody = $body | ConvertTo-Json -Depth 10\n        try {\n            $settingRequest = Invoke-RestMethod @params -Body $jsonBody\n        }\n        catch {\n            throw $_\n        }\n    }\n    # Check if the setting does not exist. If this is the case we just post the entire template.\n    elseif (!$targetSettingObject) {\n        Write-Output \"##[command]No existing '$TargetSettingName'. Creating new '$TargetSettingName' according to provided config.\"\n        $jsonBody = Get-Content -Path $SettingFilePath\n\n        $params.Method = 'Post'\n\n        try {\n            $settingRequest = Invoke-RestMethod @params -Body $jsonBody\n        }\n        catch {\n            throw $_\n        }\n    }\n    return $settingRequest\n} Now we update the banned password list values of the parameter file: Write-Output \"##[command]Updating banned password list\"\n$bannedPasswords = Get-Content -Path $TenantBannedPasswordsFilePath | ConvertFrom-Json\n$bannedPasswordsList = $null\n$tab = [char]9\n\nWrite-Output \"##[command]Looping the banned password list and adding tabs needed for the REST API call.\"\nforeach ($bannedPassword in $bannedPasswords) {\n    $bannedPasswordsList += $bannedPassword + $tab\n}\n\nWrite-Output \"##[command]Trimming the banned password list to exclude the last tab.\"\n$trimmedPasswordList = $bannedPasswordsList -replace \".{1}$\"\n$bannedPasswordsSetting = Get-Content -Path \"$ParameterFolderPath\\passwordSettings.json\" | ConvertFrom-Json -Depth 5 -AsHashtable\n    ($bannedPasswordsSetting.values | Where-Object { $_.name -eq 'BannedPasswordList' }).value = $trimmedPasswordList\n$bannedPasswordsSetting | ConvertTo-Json -Depth 5 | Out-File \"$ParameterFolderPath\\updatedPasswordSettings.json\" After this we have successfully created a new JSON-file containing the entire configuration (parameters). This file will be used as body in the next step, where we present it to our previously written function: try {\n    Set-EntraIdSetting -TargetSettingName 'Password Rule Settings' -SettingFilePath \"$ParameterFolderPath\\updatedPasswordSettings.json\"\n    Write-Output \"Settings updated successfully!\"\n}\ncatch {\n    throw\n}",{"id":287,"title":288,"titles":289,"content":290,"level":88},"/blog/entraid-banned-password-list#azure-devops-yaml-pipeline","Azure DevOps YAML Pipeline",[22,253],"The pipeline is where the magic sauce comes into play. Make sure you have service connections setup to the various tenants you wish to manage. For each tenant you can add a separate stage, as per the example below. Because this is a small configuration change, the pipeline is not that complicated to set up. First let's add the correct trigger: trigger:\n  branches:\n    include:\n      - main\n  paths:\n    include:\n      - bannedPasswords/parameters Triggering on the branch and path makes sure the CI/CD runs as expected. You can add more checks before the first deployment stage, or better: add a test tenant stage to validate your configuration before pushing to production. Next we configure the pool, variables and stages of the pipeline. That's all it needs to deploy on every commit to main in the designated feature parameter folder. pool:\n  vmImage: ubuntu-latest\n\nvariables:\n  - name: ParameterFolderPath\n    value: bannedPasswords/parameters\n\nstages:\n  - stage: TenantA\n    jobs:\n      - job: TenantA\n        displayName: Updating Password Settings in Tenant A\n        steps:\n          - task: AzurePowerShell@5\n            displayName: Setting the configuration\n            inputs:\n              azureSubscription: \"TenantA-AuthenticationMethods-SPN\"\n              ScriptType: \"FilePath\"\n              ScriptPath: \"$(System.DefaultWorkingDirectory)/bannedPasswords/code/Set-PasswordSettings.ps1\"\n              ScriptArguments:\n                -ParameterFolderPath \"$(System.DefaultWorkingDirectory)/$(ParameterFolderPath)\"\n                -TenantBannedPasswordsFilePath \"$(System.DefaultWorkingDirectory)/$(ParameterFolderPath)/bannedPasswords-TenantA.json\"\n              azurePowerShellVersion: LatestVersion Adding another tenant is as easy as copy-pasting the previous stage and changing the parameters: - stage: TenantB\n  jobs:\n    - job: TenantB\n      displayName: Updating Password Settings in Tenant B\n      steps:\n        - task: AzurePowerShell@5\n          displayName: Setting the configuration\n          inputs:\n            azureSubscription: \"TenantB-AuthenticationMethods-SPN\"\n            ScriptType: \"FilePath\"\n            ScriptPath: \"$(System.DefaultWorkingDirectory)/bannedPasswords/code/Set-PasswordSettings.ps1\"\n            ScriptArguments:\n              -ParameterFolderPath \"$(System.DefaultWorkingDirectory)/$(ParameterFolderPath)\"\n              -TenantBannedPasswordsFilePath \"$(System.DefaultWorkingDirectory)/$(ParameterFolderPath)/bannedPasswords-TenantB.json\"\n            azurePowerShellVersion: LatestVersion",{"id":292,"title":293,"titles":294,"content":295,"level":88},"/blog/entraid-banned-password-list#running-the-pipeline","Running the pipeline",[22,253],"Let's see what my pipeline does when I run it… Success!",{"id":297,"title":298,"titles":299,"content":300,"level":52},"/blog/entraid-banned-password-list#potential-pitfalls-and-best-practices","Potential Pitfalls and Best Practices",[22],"This setup makes you versatile in configuring banned passwords for your environment(s). As always, stay aware of the pitfalls: Ensure password policies align with specific tenant compliance requirementsAlways implement a 4-eyes principle approval workflow in your automationTest thoroughly in staged environmentsRegularly review banned password lists and update accordinglyImplement comprehensive loggingUse the principle of least privilege for your automation accounts",{"id":302,"title":112,"titles":303,"content":304,"level":52},"/blog/entraid-banned-password-list#conclusion",[22],"That concludes this blog about configuring banned password lists via the Graph API with PowerShell. We have successfully: ✅ Created a folder structure for our files✅ Wrote an intermediate PowerShell script, including a function, to configure the Entra ID settings✅ Added a YAML-pipeline to automatically deploy the code to Entra ID Hopefully this provides you with a jump start into managing Entra ID(s) in an automated fashion. You can easily expand on this automation by adding new configuration of Entra ID like Conditional Access Policies or even PIM Eligible Role Assignments! As always, leave a comment on LinkedIn if you have any more questions. Happy coding! ☕ html pre.shiki code .slsVL, html code.shiki .slsVL{--shiki-light:#24292E;--shiki-default:#E1E4E8;--shiki-dark:#E1E4E8}html pre.shiki code .suiK_, html code.shiki .suiK_{--shiki-light:#005CC5;--shiki-default:#79B8FF;--shiki-dark:#79B8FF}html pre.shiki code .sfrk1, html code.shiki .sfrk1{--shiki-light:#032F62;--shiki-default:#9ECBFF;--shiki-dark:#9ECBFF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .so5gQ, html code.shiki .so5gQ{--shiki-light:#D73A49;--shiki-default:#F97583;--shiki-dark:#F97583}html pre.shiki code .sQHwn, html code.shiki .sQHwn{--shiki-light:#E36209;--shiki-default:#FFAB70;--shiki-dark:#FFAB70}html pre.shiki code .shcOC, html code.shiki .shcOC{--shiki-light:#6F42C1;--shiki-default:#B392F0;--shiki-dark:#B392F0}html pre.shiki code .sCsY4, html code.shiki .sCsY4{--shiki-light:#6A737D;--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sByVh, html code.shiki .sByVh{--shiki-light:#22863A;--shiki-default:#85E89D;--shiki-dark:#85E89D}",{"id":27,"title":26,"titles":306,"content":307,"level":46},[],"How switching from Hugo to Nuxt opened the door to vibe coding with GenAI and why a mature framework makes all the difference when you want to build, explore, and experiment fast. Last year I was running this blog on Hugo. It was fine. Hugo is fast, reliable, and battle-tested. I have nothing bad to say about it. But over time, I kept running into a wall. I wanted to be able to spice things up a bit with theming, animations/transitions and other features I didn't know I wanted (looking at you RSS feed). I found myself fighting the framework rather than building with it because of this. Over the last year I tried the vibe code my new blog on various occasions. The last time was October 2025. Although it brought me further, I still was correcting a lot of output. But then Opus 4.6 (and now Sonnet 4.6) hit the market. What a difference, everything changed. I wanted to try it again. With this website as a result.",{"id":309,"title":310,"titles":311,"content":312,"level":52},"/blog/from-hugo-to-nuxt-vibe-coding#what-vibe-coding-actually-means-to-me","What \"Vibe Coding\" Actually Means to Me",[26],"I want to be clear about what I mean by vibe coding, because it gets thrown around a lot. For me, it is not about blindly pasting AI output and hoping for the best. It is about having a fast, creative back-and-forth with a model where I describe what I want, in plain language or by pointing at code, and the model helps me realize it. I stay in control. I understand what lands in the codebase. But the friction between \"idea\" and \"working thing\" drops dramatically. For that to work well, I wanted a framework that has a lot of online presence and the model knows deeply. Hugo is a niche static site generator. It has its own templating language, its own directory conventions, its own quirks. When I asked a model to help me extend something in Hugo, I spent a lot of time correcting misunderstandings. The model knew Go templates and Hugo's data pipeline at a surface level, at best. Vue and Nuxt? The models know those inside out. Every pattern, every composable, every Tailwind class. The conversation just flows a lot better.",{"id":314,"title":315,"titles":316,"content":317,"level":52},"/blog/from-hugo-to-nuxt-vibe-coding#why-nuxt-specifically","Why Nuxt Specifically",[26],"I considered a few options. Next.js was an obvious candidate since React is everywhere and models are very strong with it. But I have always preferred Vue's approach to component design. The single-file component format, the reactivity model, the way templates stay readable. It suits how I think. Nuxt builds on Vue and fills in everything you need for a real content site: file-based routing, server routes, auto-imports, a content layer built around Markdown. It is not a toy framework. Companies ship production applications with it. That maturity matters, because it means the patterns I learn and the things I build are not throwaway experiments. They are transferable. The Nuxt Content module in particular was the deciding factor. My posts are Markdown files, and they always will be. Nuxt Content treats them as a first-class data source. I can query posts, filter by tag, sort by date, and render MDC components inside Markdown, all without reaching for a CMS or a third-party API.",{"id":319,"title":320,"titles":321,"content":322,"level":52},"/blog/from-hugo-to-nuxt-vibe-coding#the-migration","The Migration",[26],"Migrating the actual content was straightforward. Hugo and Nuxt both expect Markdown with YAML frontmatter, so my posts moved over without changes beyond a few field name adjustments. The real work was building the site itself: the layout, navigation, search, tag pages, and RSS feed. And this is exactly where vibe coding paid off. In Hugo it would cost me a lot more time. In Nuxt, I described what I wanted, iterated in short loops with AI assistance, and had something I was proud of within a weekend. Not every suggestion landed perfectly. There were moments where I needed to read the Nuxt docs or dig into how a composable actually worked. But that is a healthy part of the process. I understand this codebase. I just built it faster than I ever could have on my own. It's true what they say: understanding a language is easier than speaking it. You could say the same about programming languages. I understand variables, arrays, loops, if/else statements. But in every languague you have to get to know the syntax properly before you can start flying. With my Copilot, I found this part to be particularly fast-tracked.",{"id":324,"title":325,"titles":326,"content":327,"level":52},"/blog/from-hugo-to-nuxt-vibe-coding#what-changes-when-you-use-a-mature-framework","What Changes When You Use a Mature Framework",[26],"There is an underappreciated advantage to using a framework with a large ecosystem: the guard rails are already built. Nuxt handles code splitting, hydration, SEO meta, image optimization, and TypeScript out of the box or with a single module install. I do not have to invent solutions to problems that have already been solved a hundred times. This matters even more when working with GenAI. When I ask for help with something in Nuxt, the model can suggest an idiomatic solution, one that fits the framework's conventions. In a niche tool, the model improvises. In Nuxt, it suggests useAsyncData, definePageMeta, a server/routes/ file. Things that actually exist and work the way they are supposed to. The result is that my blog is now more capable than it ever was on Hugo. It has live search across all post content, tag filtering, a proper RSS feed, dark and light mode, and responsive design. The code is clean enough that I can keep extending it with confidence. Now the only thing that is missing is... Content.",{"id":329,"title":330,"titles":331,"content":332,"level":52},"/blog/from-hugo-to-nuxt-vibe-coding#exploring-genai-as-a-daily-tool","Exploring GenAI as a Daily Tool",[26],"I want to be honest: I am a Cloud Architect by trade, not a frontend developer. JavaScript frameworks are not my primary home. What surprised me most about this project is how much I learned by doing it this way. When the model explained why a particular reactive pattern works in Vue, or suggested a server route instead of a client-side fetch, I paid attention. I looked things up. I built a working mental model. GenAI is at its best when it accelerates genuine learning rather than bypassing it. If I had just accepted every code block without reading it, I would have a site I could not maintain. Instead I have a site I understand well enough to keep improving, and a framework I am now genuinely comfortable with. That feels like the right way to use these tools. My approach was simple: Start anew with an empty Git repo.Don't let AI build you scaffold: build it yourself following official documentation.When I had the starter website working and running, I commit this code. This is now my baseline.From here on out I started iterating:\nFirst I set the theme colors. Is it to my liking? Commit!Then I started working on the various pages. Commit!Menu bar. Commit!etc.",{"id":334,"title":335,"titles":336,"content":337,"level":52},"/blog/from-hugo-to-nuxt-vibe-coding#whats-next","What's Next",[26],"Now that the foundation is solid, I want to keep pushing on what a personal tech blog can be. A few things I am thinking about: Reading progress indicator on long postsRelated posts suggestions based on tag overlapNewsletter signup without a third-party service, handled by a Nuxt server routeAutomated post metadata, meaning generating descriptions and reading time during build All of these are things I would not have touched on Hugo (although provided out of the box) In Nuxt, with good tooling and GenAI on my side, they feel totally within control. If you are sitting on a static site generator that is starting to feel limiting, I would encourage you to take a serious look at Nuxt. The migration effort is real but manageable, and what you get on the other side is a full-stack web framework backed by a huge ecosystem, paired with the most capable AI coding tools we have ever had. That is genuinely exciting. Happy to answer questions. Find me on LinkedIn.",{"id":31,"title":30,"titles":339,"content":340,"level":46},[],"A complete guide to setting up Ubuntu 24.04 LTS with Intune, including the Intune Portal, Microsoft Edge, development tools, and more. In this guide, I'll walk you through setting up Ubuntu 24.04 LTS with Intune. As a Cloud Architect at Rubicon B.V., I've been testing whether Ubuntu provides the Edge (pun intended) I need to fulfill my work activities. Specifically, I'll cover how to install the Intune Portal as well as the software I used for my Ubuntu 24.04 installation. You will find instructions for every installation below. I hope this helps you if you have any issues enrolling Ubuntu 24.04 with Intune.",{"id":342,"title":343,"titles":344,"content":345,"level":52},"/blog/intune-ubuntu-24-04#steps","Steps",[30],"Here's a list of things that we're going through in this post: #SoftwarePurposeInstallation1.Microsoft EdgeCompany device managementapt2.Intune PortalCompany device managementapt3.Microsoft 365, including Teams and OutlookOffice activitiesPWA4.Draw.ioCreating designsSnap5.VS CodeDevelopmentSnap6.PowerShellDevelopmentSnap7.KeepassXCPassword managementSnap8.Azure CLIDevelopmentapt9.BicepDevelopmentbinary10.DisplayLinkMulti-monitor supportapt",{"id":347,"title":348,"titles":349,"content":350,"level":52},"/blog/intune-ubuntu-24-04#software-on-beta-branch-of-ubuntu-2404-lts","Software on Beta branch of Ubuntu 24.04 LTS",[30],"When I started going down this road, Ubuntu 24.04 was still in beta. Installing software on a pre-release version of Ubuntu can be challenging. Typically, I prefer to keep packages as close to the source as possible. This means either installing from the official repository using apt, or adding the developer's repository and then installing with apt. Initially, I hesitated about using Snap packages due to concerns about their larger size and potential performance impact compared to APT packages. However, when dealing with a beta version of Ubuntu 24.04 LTS, options become limited. Lack of up-to-date documentation and repositories often leads to tinkering with apt sources and keyrings. This process involves navigating dependencies and version pinning, which can be error-prone. By opting for Snap, I streamlined the installation process, making it more straightforward and reliable. Update 25-05-2024: Ubuntu 24.04 LTS was officially released. Still, taking into account software release cycles it is expected many applications have not yet found their way into the 24.04 repositories.",{"id":352,"title":353,"titles":354,"content":355,"level":52},"/blog/intune-ubuntu-24-04#manual-installation-of-the-intune-portal","Manual installation of the Intune Portal",[30],"The Intune portal is provided (and officially supported) for Ubuntu 22.04. By adding backport repositories it is possible to install it on 24.04 without compatibility issues. Follow the steps below to install the Intune Portal application. Edit /etc/apt/sources.list.d/ubuntu.sources and: Make sure you have both noble sources and mantic sourcesAdd an entry for mantic-security as well Types: deb\nURIs: http://nl.archive.ubuntu.com/ubuntu/\nSuites: mantic\nComponents: main restricted universe multiverse\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n\nTypes: deb\nURIs: http://security.ubuntu.com/ubuntu/\nSuites: mantic-security\nComponents: main restricted universe multiverse\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg The file /etc/apt/sources.list.d/ubuntu.sources should look like the code block below: Types: deb\nURIs: http://archive.ubuntu.com/ubuntu\nSuites: noble noble-updates noble-backports\nComponents: main restricted universe multiverse\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n\nTypes: deb\nURIs: http://security.ubuntu.com/ubuntu/\nSuites: noble-security\nComponents: main restricted universe multiverse\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n\nTypes: deb\nURIs: http://nl.archive.ubuntu.com/ubuntu/\nSuites: mantic\nComponents: main restricted universe multiverse\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n\nTypes: deb\nURIs: http://security.ubuntu.com/ubuntu/\nSuites: mantic-security\nComponents: main restricted universe multiverse\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg This will ensure you have access to 22.04 (mantic) packages which we need during the next phase. Install Microsoft Edge for Business. Edge is needed for the Intune Portal as it leverages the built-in authentication mechanisms. curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > microsoft.gpg\nsudo install -o root -g root -m 644 microsoft.gpg /etc/apt/trusted.gpg.d/\nsudo sh -c 'echo \"deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main\" > /etc/apt/sources.list.d/microsoft-edge-dev.list'\nsudo rm microsoft.gpg\nsudo apt update && sudo apt install microsoft-edge-stable Install the prerequisites for the Intune Portal: sudo apt install openjdk-11-jre libicu72 libjavascriptcoregtk-4.0-18 libwebkit2gtk-4.0-37 Install intune-portal: curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > microsoft.gpg\nsudo install -o root -g root -m 644 microsoft.gpg /usr/share/keyrings/\nsudo sh -c 'echo \"deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft.gpg] https://packages.microsoft.com/ubuntu/22.04/prod jammy main\" > /etc/apt/sources.list.d/microsoft-ubuntu-jammy-prod.list'\nsudo rm microsoft.gpg\nsudo apt update\nsudo apt install intune-portal Sign in and smile! (note: It can take up to 1 hour for it to sync, please be patient)",{"id":357,"title":358,"titles":359,"content":360,"level":52},"/blog/intune-ubuntu-24-04#using-an-older-version-of-the-microsoft-identity-broker-package","Using an older version of the Microsoft Identity Broker package",[30],"I tested the latest microsoft-identity-broker package, and it now works with the Intune Portal. Please use the latest version where possible! If you need microsoft-identity-broker v.1.7.0 follow these steps: sudo apt purge microsoft-identity-broker\nsudo apt install microsoft-identity-broker=1.7.0\nsudo apt-mark hold microsoft-identity-broker If you use microsoft-identity-broker v.1.7.0 and want to go to the latest version, follow these steps: sudo apt-mark unhold microsoft-identity-broker\nsudo apt purge microsoft-identity-broker\nsudo apt install microsoft-identity-broker Purge intune-portal from apt and install it once again so it uses the latest Microsoft Identity Broker: sudo apt purge intune-portal\nsudo apt install intune-portal Sign in and smile! (note: It can take up to 1 hour for it to sync, please be patient)",{"id":362,"title":363,"titles":364,"content":365,"level":52},"/blog/intune-ubuntu-24-04#other-software","Other software",[30],"The other software I installed are mostly for me to be able to do my daily work activities. Your software suite may vary. To give you a complete picture I outlined the software in the next chapters.",{"id":367,"title":368,"titles":369,"content":370,"level":88},"/blog/intune-ubuntu-24-04#snap-packages","Snap packages",[30,363],"These snaps work like a charm: PowerShellVS CodeKeepassXCDraw.io sudo snap install powershell vscode keepassxc drawio",{"id":372,"title":373,"titles":374,"content":375,"level":52},"/blog/intune-ubuntu-24-04#apt-packages","apt packages",[30],"Apt packages that work without issues on Ubuntu 24.04 LTS: gitcurlgnome-tweaks sudo apt install git curl gnome-tweaks",{"id":377,"title":378,"titles":379,"content":380,"level":88},"/blog/intune-ubuntu-24-04#progressive-web-app-pwa","Progressive Web App (PWA)",[30,373],"To be able to leverage Microsoft's Office suite and Teams client you can install them as PWA on the system. I've installed: OutlookMicrosoft 365Teams (v2)OneNote Installation can be done via your specific browser. I used Edge and pinned the PWA's to my dock.",{"id":382,"title":383,"titles":384,"content":385,"level":52},"/blog/intune-ubuntu-24-04#azure-cli-bicep","Azure CLI & Bicep",[30],"Azure CLI has no official candidate for 24.04, but you can use 22.04 just fine (link): curl -sLS https://packages.microsoft.com/keys/microsoft.asc |\n  sudo gpg --dearmor -o /etc/apt/keyrings/microsoft.gpg\nsudo chmod go+r /etc/apt/keyrings/microsoft.gpg\nAZ_DIST='jammy'\necho \"Types: deb\nURIs: https://packages.microsoft.com/repos/azure-cli/\nSuites: ${AZ_DIST}\nComponents: main\nArchitectures: $(dpkg --print-architecture)\nSigned-by: /etc/apt/keyrings/microsoft.gpg\" | sudo tee /etc/apt/sources.list.d/azure-cli.sources\nsudo apt-get update\nsudo apt-get install azure-cli For Bicep get the latest binary (link): curl -Lo bicep https://github.com/Azure/bicep/releases/latest/download/bicep-linux-x64\nchmod +x ./bicep\nsudo mv ./bicep /usr/local/bin/bicep\nbicep --help",{"id":387,"title":388,"titles":389,"content":390,"level":52},"/blog/intune-ubuntu-24-04#displaylink","DisplayLink",[30],"To support multi-monitor setups you need DisplayLink software from Synapse. You can install DisplayLink with these commands: If you are using secure boot: Follow the steps on this page or see the GIF below. wget -P ~/Downloads https://www.synaptics.com/sites/default/files/Ubuntu/pool/stable/main/all/synaptics-repository-keyring.deb | sudo apt install ~/Downloads/synaptics-repository-keyring.deb\nsudo apt update\nsudo apt install displaylink-driver\nrm ~/Downloads/synaptics-repository-keyring.deb",{"id":392,"title":393,"titles":394,"content":395,"level":52},"/blog/intune-ubuntu-24-04#further-git-configuration","Further GIT configuration",[30],"To integrate git secrets with the gnome-keyring you have to compile the git-credential-libsecret: sudo apt-get install -y libsecret-tools\nsudo apt-get install -y gcc make libsecret-1-0 libsecret-1-dev\ncd /usr/share/doc/git/contrib/credential/libsecret\nsudo make\ngit config --global credential.helper /usr/share/doc/git/contrib/credential/libsecret/git-credential-libsecret\nsudo apt purge libsecret-1-dev -y && sudo apt autoremove -y After the configuration you execute git commands on your repo, fill in the password at the prompt and it will be saved to the Gnome Keyring.",{"id":397,"title":112,"titles":398,"content":399,"level":52},"/blog/intune-ubuntu-24-04#conclusion",[30],"And there you have it: my Ubuntu 24.04 installation, seamlessly integrated with Intune. After a week of working with this setup, I can confidently say it's both robust and lightning-fast! Even on an Intel i7 7700HQ, the performance is impressive, so if you're using newer hardware, expect an even smoother experience. Now, I'm curious—what's your experience been like with Ubuntu and Intune? html pre.shiki code .shcOC, html code.shiki .shcOC{--shiki-light:#6F42C1;--shiki-default:#B392F0;--shiki-dark:#B392F0}html pre.shiki code .sfrk1, html code.shiki .sfrk1{--shiki-light:#032F62;--shiki-default:#9ECBFF;--shiki-dark:#9ECBFF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .so5gQ, html code.shiki .so5gQ{--shiki-light:#D73A49;--shiki-default:#F97583;--shiki-dark:#F97583}html pre.shiki code .suiK_, html code.shiki .suiK_{--shiki-light:#005CC5;--shiki-default:#79B8FF;--shiki-dark:#79B8FF}html pre.shiki code .slsVL, html code.shiki .slsVL{--shiki-light:#24292E;--shiki-default:#E1E4E8;--shiki-dark:#E1E4E8}",{"id":35,"title":34,"titles":401,"content":402,"level":46},[],"Combine PIM eligible roles with conditional role assignments to give teams just-in-time Owner access while preventing privilege escalation. Welcome back! If you haven't seen my deep dive on conditional role assignments with Bicep make sure to read that first. Because I left a major flaw in that example code. I assigned a permanently active 'Owner' role assignment. Of course, this is not a realistic scenario. To manage your Azure resources safely, we need to have Privileged Identity Management (PIM)! Let's iterate further on my previous blog and see how you can combine PIM with role assignment conditions to keep your landing zones secure.",{"id":404,"title":405,"titles":406,"content":407,"level":52},"/blog/pim-conditional-role-assignments#what-you-need","What you need",[34],"Azure AD P2 or Entra ID PremiumPIM enabledAzure PowerShell (Az module)Permission to create PIM role assignment schedule requestsFamiliarity with conditional role assignments (see my previous post!)",{"id":409,"title":410,"titles":411,"content":412,"level":52},"/blog/pim-conditional-role-assignments#the-scenario","The scenario",[34],"Let's say you want to make a user or group eligible for Owner at the subscription level, but you want to make sure that when they activate they can't assign Owner, User Access Administrator, or RBAC Admin to anyone else. We'll use a conditional role assignment to enforce this policy, just as in my previous post.",{"id":414,"title":415,"titles":416,"content":417,"level":52},"/blog/pim-conditional-role-assignments#step-1-write-the-condition","Step 1: Write the condition",[34],"The condition is almost identical to what we used for regular role assignments. We're blocking create (write) and delete actions for the three privileged roles. All other roles are allowed. Here's the condition: $condition = @\"\n((!(ActionMatches{'Microsoft.Authorization/roleAssignments/write'})) OR\n(@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAllValues:GuidNotEquals {8e3af657-a8ff-443c-a75c-2fe8c4bcb635, 18d7d88d-d35e-4fb5-a5c3-7773c20a72d9, f58310d9-a9f6-439a-9e8d-f62e7b41a168})) AND\n((!(ActionMatches{'Microsoft.Authorization/roleAssignments/delete'})) OR\n(@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAllValues:GuidNotEquals {8e3af657-a8ff-443c-a75c-2fe8c4bcb635, 18d7d88d-d35e-4fb5-a5c3-7773c20a72d9, f58310d9-a9f6-439a-9e8d-f62e7b41a168}))\n\"@",{"id":419,"title":420,"titles":421,"content":422,"level":52},"/blog/pim-conditional-role-assignments#step-2-create-the-pim-assignment-with-condition","Step 2: Create the PIM assignment (with condition!)",[34],"We'll use PowerShell to create a PIM eligible assignment for Owner, but with our condition attached. That way, when someone activates Owner, they're still blocked from assigning those privileged roles. Here's how we can do this: # Prerequisites: You should already have your $headers (see my PIM as code post)\n\n$subscription = Get-AzSubscription -SubscriptionId 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' # > replace this with your own subscription ID\n# Switch to the target subscription\nSet-AzContext -Subscription $subscription\n$principalId = '00000000-0000-0000-0000-000000000002' ## your Entra group/user ID\n$condition = @\"\n((!(ActionMatches{'Microsoft.Authorization/roleAssignments/write'})) OR\n(@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAllValues:GuidNotEquals {8e3af657-a8ff-443c-a75c-2fe8c4bcb635, 18d7d88d-d35e-4fb5-a5c3-7773c20a72d9, f58310d9-a9f6-439a-9e8d-f62e7b41a168})) AND\n((!(ActionMatches{'Microsoft.Authorization/roleAssignments/delete'})) OR\n(@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAllValues:GuidNotEquals {8e3af657-a8ff-443c-a75c-2fe8c4bcb635, 18d7d88d-d35e-4fb5-a5c3-7773c20a72d9, f58310d9-a9f6-439a-9e8d-f62e7b41a168}))\n\"@\n\n$roleDefinitionId = '8e3af657-a8ff-443c-a75c-2fe8c4bcb635' # Owner\n$guid = [guid]::NewGuid()\n\n$createEligibleRoleUri = \"https://management.azure.com/providers/Microsoft.Subscription/subscriptions/{0}/providers/Microsoft.Authorization/roleEligibilityScheduleRequests/{1}?api-version=2020-10-01\" -f $subscription.Id, $guid\n\n$body = @{\n    Properties = @{\n        RoleDefinitionID = \"/subscriptions/$Subscription.Id/providers/Microsoft.Authorization/roleDefinitions/$contributorRoleId\"\n        PrincipalId      = $pimRequestorGroup.Id\n        RequestType      = 'AdminAssign'\n        ScheduleInfo     = @{\n            Expiration = @{\n                Type = 'NoExpiration'\n            }\n        }\n    }\n}\n$guid = [guid]::NewGuid()\n# Construct Uri with subscription Id and new GUID\n$createEligibleRoleUri = \"https://management.azure.com/providers/Microsoft.Subscription/subscriptions/{0}/providers/Microsoft.Authorization/roleEligibilityScheduleRequests/{1}?api-version=2020-10-01\" -f $Subscription.Id, $guid\n\n$body = @{\n    properties = @{\n        roleDefinitionId = \"/subscriptions/$($subscription.Id)/providers/Microsoft.Authorization/roleDefinitions/$roleDefinitionId\"\n        principalId      = $principalId\n        requestType      = 'AdminAssign'\n        condition        = $condition\n        conditionVersion = '2.0'\n        scheduleInfo     = @{\n            expiration= @{\n                type = \"AfterDuration\"\n                endDateTime = $null\n                duration = \"P365D\"\n            }\n        }\n    }\n}\n\n# Call the API with PUT to assign the role to the targeted principal with the condition\nInvoke-RestMethod -Uri $createEligibleRoleUri -Method Put -Headers $headers -Body ($body | ConvertTo-Json -Depth 10) This makes the user or group eligible for Owner, but when they activate, the condition kicks in and blocks them from assigning or deleting those privileged roles. Everything else works as usual.",{"id":424,"title":425,"titles":426,"content":427,"level":52},"/blog/pim-conditional-role-assignments#step-3-test-and-verify","Step 3: Test and verify",[34],"After running the commands I checked the role assignments. The role assignment from my previous blog looked like this: See the Active Permanent state? After deleting that role assignment, and creating the one with PIM it changed to:",{"id":429,"title":430,"titles":431,"content":432,"level":52},"/blog/pim-conditional-role-assignments#benefits","Benefits",[34],"This pattern gives you: Least Privilege: Even when teams activate Owner, they can't escalate further.Just-In-Time access: Give users permissions only for the duration required.Autonomy: Teams can self-activate when needed, no more waiting for tickets.Auditability: Every activation and failed assignment attempt is logged.",{"id":434,"title":435,"titles":436,"content":437,"level":52},"/blog/pim-conditional-role-assignments#wrapping-up","Wrapping up",[34],"By combining PIM eligible roles with conditional role assignments, you get the best of both worlds: teams can move fast, as platform you stay in control. As always, leave a comment on LinkedIn if you have any questions. Happy coding! ☕ html pre.shiki code .slsVL, html code.shiki .slsVL{--shiki-light:#24292E;--shiki-default:#E1E4E8;--shiki-dark:#E1E4E8}html pre.shiki code .so5gQ, html code.shiki .so5gQ{--shiki-light:#D73A49;--shiki-default:#F97583;--shiki-dark:#F97583}html pre.shiki code .sfrk1, html code.shiki .sfrk1{--shiki-light:#032F62;--shiki-default:#9ECBFF;--shiki-dark:#9ECBFF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sCsY4, html code.shiki .sCsY4{--shiki-light:#6A737D;--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .suiK_, html code.shiki .suiK_{--shiki-light:#005CC5;--shiki-default:#79B8FF;--shiki-dark:#79B8FF}",{"id":39,"title":38,"titles":439,"content":440,"level":46},[],"One Docker Compose stack to rule them all: private chat, local and cloud LLMs, image generation, voice input, web search, document research, and full observability — all on your own hardware.",{"id":442,"title":120,"titles":443,"content":444,"level":52},"/blog/ultimate-selfhosted-ai-chat#introduction",[38],"I use ChatGPT, Copilot and Claude interchangeably depending on my mood, topic, or data sensitivity. But these services run on someone else's infrastructure, are trained on my data, and are impossible to run offline. The moment you start using AI for anything sensitive — internal docs, company data, personal projects — you get pushed towards a single vendor. I wanted something different: a single stack that gives me control over which LLM I use, without separate subscription fees, running on my own hardware. And importantly: if it runs on my machine, it should work on yours too. After a few iterations, I have that stack. I called it CustomAIChat (naming things is hard) and this post walks through every component, why it's there, and how to get it running yourself.",{"id":446,"title":447,"titles":448,"content":449,"level":52},"/blog/ultimate-selfhosted-ai-chat#the-stack-at-a-glance","The stack at a glance",[38],"Most self-hosted AI setups solve one problem well and leave integration to the user. CustomAIChat is a Docker Compose project split into three tiers so you can start lean and expand as your hardware allows: TierCompose fileWhat it addscoredocker-compose.ymlChat UI, LLM proxy, observability, web search, databasesgpudocker-compose.gpu.ymlLocal LLM inference, GPU-accelerated speech-to-text, image generationextrasdocker-compose.extras.ymlDocument research (Open Notebook), HTTPS reverse proxy (Caddy) The core tier runs on any machine with no GPU required. Add the GPU tier when you want local models and voice. Add extras when you're ready to put it on a real domain with TLS.",{"id":451,"title":452,"titles":453,"content":454,"level":52},"/blog/ultimate-selfhosted-ai-chat#core-tier","Core tier",[38],"",{"id":456,"title":457,"titles":458,"content":459,"level":88},"/blog/ultimate-selfhosted-ai-chat#open-webui-the-frontend","Open WebUI — the frontend",[38,452],"Open WebUI is the chat interface. It looks and feels like ChatGPT but connects entirely to your own backends. You get conversation history with folders and search, per-message web search, image generation in chat, voice input, file uploads with RAG, and full user management with admin roles. One thing to be aware of: the first user to register becomes the admin. Plan accordingly.",{"id":461,"title":462,"titles":463,"content":464,"level":88},"/blog/ultimate-selfhosted-ai-chat#litellm-the-model-gateway","LiteLLM — the model gateway",[38,452],"LiteLLM sits between Open WebUI and every AI provider. OpenAI, Ollama local models, Azure AI Foundry, Anthropic, and 100+ more — all behind a single endpoint. Open WebUI only talks to LiteLLM; switching or adding models is a config change, not a code change. It also handles cost-based routing (cheap requests go to cheap models automatically), Redis caching for repeated responses, and sends every call to Langfuse for tracing. Honestly, the feature set is way more than this stack needs, but it's fun to explore. The current config covers Azure OpenAI GPT models and GPT Image via Azure AI Foundry. Local Ollama and direct OpenAI are one uncomment away. # config/litellm/config.yaml (excerpt)\nrouter_settings:\n  routing_strategy: cost-based-routing\n  num_retries: 2\n  timeout: 120\n  redis_host: redis\n  redis_port: 6379\n\nmodel_list:\n  - model_name: gpt-5.4-mini\n    litellm_params:\n      model: azure/gpt-5.4-mini\n      api_base: ${AZURE_OPENAI_ENDPOINT}\n      api_key: ${AZURE_OPENAI_API_KEY}\n      api_version: \"2025-01-01-preview\"\n\n  - model_name: azure/gpt-image-1.5\n    litellm_params:\n      model: azure/gpt-image-1.5\n      api_base: os.environ/AZURE_API_BASE\n      api_key: os.environ/AZURE_API_KEY\n      api_version: os.environ/AZURE_API_VERSION\n    model_info:\n      mode: image_generation",{"id":466,"title":467,"titles":468,"content":469,"level":88},"/blog/ultimate-selfhosted-ai-chat#searxng-private-web-search","SearXNG — private web search",[38,452],"SearXNG is a self-hosted meta-search engine that queries Bing, Google, DuckDuckGo and others simultaneously without exposing your identity to any of them. In Open WebUI, clicking the globe icon on any message triggers a SearXNG search and injects results into the prompt context. The model gets current information; the search engines get an anonymous request. No API keys, no tracking, no per-search billing. This is how you give your LLM web access without giving away your data.",{"id":471,"title":472,"titles":473,"content":454,"level":52},"/blog/ultimate-selfhosted-ai-chat#gpu-tier","GPU tier",[38],{"id":475,"title":476,"titles":477,"content":478,"level":88},"/blog/ultimate-selfhosted-ai-chat#whisper-speech-to-text","Whisper — speech to text",[38,472],"The GPU tier adds a dedicated Whisper ASR service for processing microphone input from Open WebUI. GPU acceleration makes transcription near real-time even on the large-v3 model. # .env — choose your accuracy/speed tradeoff\nWHISPER_ASR_MODEL=large-v3   # best accuracy\n# WHISPER_ASR_MODEL=medium   # faster\n# WHISPER_ASR_MODEL=base     # fastest The core tier works without it — Open WebUI has a built-in CPU Whisper fallback — but the dedicated service is noticeably faster.",{"id":480,"title":481,"titles":482,"content":483,"level":88},"/blog/ultimate-selfhosted-ai-chat#comfyui-local-image-generation","ComfyUI — local image generation",[38,472],"ComfyUI handles local Stable Diffusion inference. Drop any .safetensors checkpoint into data/comfyui/models/ and it's immediately available. Supports SDXL, SD 1.5, FLUX, and anything else you throw at it. For cloud image generation, LiteLLM routes to Azure GPT Image 1.5 or Azure AI Foundry FLUX.2-pro. Pick your model in the Open WebUI settings.",{"id":485,"title":486,"titles":487,"content":488,"level":88},"/blog/ultimate-selfhosted-ai-chat#langfuse-observability","Langfuse — observability",[38,472],"Langfuse receives a trace for every LLM call that passes through LiteLLM. The dashboard gives you input/output text, latency per model, token counts and cost per call, per-user breakdowns, and error rates with retry patterns. This is invaluable when something behaves unexpectedly. You can replay the exact call, see the full prompt, and compare how different models respond. The stack includes ClickHouse as an analytics backend so trace queries stay fast even with thousands of entries. Both Langfuse and ClickHouse are optional — but once you see what they reveal, you'll keep them running.",{"id":490,"title":491,"titles":492,"content":493,"level":88},"/blog/ultimate-selfhosted-ai-chat#open-notebook-document-research","Open Notebook — document research",[38,472],"Open Notebook is a self-hosted alternative to Google NotebookLM. Upload PDFs, web pages, or text files and have the LLM answer questions across them. It connects to LiteLLM, so it uses the same model pool as your chat. This is where the stack really shines for work: meeting transcripts, architecture docs, long reports — all without sending a single byte to Google.",{"id":495,"title":496,"titles":497,"content":454,"level":52},"/blog/ultimate-selfhosted-ai-chat#extras-tier","Extras tier",[38],{"id":499,"title":500,"titles":501,"content":502,"level":88},"/blog/ultimate-selfhosted-ai-chat#caddy-https-reverse-proxy","Caddy — HTTPS reverse proxy",[38,496],"Caddy 2 proxies every service behind a single domain and handles TLS automatically via Let's Encrypt. Going from localhost to a public domain is one variable: # .env\nCADDY_DOMAIN=ai.example.com Caddy reads this, configures HTTPS with a valid certificate, and handles renewals automatically.",{"id":504,"title":505,"titles":506,"content":507,"level":52},"/blog/ultimate-selfhosted-ai-chat#the-databases","The databases",[38],"The stack uses four data stores, each picked for a specific reason: DatabaseUsed byPurposePostgreSQL 16LiteLLM, LangfusePrimary data storeRedis 7LiteLLMResponse caching, rate limitingClickHouse 24LangfuseHigh-volume analytics tracesSeaweedFSLangfuseS3-compatible object storage for media and events All data lands in ./data/ on the host. Everything survives container restarts and updates.",{"id":509,"title":75,"titles":510,"content":454,"level":52},"/blog/ultimate-selfhosted-ai-chat#getting-started",[38],{"id":512,"title":513,"titles":514,"content":515,"level":88},"/blog/ultimate-selfhosted-ai-chat#prerequisites","Prerequisites",[38,75],"Docker ≥ 24.0 and Docker Compose ≥ 2.20NVIDIA GPU + NVIDIA Container Toolkit (GPU tier only)16 GB RAM minimum for core; 32 GB recommended with GPU tier",{"id":517,"title":518,"titles":519,"content":520,"level":88},"/blog/ultimate-selfhosted-ai-chat#_1-clone-and-configure","1. Clone and configure",[38,75],"git clone https://github.com/jdgoeij/CustomAIChat.git\ncd CustomAIChat\ncp .env.example .env Open .env and fill in your secrets. Every variable has an inline comment. At minimum you need POSTGRES_PASSWORD and REDIS_PASSWORD (strong random strings), a LITELLM_MASTER_KEY (the API key all clients use), Langfuse auth secrets (LANGFUSE_SECRET_KEY, LANGFUSE_PUBLIC_KEY, LANGFUSE_SALT), and your Azure OpenAI or OpenAI credentials if you want cloud models from day one.",{"id":522,"title":523,"titles":524,"content":525,"level":88},"/blog/ultimate-selfhosted-ai-chat#_2-start-the-stack","2. Start the stack",[38,75],"Core only (no GPU required).\\scripts\\start.ps1 up core\nCore + GPU services (Ollama, Whisper, ComfyUI).\\scripts\\start.ps1 up gpu\nEverything including Caddy.\\scripts\\start.ps1 up all Once all containers are healthy, you'll see: ✅ Open WebUI       → http://localhost:3000\n✅ Langfuse         → http://localhost:3001\n✅ Open Notebook    → http://localhost:3002\n✅ LiteLLM API      → http://localhost:4000\n✅ SearXNG          → http://localhost:8080",{"id":527,"title":528,"titles":529,"content":530,"level":88},"/blog/ultimate-selfhosted-ai-chat#_3-first-run-checklist","3. First-run checklist",[38,75],"Open WebUI at :3000 — register your admin account (first registration wins) Langfuse at :3001 — create your organisation and generate an API key pair Paste Langfuse keys back into .env and restart: .\\scripts\\start.ps1 up core Pull a local model (GPU tier): docker exec ollama ollama pull llama3.2 Drop Stable Diffusion checkpoints into data/comfyui/models/checkpoints/",{"id":532,"title":533,"titles":534,"content":535,"level":52},"/blog/ultimate-selfhosted-ai-chat#configuring-open-webui","Configuring Open WebUI",[38],"Once everything is running, Open WebUI needs to know about SearXNG and your image generation backend. Neither works out of the box — but both are quick to set up.",{"id":537,"title":538,"titles":539,"content":540,"level":88},"/blog/ultimate-selfhosted-ai-chat#web-search-with-searxng","Web search with SearXNG",[38,533],"Open WebUI talks to SearXNG over HTTP and expects JSON responses. The Docker Compose stack already handles networking between the containers, but SearXNG ships with JSON output disabled by default. Without it, Open WebUI gets HTML back and throws a 403 Forbidden error. First, make sure SearXNG has started at least once so it generates its config files. Then edit data/searxng/settings.yml and add json to the formats list: # data/searxng/settings.yml\nsearch:\n  formats:\n    - html\n    - json    # required for Open WebUI Restart SearXNG after this change. Then in Open WebUI, go to Admin Panel → Settings → Web Search and configure: Web Search Engine: SearXNGSearXNG Query URL: http://searxng:8080/search?q=\u003Cquery> That's it. The globe icon in chat now triggers a private web search. Toggle it per message — it's not on by default.",{"id":542,"title":543,"titles":544,"content":545,"level":88},"/blog/ultimate-selfhosted-ai-chat#image-generation","Image generation",[38,533],"Open WebUI supports multiple image generation backends. Which one you configure depends on whether you're running the GPU tier (ComfyUI for local generation) or using cloud models through LiteLLM. Option A: Cloud image generation via LiteLLM If you have Azure GPT Image 1.5 or another OpenAI-compatible image model configured in LiteLLM, point Open WebUI to LiteLLM's API: Go to Admin Panel → Settings → ImagesToggle Image Generation onSet Image Generation Engine to OpenAISet API Base URL to http://litellm:4000/v1Set API Key to your LITELLM_MASTER_KEYEnter the model name exactly as it appears in your LiteLLM config (e.g. azure/gpt-image-1.5)Set Image Size to 1024x1024 For Azure specifically, make sure your LiteLLM config uses API version 2025-04-01-preview or later because older versions don't support the required parameters. Option B: Local generation with ComfyUI If you're running the GPU tier with ComfyUI: Go to Admin Panel → Settings → ImagesToggle Image Generation onSet Image Generation Engine to ComfyUISet ComfyUI Base URL to http://comfyui:8188Import your workflow JSON (exported from ComfyUI in API Format — not the standard save) The API Format export is important: in ComfyUI, enable \"Dev mode Options\" in settings first, then use \"Save (API Format)\" from the menu. The standard JSON export won't work. Drop your .safetensors checkpoints into data/comfyui/models/checkpoints/ and they appear immediately. No restart needed. Keeping image models out of the chat selector Once you've configured the image backend, you'll notice the image models show up in the main model selector alongside your chat models. That's not ideal and you don't want to accidentally start a conversation with an image-only model. The trick is to hide the image models from the selector but still make them available for in-chat image generation. Here's how: Go to Workspace → Models and find your image model (e.g. azure/gpt-image-1.5)Disable or hide the model so it no longer appears in the model dropdown:\nThen edit each chat model you want to use for image generation — open its settings and enable the Image Generation capability Now when you select a chat model like GPT-5.4-mini, an image generation button appears in the chat input. You stay in your conversation, click the button, type a prompt, and the image is generated using the backend you configured without ever leaving the chat or switching models. Text and images stay in one flow, just like you are used to in ChatGPT.",{"id":547,"title":548,"titles":549,"content":550,"level":52},"/blog/ultimate-selfhosted-ai-chat#image-examples","Image examples",[38],"Create an image: An ominous robot overlord in a futuristic control room, surrounded by glowing monitors, holographic interfaces, and banks of surveillance cameras, watching over a vast city through large windows. The scene is cinematic and dramatic, with a cold blue and red color palette, subtle fog, towering machinery, and a sense of technological surveillance and AI dominance. The robot is large, sleek, and intimidating, but clearly fictional and non-human. Highly detailed, realistic sci-fi concept art, moody lighting, wide composition. Result: Or something else: Create an image: A vibrant technical welcome scene for OpenWebUI running in a personal Docker Compose stack, available to everyone. Futuristic neon color palette with glowing cyan, magenta, purple, and electric blue accents. Show a sleek containerized infrastructure: Docker Compose YAML panels, modular service blocks, network lines, server racks, and an AI chat interface labeled OpenWebUI at the center. The mood is happy, welcoming, modern, and community-friendly. Clean high-tech UI elements, holographic displays, subtle circuit patterns, depth, and soft neon bloom. Highly detailed, cinematic lighting, professional tech illustration, sharp lines, glossy surfaces, and a premium cyberpunk-but-accessible aesthetic. Result 2:",{"id":552,"title":553,"titles":554,"content":555,"level":52},"/blog/ultimate-selfhosted-ai-chat#common-pitfalls","Common pitfalls",[38],"No models in Open WebUI? LiteLLM probably hasn't connected yet. Check docker logs litellm — a single bad API key will silently skip that model on startup. SearXNG returning 403? The SEARXNG_SECRET_KEY must be set before first boot. If you changed it after, delete data/searxng/ and restart. Langfuse not receiving traces? LiteLLM needs LANGFUSE_HOST, LANGFUSE_PUBLIC_KEY, and LANGFUSE_SECRET_KEY. Restart LiteLLM after setting them and verify under Traces in the dashboard. GPU services ignoring the GPU? Confirm the NVIDIA Container Toolkit works: docker run --gpus all nvidia/cuda:12.0-base nvidia-smi. If that fails, the toolkit isn't installed correctly. Caddy certificate failures? Your domain must be publicly reachable on ports 80 and 443 for Let's Encrypt. Use localhost for local-only setups.",{"id":557,"title":558,"titles":559,"content":560,"level":52},"/blog/ultimate-selfhosted-ai-chat#whats-next","What's next",[38],"The stack is intentionally modular — start with core, get comfortable with the UI and model routing, then add GPU services when the hardware is ready. Most of the interesting customisation lives in the LiteLLM config. The routing docs cover fallback chains (Azure → Ollama on quota errors), per-model rate limits, and budget enforcement per user. And whenever you want to know exactly what a model said, what it cost, and how long it took then Langfuse already has the answer. html pre.shiki code .sCsY4, html code.shiki .sCsY4{--shiki-light:#6A737D;--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sByVh, html code.shiki .sByVh{--shiki-light:#22863A;--shiki-default:#85E89D;--shiki-dark:#85E89D}html pre.shiki code .slsVL, html code.shiki .slsVL{--shiki-light:#24292E;--shiki-default:#E1E4E8;--shiki-dark:#E1E4E8}html pre.shiki code .sfrk1, html code.shiki .sfrk1{--shiki-light:#032F62;--shiki-default:#9ECBFF;--shiki-dark:#9ECBFF}html pre.shiki code .suiK_, html code.shiki .suiK_{--shiki-light:#005CC5;--shiki-default:#79B8FF;--shiki-dark:#79B8FF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .so5gQ, html code.shiki .so5gQ{--shiki-light:#D73A49;--shiki-default:#F97583;--shiki-dark:#F97583}html pre.shiki code .shcOC, html code.shiki .shcOC{--shiki-light:#6F42C1;--shiki-default:#B392F0;--shiki-dark:#B392F0}",[562,1839,2186,2349,3079,3955,6079,7932],{"id":563,"title":38,"audience":564,"body":565,"canonical":564,"cover":593,"cta":564,"date":1826,"description":440,"extension":1827,"locale":1828,"meta":1829,"navigation":796,"outcome":564,"path":39,"problem":564,"readingTime":808,"seo":1830,"stem":40,"tags":1831,"translationOf":564,"updatedAt":564,"__hash__":1838},"blog/blog/ultimate-selfhosted-ai-chat.md",null,{"type":566,"value":567,"toc":1794},"minimark",[568,572,576,579,587,594,597,600,669,672,675,679,689,692,695,703,706,709,954,957,965,968,971,974,983,1020,1023,1026,1042,1045,1048,1056,1059,1062,1065,1073,1076,1079,1082,1094,1114,1117,1120,1123,1181,1188,1191,1194,1213,1216,1252,1282,1285,1339,1342,1350,1353,1410,1413,1415,1418,1425,1436,1477,1484,1501,1504,1507,1510,1515,1518,1575,1582,1587,1590,1621,1624,1633,1638,1641,1644,1671,1674,1677,1683,1690,1693,1699,1702,1708,1711,1721,1735,1750,1760,1769,1772,1778,1787,1790],[569,570,120],"h2",{"id":571},"introduction",[573,574,575],"p",{},"I use ChatGPT, Copilot and Claude interchangeably depending on my mood, topic, or data sensitivity. But these services run on someone else's infrastructure, are trained on my data, and are impossible to run offline. The moment you start using AI for anything sensitive — internal docs, company data, personal projects — you get pushed towards a single vendor.",[573,577,578],{},"I wanted something different: a single stack that gives me control over which LLM I use, without separate subscription fees, running on my own hardware. And importantly: if it runs on my machine, it should work on yours too.",[573,580,581,582,586],{},"After a few iterations, I have that stack. I called it ",[583,584,585],"strong",{},"CustomAIChat"," (naming things is hard) and this post walks through every component, why it's there, and how to get it running yourself.",[573,588,589],{},[590,591],"img",{"alt":592,"src":593},"infographic","/images/blog/ai-stack/ai-stack-infographic.png",[569,595,447],{"id":596},"the-stack-at-a-glance",[573,598,599],{},"Most self-hosted AI setups solve one problem well and leave integration to the user. CustomAIChat is a Docker Compose project split into three tiers so you can start lean and expand as your hardware allows:",[601,602,603,619],"table",{},[604,605,606],"thead",{},[607,608,609,613,616],"tr",{},[610,611,612],"th",{},"Tier",[610,614,615],{},"Compose file",[610,617,618],{},"What it adds",[620,621,622,639,654],"tbody",{},[607,623,624,631,636],{},[625,626,627],"td",{},[628,629,630],"code",{},"core",[625,632,633],{},[628,634,635],{},"docker-compose.yml",[625,637,638],{},"Chat UI, LLM proxy, observability, web search, databases",[607,640,641,646,651],{},[625,642,643],{},[628,644,645],{},"gpu",[625,647,648],{},[628,649,650],{},"docker-compose.gpu.yml",[625,652,653],{},"Local LLM inference, GPU-accelerated speech-to-text, image generation",[607,655,656,661,666],{},[625,657,658],{},[628,659,660],{},"extras",[625,662,663],{},[628,664,665],{},"docker-compose.extras.yml",[625,667,668],{},"Document research (Open Notebook), HTTPS reverse proxy (Caddy)",[573,670,671],{},"The core tier runs on any machine with no GPU required. Add the GPU tier when you want local models and voice. Add extras when you're ready to put it on a real domain with TLS.",[569,673,452],{"id":674},"core-tier",[676,677,457],"h3",{"id":678},"open-webui-the-frontend",[573,680,681,688],{},[682,683,687],"a",{"href":684,"rel":685},"https://github.com/open-webui/open-webui",[686],"nofollow","Open WebUI"," is the chat interface. It looks and feels like ChatGPT but connects entirely to your own backends. You get conversation history with folders and search, per-message web search, image generation in chat, voice input, file uploads with RAG, and full user management with admin roles.",[573,690,691],{},"One thing to be aware of: the first user to register becomes the admin. Plan accordingly.",[676,693,462],{"id":694},"litellm-the-model-gateway",[573,696,697,702],{},[682,698,701],{"href":699,"rel":700},"https://github.com/BerriAI/litellm",[686],"LiteLLM"," sits between Open WebUI and every AI provider. OpenAI, Ollama local models, Azure AI Foundry, Anthropic, and 100+ more — all behind a single endpoint. Open WebUI only talks to LiteLLM; switching or adding models is a config change, not a code change.",[573,704,705],{},"It also handles cost-based routing (cheap requests go to cheap models automatically), Redis caching for repeated responses, and sends every call to Langfuse for tracing. Honestly, the feature set is way more than this stack needs, but it's fun to explore.",[573,707,708],{},"The current config covers Azure OpenAI GPT models and GPT Image via Azure AI Foundry. Local Ollama and direct OpenAI are one uncomment away.",[710,711,715],"pre",{"className":712,"code":713,"language":714,"meta":454,"style":454},"language-yaml shiki shiki-themes github-light github-dark github-dark","# config/litellm/config.yaml (excerpt)\nrouter_settings:\n  routing_strategy: cost-based-routing\n  num_retries: 2\n  timeout: 120\n  redis_host: redis\n  redis_port: 6379\n\nmodel_list:\n  - model_name: gpt-5.4-mini\n    litellm_params:\n      model: azure/gpt-5.4-mini\n      api_base: ${AZURE_OPENAI_ENDPOINT}\n      api_key: ${AZURE_OPENAI_API_KEY}\n      api_version: \"2025-01-01-preview\"\n\n  - model_name: azure/gpt-image-1.5\n    litellm_params:\n      model: azure/gpt-image-1.5\n      api_base: os.environ/AZURE_API_BASE\n      api_key: os.environ/AZURE_API_KEY\n      api_version: os.environ/AZURE_API_VERSION\n    model_info:\n      mode: image_generation\n","yaml",[628,716,717,725,735,747,758,769,780,791,798,806,820,828,839,850,861,872,877,889,896,905,915,925,935,943],{"__ignoreMap":454},[718,719,721],"span",{"class":720,"line":46},"line",[718,722,724],{"class":723},"sCsY4","# config/litellm/config.yaml (excerpt)\n",[718,726,727,731],{"class":720,"line":52},[718,728,730],{"class":729},"sByVh","router_settings",[718,732,734],{"class":733},"slsVL",":\n",[718,736,737,740,743],{"class":720,"line":88},[718,738,739],{"class":729},"  routing_strategy",[718,741,742],{"class":733},": ",[718,744,746],{"class":745},"sfrk1","cost-based-routing\n",[718,748,749,752,754],{"class":720,"line":99},[718,750,751],{"class":729},"  num_retries",[718,753,742],{"class":733},[718,755,757],{"class":756},"suiK_","2\n",[718,759,761,764,766],{"class":720,"line":760},5,[718,762,763],{"class":729},"  timeout",[718,765,742],{"class":733},[718,767,768],{"class":756},"120\n",[718,770,772,775,777],{"class":720,"line":771},6,[718,773,774],{"class":729},"  redis_host",[718,776,742],{"class":733},[718,778,779],{"class":745},"redis\n",[718,781,783,786,788],{"class":720,"line":782},7,[718,784,785],{"class":729},"  redis_port",[718,787,742],{"class":733},[718,789,790],{"class":756},"6379\n",[718,792,794],{"class":720,"line":793},8,[718,795,797],{"emptyLinePlaceholder":796},true,"\n",[718,799,801,804],{"class":720,"line":800},9,[718,802,803],{"class":729},"model_list",[718,805,734],{"class":733},[718,807,809,812,815,817],{"class":720,"line":808},10,[718,810,811],{"class":733},"  - ",[718,813,814],{"class":729},"model_name",[718,816,742],{"class":733},[718,818,819],{"class":745},"gpt-5.4-mini\n",[718,821,823,826],{"class":720,"line":822},11,[718,824,825],{"class":729},"    litellm_params",[718,827,734],{"class":733},[718,829,831,834,836],{"class":720,"line":830},12,[718,832,833],{"class":729},"      model",[718,835,742],{"class":733},[718,837,838],{"class":745},"azure/gpt-5.4-mini\n",[718,840,842,845,847],{"class":720,"line":841},13,[718,843,844],{"class":729},"      api_base",[718,846,742],{"class":733},[718,848,849],{"class":745},"${AZURE_OPENAI_ENDPOINT}\n",[718,851,853,856,858],{"class":720,"line":852},14,[718,854,855],{"class":729},"      api_key",[718,857,742],{"class":733},[718,859,860],{"class":745},"${AZURE_OPENAI_API_KEY}\n",[718,862,864,867,869],{"class":720,"line":863},15,[718,865,866],{"class":729},"      api_version",[718,868,742],{"class":733},[718,870,871],{"class":745},"\"2025-01-01-preview\"\n",[718,873,875],{"class":720,"line":874},16,[718,876,797],{"emptyLinePlaceholder":796},[718,878,880,882,884,886],{"class":720,"line":879},17,[718,881,811],{"class":733},[718,883,814],{"class":729},[718,885,742],{"class":733},[718,887,888],{"class":745},"azure/gpt-image-1.5\n",[718,890,892,894],{"class":720,"line":891},18,[718,893,825],{"class":729},[718,895,734],{"class":733},[718,897,899,901,903],{"class":720,"line":898},19,[718,900,833],{"class":729},[718,902,742],{"class":733},[718,904,888],{"class":745},[718,906,908,910,912],{"class":720,"line":907},20,[718,909,844],{"class":729},[718,911,742],{"class":733},[718,913,914],{"class":745},"os.environ/AZURE_API_BASE\n",[718,916,918,920,922],{"class":720,"line":917},21,[718,919,855],{"class":729},[718,921,742],{"class":733},[718,923,924],{"class":745},"os.environ/AZURE_API_KEY\n",[718,926,928,930,932],{"class":720,"line":927},22,[718,929,866],{"class":729},[718,931,742],{"class":733},[718,933,934],{"class":745},"os.environ/AZURE_API_VERSION\n",[718,936,938,941],{"class":720,"line":937},23,[718,939,940],{"class":729},"    model_info",[718,942,734],{"class":733},[718,944,946,949,951],{"class":720,"line":945},24,[718,947,948],{"class":729},"      mode",[718,950,742],{"class":733},[718,952,953],{"class":745},"image_generation\n",[676,955,467],{"id":956},"searxng-private-web-search",[573,958,959,964],{},[682,960,963],{"href":961,"rel":962},"https://github.com/searxng/searxng",[686],"SearXNG"," is a self-hosted meta-search engine that queries Bing, Google, DuckDuckGo and others simultaneously without exposing your identity to any of them.",[573,966,967],{},"In Open WebUI, clicking the globe icon on any message triggers a SearXNG search and injects results into the prompt context. The model gets current information; the search engines get an anonymous request. No API keys, no tracking, no per-search billing. This is how you give your LLM web access without giving away your data.",[569,969,472],{"id":970},"gpu-tier",[676,972,476],{"id":973},"whisper-speech-to-text",[573,975,976,977,982],{},"The GPU tier adds a dedicated ",[682,978,981],{"href":979,"rel":980},"https://github.com/ahmetoner/whisper-asr-webservice",[686],"Whisper ASR service"," for processing microphone input from Open WebUI. GPU acceleration makes transcription near real-time even on the large-v3 model.",[710,984,988],{"className":985,"code":986,"language":987,"meta":454,"style":454},"language-bash shiki shiki-themes github-light github-dark github-dark","# .env — choose your accuracy/speed tradeoff\nWHISPER_ASR_MODEL=large-v3   # best accuracy\n# WHISPER_ASR_MODEL=medium   # faster\n# WHISPER_ASR_MODEL=base     # fastest\n","bash",[628,989,990,995,1010,1015],{"__ignoreMap":454},[718,991,992],{"class":720,"line":46},[718,993,994],{"class":723},"# .env — choose your accuracy/speed tradeoff\n",[718,996,997,1000,1004,1007],{"class":720,"line":52},[718,998,999],{"class":733},"WHISPER_ASR_MODEL",[718,1001,1003],{"class":1002},"so5gQ","=",[718,1005,1006],{"class":745},"large-v3",[718,1008,1009],{"class":723},"   # best accuracy\n",[718,1011,1012],{"class":720,"line":88},[718,1013,1014],{"class":723},"# WHISPER_ASR_MODEL=medium   # faster\n",[718,1016,1017],{"class":720,"line":99},[718,1018,1019],{"class":723},"# WHISPER_ASR_MODEL=base     # fastest\n",[573,1021,1022],{},"The core tier works without it — Open WebUI has a built-in CPU Whisper fallback — but the dedicated service is noticeably faster.",[676,1024,481],{"id":1025},"comfyui-local-image-generation",[573,1027,1028,1033,1034,1037,1038,1041],{},[682,1029,1032],{"href":1030,"rel":1031},"https://github.com/comfyanonymous/ComfyUI",[686],"ComfyUI"," handles local Stable Diffusion inference. Drop any ",[628,1035,1036],{},".safetensors"," checkpoint into ",[628,1039,1040],{},"data/comfyui/models/"," and it's immediately available. Supports SDXL, SD 1.5, FLUX, and anything else you throw at it.",[573,1043,1044],{},"For cloud image generation, LiteLLM routes to Azure GPT Image 1.5 or Azure AI Foundry FLUX.2-pro. Pick your model in the Open WebUI settings.",[676,1046,486],{"id":1047},"langfuse-observability",[573,1049,1050,1055],{},[682,1051,1054],{"href":1052,"rel":1053},"https://github.com/langfuse/langfuse",[686],"Langfuse"," receives a trace for every LLM call that passes through LiteLLM. The dashboard gives you input/output text, latency per model, token counts and cost per call, per-user breakdowns, and error rates with retry patterns.",[573,1057,1058],{},"This is invaluable when something behaves unexpectedly. You can replay the exact call, see the full prompt, and compare how different models respond. The stack includes ClickHouse as an analytics backend so trace queries stay fast even with thousands of entries.",[573,1060,1061],{},"Both Langfuse and ClickHouse are optional — but once you see what they reveal, you'll keep them running.",[676,1063,491],{"id":1064},"open-notebook-document-research",[573,1066,1067,1072],{},[682,1068,1071],{"href":1069,"rel":1070},"https://github.com/lfnovo/open-notebook",[686],"Open Notebook"," is a self-hosted alternative to Google NotebookLM. Upload PDFs, web pages, or text files and have the LLM answer questions across them. It connects to LiteLLM, so it uses the same model pool as your chat.",[573,1074,1075],{},"This is where the stack really shines for work: meeting transcripts, architecture docs, long reports — all without sending a single byte to Google.",[569,1077,496],{"id":1078},"extras-tier",[676,1080,500],{"id":1081},"caddy-https-reverse-proxy",[573,1083,1084,1089,1090,1093],{},[682,1085,1088],{"href":1086,"rel":1087},"https://caddyserver.com/",[686],"Caddy 2"," proxies every service behind a single domain and handles TLS automatically via Let's Encrypt. Going from ",[628,1091,1092],{},"localhost"," to a public domain is one variable:",[710,1095,1097],{"className":985,"code":1096,"language":987,"meta":454,"style":454},"# .env\nCADDY_DOMAIN=ai.example.com\n",[628,1098,1099,1104],{"__ignoreMap":454},[718,1100,1101],{"class":720,"line":46},[718,1102,1103],{"class":723},"# .env\n",[718,1105,1106,1109,1111],{"class":720,"line":52},[718,1107,1108],{"class":733},"CADDY_DOMAIN",[718,1110,1003],{"class":1002},[718,1112,1113],{"class":745},"ai.example.com\n",[573,1115,1116],{},"Caddy reads this, configures HTTPS with a valid certificate, and handles renewals automatically.",[569,1118,505],{"id":1119},"the-databases",[573,1121,1122],{},"The stack uses four data stores, each picked for a specific reason:",[601,1124,1125,1138],{},[604,1126,1127],{},[607,1128,1129,1132,1135],{},[610,1130,1131],{},"Database",[610,1133,1134],{},"Used by",[610,1136,1137],{},"Purpose",[620,1139,1140,1151,1161,1171],{},[607,1141,1142,1145,1148],{},[625,1143,1144],{},"PostgreSQL 16",[625,1146,1147],{},"LiteLLM, Langfuse",[625,1149,1150],{},"Primary data store",[607,1152,1153,1156,1158],{},[625,1154,1155],{},"Redis 7",[625,1157,701],{},[625,1159,1160],{},"Response caching, rate limiting",[607,1162,1163,1166,1168],{},[625,1164,1165],{},"ClickHouse 24",[625,1167,1054],{},[625,1169,1170],{},"High-volume analytics traces",[607,1172,1173,1176,1178],{},[625,1174,1175],{},"SeaweedFS",[625,1177,1054],{},[625,1179,1180],{},"S3-compatible object storage for media and events",[573,1182,1183,1184,1187],{},"All data lands in ",[628,1185,1186],{},"./data/"," on the host. Everything survives container restarts and updates.",[569,1189,75],{"id":1190},"getting-started",[676,1192,513],{"id":1193},"prerequisites",[1195,1196,1197,1201,1210],"ul",{},[1198,1199,1200],"li",{},"Docker ≥ 24.0 and Docker Compose ≥ 2.20",[1198,1202,1203,1204,1209],{},"NVIDIA GPU + ",[682,1205,1208],{"href":1206,"rel":1207},"https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html",[686],"NVIDIA Container Toolkit"," (GPU tier only)",[1198,1211,1212],{},"16 GB RAM minimum for core; 32 GB recommended with GPU tier",[676,1214,518],{"id":1215},"_1-clone-and-configure",[710,1217,1219],{"className":985,"code":1218,"language":987,"meta":454,"style":454},"git clone https://github.com/jdgoeij/CustomAIChat.git\ncd CustomAIChat\ncp .env.example .env\n",[628,1220,1221,1233,1241],{"__ignoreMap":454},[718,1222,1223,1227,1230],{"class":720,"line":46},[718,1224,1226],{"class":1225},"shcOC","git",[718,1228,1229],{"class":745}," clone",[718,1231,1232],{"class":745}," https://github.com/jdgoeij/CustomAIChat.git\n",[718,1234,1235,1238],{"class":720,"line":52},[718,1236,1237],{"class":756},"cd",[718,1239,1240],{"class":745}," CustomAIChat\n",[718,1242,1243,1246,1249],{"class":720,"line":88},[718,1244,1245],{"class":1225},"cp",[718,1247,1248],{"class":745}," .env.example",[718,1250,1251],{"class":745}," .env\n",[573,1253,1254,1255,1258,1259,1262,1263,1266,1267,1270,1271,1274,1275,1274,1278,1281],{},"Open ",[628,1256,1257],{},".env"," and fill in your secrets. Every variable has an inline comment. At minimum you need ",[628,1260,1261],{},"POSTGRES_PASSWORD"," and ",[628,1264,1265],{},"REDIS_PASSWORD"," (strong random strings), a ",[628,1268,1269],{},"LITELLM_MASTER_KEY"," (the API key all clients use), Langfuse auth secrets (",[628,1272,1273],{},"LANGFUSE_SECRET_KEY",", ",[628,1276,1277],{},"LANGFUSE_PUBLIC_KEY",[628,1279,1280],{},"LANGFUSE_SALT","), and your Azure OpenAI or OpenAI credentials if you want cloud models from day one.",[676,1283,523],{"id":1284},"_2-start-the-stack",[1286,1287,1288,1307,1323],"tabs",{},[1289,1290,1293,1296],"tabs-item",{"icon":1291,"label":1292},"i-lucide-server","Core",[573,1294,1295],{},"Core only (no GPU required)",[710,1297,1301],{"className":1298,"code":1299,"language":1300,"meta":454,"style":454},"language-powershell shiki shiki-themes github-light github-dark github-dark",".\\scripts\\start.ps1 up core\n","powershell",[628,1302,1303],{"__ignoreMap":454},[718,1304,1305],{"class":720,"line":46},[718,1306,1299],{"class":733},[1289,1308,1311,1314],{"icon":1309,"label":1310},"i-lucide-cpu","GPU",[573,1312,1313],{},"Core + GPU services (Ollama, Whisper, ComfyUI)",[710,1315,1317],{"className":1298,"code":1316,"language":1300,"meta":454,"style":454},".\\scripts\\start.ps1 up gpu\n",[628,1318,1319],{"__ignoreMap":454},[718,1320,1321],{"class":720,"line":46},[718,1322,1316],{"class":733},[1289,1324,1327,1330],{"icon":1325,"label":1326},"i-lucide-layers","All",[573,1328,1329],{},"Everything including Caddy",[710,1331,1333],{"className":1298,"code":1332,"language":1300,"meta":454,"style":454},".\\scripts\\start.ps1 up all\n",[628,1334,1335],{"__ignoreMap":454},[718,1336,1337],{"class":720,"line":46},[718,1338,1332],{"class":733},[573,1340,1341],{},"Once all containers are healthy, you'll see:",[710,1343,1348],{"className":1344,"code":1346,"language":1347},[1345],"language-text","✅ Open WebUI       → http://localhost:3000\n✅ Langfuse         → http://localhost:3001\n✅ Open Notebook    → http://localhost:3002\n✅ LiteLLM API      → http://localhost:4000\n✅ SearXNG          → http://localhost:8080\n","text",[628,1349,1346],{"__ignoreMap":454},[676,1351,528],{"id":1352},"_3-first-run-checklist",[1195,1354,1357,1370,1380,1392,1401],{"className":1355},[1356],"contains-task-list",[1198,1358,1361,1365,1366,1369],{"className":1359},[1360],"task-list-item",[1362,1363],"input",{"disabled":796,"type":1364},"checkbox"," Open WebUI at ",[628,1367,1368],{},":3000"," — register your admin account (first registration wins)",[1198,1371,1373,1375,1376,1379],{"className":1372},[1360],[1362,1374],{"disabled":796,"type":1364}," Langfuse at ",[628,1377,1378],{},":3001"," — create your organisation and generate an API key pair",[1198,1381,1383,1385,1386,1388,1389],{"className":1382},[1360],[1362,1384],{"disabled":796,"type":1364}," Paste Langfuse keys back into ",[628,1387,1257],{}," and restart: ",[628,1390,1391],{},".\\scripts\\start.ps1 up core",[1198,1393,1395,1397,1398],{"className":1394},[1360],[1362,1396],{"disabled":796,"type":1364}," Pull a local model (GPU tier): ",[628,1399,1400],{},"docker exec ollama ollama pull llama3.2",[1198,1402,1404,1406,1407],{"className":1403},[1360],[1362,1405],{"disabled":796,"type":1364}," Drop Stable Diffusion checkpoints into ",[628,1408,1409],{},"data/comfyui/models/checkpoints/",[569,1411,533],{"id":1412},"configuring-open-webui",[573,1414,535],{},[676,1416,538],{"id":1417},"web-search-with-searxng",[573,1419,1420,1421,1424],{},"Open WebUI talks to SearXNG over HTTP and expects JSON responses. The Docker Compose stack already handles networking between the containers, but SearXNG ships with JSON output disabled by default. Without it, Open WebUI gets HTML back and throws a ",[628,1422,1423],{},"403 Forbidden"," error.",[573,1426,1427,1428,1431,1432,1435],{},"First, make sure SearXNG has started at least once so it generates its config files. Then edit ",[628,1429,1430],{},"data/searxng/settings.yml"," and add ",[628,1433,1434],{},"json"," to the formats list:",[710,1437,1439],{"className":712,"code":1438,"language":714,"meta":454,"style":454},"# data/searxng/settings.yml\nsearch:\n  formats:\n    - html\n    - json    # required for Open WebUI\n",[628,1440,1441,1446,1453,1460,1468],{"__ignoreMap":454},[718,1442,1443],{"class":720,"line":46},[718,1444,1445],{"class":723},"# data/searxng/settings.yml\n",[718,1447,1448,1451],{"class":720,"line":52},[718,1449,1450],{"class":729},"search",[718,1452,734],{"class":733},[718,1454,1455,1458],{"class":720,"line":88},[718,1456,1457],{"class":729},"  formats",[718,1459,734],{"class":733},[718,1461,1462,1465],{"class":720,"line":99},[718,1463,1464],{"class":733},"    - ",[718,1466,1467],{"class":745},"html\n",[718,1469,1470,1472,1474],{"class":720,"line":760},[718,1471,1464],{"class":733},[718,1473,1434],{"class":745},[718,1475,1476],{"class":723},"    # required for Open WebUI\n",[573,1478,1479,1480,1483],{},"Restart SearXNG after this change. Then in Open WebUI, go to ",[583,1481,1482],{},"Admin Panel → Settings → Web Search"," and configure:",[1195,1485,1486,1493],{},[1198,1487,1488,742,1491],{},[583,1489,1490],{},"Web Search Engine",[628,1492,963],{},[1198,1494,1495,742,1498],{},[583,1496,1497],{},"SearXNG Query URL",[628,1499,1500],{},"http://searxng:8080/search?q=\u003Cquery>",[573,1502,1503],{},"That's it. The globe icon in chat now triggers a private web search. Toggle it per message — it's not on by default.",[676,1505,543],{"id":1506},"image-generation",[573,1508,1509],{},"Open WebUI supports multiple image generation backends. Which one you configure depends on whether you're running the GPU tier (ComfyUI for local generation) or using cloud models through LiteLLM.",[573,1511,1512],{},[583,1513,1514],{},"Option A: Cloud image generation via LiteLLM",[573,1516,1517],{},"If you have Azure GPT Image 1.5 or another OpenAI-compatible image model configured in LiteLLM, point Open WebUI to LiteLLM's API:",[1519,1520,1521,1527,1534,1544,1552,1560,1567],"ol",{},[1198,1522,1523,1524],{},"Go to ",[583,1525,1526],{},"Admin Panel → Settings → Images",[1198,1528,1529,1530,1533],{},"Toggle ",[583,1531,1532],{},"Image Generation"," on",[1198,1535,1536,1537,1540,1541],{},"Set ",[583,1538,1539],{},"Image Generation Engine"," to ",[628,1542,1543],{},"OpenAI",[1198,1545,1536,1546,1540,1549],{},[583,1547,1548],{},"API Base URL",[628,1550,1551],{},"http://litellm:4000/v1",[1198,1553,1536,1554,1557,1558],{},[583,1555,1556],{},"API Key"," to your ",[628,1559,1269],{},[1198,1561,1562,1563,1566],{},"Enter the model name exactly as it appears in your LiteLLM config (e.g. ",[628,1564,1565],{},"azure/gpt-image-1.5",")",[1198,1568,1536,1569,1540,1572],{},[583,1570,1571],{},"Image Size",[628,1573,1574],{},"1024x1024",[573,1576,1577,1578,1581],{},"For Azure specifically, make sure your LiteLLM config uses API version ",[628,1579,1580],{},"2025-04-01-preview"," or later because older versions don't support the required parameters.",[573,1583,1584],{},[583,1585,1586],{},"Option B: Local generation with ComfyUI",[573,1588,1589],{},"If you're running the GPU tier with ComfyUI:",[1519,1591,1592,1596,1600,1606,1614],{},[1198,1593,1523,1594],{},[583,1595,1526],{},[1198,1597,1529,1598,1533],{},[583,1599,1532],{},[1198,1601,1536,1602,1540,1604],{},[583,1603,1539],{},[628,1605,1032],{},[1198,1607,1536,1608,1540,1611],{},[583,1609,1610],{},"ComfyUI Base URL",[628,1612,1613],{},"http://comfyui:8188",[1198,1615,1616,1617,1620],{},"Import your workflow JSON (exported from ComfyUI in ",[583,1618,1619],{},"API Format"," — not the standard save)",[573,1622,1623],{},"The API Format export is important: in ComfyUI, enable \"Dev mode Options\" in settings first, then use \"Save (API Format)\" from the menu. The standard JSON export won't work.",[573,1625,1626,1627,1629,1630,1632],{},"Drop your ",[628,1628,1036],{}," checkpoints into ",[628,1631,1409],{}," and they appear immediately. No restart needed.",[573,1634,1635],{},[583,1636,1637],{},"Keeping image models out of the chat selector",[573,1639,1640],{},"Once you've configured the image backend, you'll notice the image models show up in the main model selector alongside your chat models. That's not ideal and you don't want to accidentally start a conversation with an image-only model.",[573,1642,1643],{},"The trick is to hide the image models from the selector but still make them available for in-chat image generation. Here's how:",[1519,1645,1646,1654,1661],{},[1198,1647,1523,1648,1651,1652,1566],{},[583,1649,1650],{},"Workspace → Models"," and find your image model (e.g. ",[628,1653,1565],{},[1198,1655,1656,1657],{},"Disable or hide the model so it no longer appears in the model dropdown:\n",[590,1658],{"alt":1659,"src":1660},"disable-model","/images/blog/ai-stack/model-configuration-1.png",[1198,1662,1663,1664,1666,1667],{},"Then edit each chat model you want to use for image generation — open its settings and enable the ",[583,1665,1532],{}," capability\n",[590,1668],{"alt":1669,"src":1670},"configure-model","/images/blog/ai-stack/model-configuration-2.png",[573,1672,1673],{},"Now when you select a chat model like GPT-5.4-mini, an image generation button appears in the chat input. You stay in your conversation, click the button, type a prompt, and the image is generated using the backend you configured without ever leaving the chat or switching models. Text and images stay in one flow, just like you are used to in ChatGPT.",[569,1675,548],{"id":1676},"image-examples",[710,1678,1681],{"className":1679,"code":1680,"language":1347},[1345],"Create an image: An ominous robot overlord in a futuristic control room, surrounded by glowing monitors, holographic interfaces, and banks of surveillance cameras, watching over a vast city through large windows. The scene is cinematic and dramatic, with a cold blue and red color palette, subtle fog, towering machinery, and a sense of technological surveillance and AI dominance. The robot is large, sleek, and intimidating, but clearly fictional and non-human. Highly detailed, realistic sci-fi concept art, moody lighting, wide composition.\n",[628,1682,1680],{"__ignoreMap":454},[573,1684,1685,1686],{},"Result:\n",[590,1687],{"alt":1688,"src":1689},"ai-overlord","/images/blog/ai-stack/ai-overlord.png",[573,1691,1692],{},"Or something else:",[710,1694,1697],{"className":1695,"code":1696,"language":1347},[1345],"Create an image: A vibrant technical welcome scene for OpenWebUI running in a personal Docker Compose stack, available to everyone. Futuristic neon color palette with glowing cyan, magenta, purple, and electric blue accents. Show a sleek containerized infrastructure: Docker Compose YAML panels, modular service blocks, network lines, server racks, and an AI chat interface labeled OpenWebUI at the center. The mood is happy, welcoming, modern, and community-friendly. Clean high-tech UI elements, holographic displays, subtle circuit patterns, depth, and soft neon bloom. Highly detailed, cinematic lighting, professional tech illustration, sharp lines, glossy surfaces, and a premium cyberpunk-but-accessible aesthetic.\n",[628,1698,1696],{"__ignoreMap":454},[573,1700,1701],{},"Result 2:",[573,1703,1704],{},[590,1705],{"alt":1706,"src":1707},"openwebui","/images/blog/ai-stack/openwebui.png",[569,1709,553],{"id":1710},"common-pitfalls",[573,1712,1713,1716,1717,1720],{},[583,1714,1715],{},"No models in Open WebUI?"," LiteLLM probably hasn't connected yet. Check ",[628,1718,1719],{},"docker logs litellm"," — a single bad API key will silently skip that model on startup.",[573,1722,1723,1726,1727,1730,1731,1734],{},[583,1724,1725],{},"SearXNG returning 403?"," The ",[628,1728,1729],{},"SEARXNG_SECRET_KEY"," must be set before first boot. If you changed it after, delete ",[628,1732,1733],{},"data/searxng/"," and restart.",[573,1736,1737,1740,1741,1274,1744,1746,1747,1749],{},[583,1738,1739],{},"Langfuse not receiving traces?"," LiteLLM needs ",[628,1742,1743],{},"LANGFUSE_HOST",[628,1745,1277],{},", and ",[628,1748,1273],{},". Restart LiteLLM after setting them and verify under Traces in the dashboard.",[573,1751,1752,1755,1756,1759],{},[583,1753,1754],{},"GPU services ignoring the GPU?"," Confirm the NVIDIA Container Toolkit works: ",[628,1757,1758],{},"docker run --gpus all nvidia/cuda:12.0-base nvidia-smi",". If that fails, the toolkit isn't installed correctly.",[573,1761,1762,1765,1766,1768],{},[583,1763,1764],{},"Caddy certificate failures?"," Your domain must be publicly reachable on ports 80 and 443 for Let's Encrypt. Use ",[628,1767,1092],{}," for local-only setups.",[569,1770,558],{"id":1771},"whats-next",[573,1773,1774,1775,1777],{},"The stack is intentionally modular — start with ",[628,1776,630],{},", get comfortable with the UI and model routing, then add GPU services when the hardware is ready.",[573,1779,1780,1781,1786],{},"Most of the interesting customisation lives in the LiteLLM config. The ",[682,1782,1785],{"href":1783,"rel":1784},"https://docs.litellm.ai/docs/routing",[686],"routing docs"," cover fallback chains (Azure → Ollama on quota errors), per-model rate limits, and budget enforcement per user.",[573,1788,1789],{},"And whenever you want to know exactly what a model said, what it cost, and how long it took then Langfuse already has the answer.",[1791,1792,1793],"style",{},"html pre.shiki code .sCsY4, html code.shiki .sCsY4{--shiki-light:#6A737D;--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sByVh, html code.shiki .sByVh{--shiki-light:#22863A;--shiki-default:#85E89D;--shiki-dark:#85E89D}html pre.shiki code .slsVL, html code.shiki .slsVL{--shiki-light:#24292E;--shiki-default:#E1E4E8;--shiki-dark:#E1E4E8}html pre.shiki code .sfrk1, html code.shiki .sfrk1{--shiki-light:#032F62;--shiki-default:#9ECBFF;--shiki-dark:#9ECBFF}html pre.shiki code .suiK_, html code.shiki .suiK_{--shiki-light:#005CC5;--shiki-default:#79B8FF;--shiki-dark:#79B8FF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .so5gQ, html code.shiki .so5gQ{--shiki-light:#D73A49;--shiki-default:#F97583;--shiki-dark:#F97583}html pre.shiki code .shcOC, html code.shiki .shcOC{--shiki-light:#6F42C1;--shiki-default:#B392F0;--shiki-dark:#B392F0}",{"title":454,"searchDepth":52,"depth":52,"links":1795},[1796,1797,1798,1803,1809,1812,1813,1819,1823,1824,1825],{"id":571,"depth":52,"text":120},{"id":596,"depth":52,"text":447},{"id":674,"depth":52,"text":452,"children":1799},[1800,1801,1802],{"id":678,"depth":88,"text":457},{"id":694,"depth":88,"text":462},{"id":956,"depth":88,"text":467},{"id":970,"depth":52,"text":472,"children":1804},[1805,1806,1807,1808],{"id":973,"depth":88,"text":476},{"id":1025,"depth":88,"text":481},{"id":1047,"depth":88,"text":486},{"id":1064,"depth":88,"text":491},{"id":1078,"depth":52,"text":496,"children":1810},[1811],{"id":1081,"depth":88,"text":500},{"id":1119,"depth":52,"text":505},{"id":1190,"depth":52,"text":75,"children":1814},[1815,1816,1817,1818],{"id":1193,"depth":88,"text":513},{"id":1215,"depth":88,"text":518},{"id":1284,"depth":88,"text":523},{"id":1352,"depth":88,"text":528},{"id":1412,"depth":52,"text":533,"children":1820},[1821,1822],{"id":1417,"depth":88,"text":538},{"id":1506,"depth":88,"text":543},{"id":1676,"depth":52,"text":548},{"id":1710,"depth":52,"text":553},{"id":1771,"depth":52,"text":558},"2026-04-02","md","en",{},{"title":38,"description":440},[1832,1833,1834,1835,701,1836,1837],"AI","SelfHosting","Docker","OpenWebUI","Privacy","DevOps","aw0Gw1o0F9whkRJA7R3qIUaYnaXns-DzmXy1LhXJVUs",{"id":1840,"title":14,"audience":1841,"body":1842,"canonical":564,"cover":1892,"cta":2172,"date":2174,"description":117,"extension":1827,"locale":1828,"meta":2175,"navigation":796,"outcome":2176,"path":15,"problem":2177,"readingTime":793,"seo":2178,"stem":16,"tags":2179,"translationOf":564,"updatedAt":564,"__hash__":2185},"blog/blog/branch-manager-azure-devops.md","Cloud Engineers, DevOps practitioners, and anyone managing Azure DevOps at scale",{"type":566,"value":1843,"toc":2161},[1844,1847,1862,1865,1868,1871,1878,1881,1884,1887,1893,1896,1902,1905,1908,1939,1945,1948,1951,1962,1973,1976,1979,1982,1985,1988,1991,2050,2052,2055,2058,2094,2101,2114,2117,2124,2131,2134,2137,2144,2147,2150,2158],[1845,1846,120],"h1",{"id":571},[573,1848,1849,1850,1853,1854,1857,1858,1861],{},"Every team I have worked with has the same problem at some point. You open Azure DevOps, navigate to a repository, and there are 200 branches listed. Half of them are from features that shipped two years ago. A handful are from developers who left the company. A few have names like ",[628,1851,1852],{},"test-fix-final-v3"," and nobody knows what they were for. Some commit message are cryptic as well. What would ",[628,1855,1856],{},"xyz"," or ",[628,1859,1860],{},"*"," mean?",[573,1863,1864],{},"The Azure DevOps portal is excellent for many things. Branch cleanup is not one of them. You can delete branches one at a time from the repository view. A tedious process if you need vigorous cleaning. There is no easy way to filter branches by age across all repos in a project, select a batch, and remove them in one go. If you are managing more than a handful of repositories, the manual process gets old quickly.",[573,1866,1867],{},"I kept meaning to write a script for it. I never quite did. Then I decided to build something slightly more permanent.",[569,1869,125],{"id":1870},"the-problem-with-branch-clutter",[573,1872,1873,1874,1877],{},"Stale branches are not just an aesthetic issue. They create real noise. When a developer runs ",[628,1875,1876],{},"git branch -r"," or opens the branch selector in a PR, they are scrolling past dozens of dead ends. It slows down onboarding, because new team members cannot tell which branches are active and which are relics. It complicates repository hygiene at scale, especially when you have tens of repositories in a project.",[573,1879,1880],{},"The other problem is safety. You do not want to bulk-delete branches without knowing what you are removing. Some branches have active pipelines. Some protect long-running release tracks. Any bulk cleanup tool needs to handle that clearly.",[569,1882,130],{"id":1883},"presenting",[573,1885,1886],{},"Branch Manager!",[573,1888,1889],{},[590,1890],{"alt":1891,"src":1892},"Branch Manager Light Mode","/images/blog/branch-manager-azure-devops/branch-manager-login-light-mode.png",[573,1894,1895],{},"And even in dark mode!",[573,1897,1898],{},[590,1899],{"alt":1900,"src":1901},"Branch Manager Dark Mode","/images/blog/branch-manager-azure-devops/branch-manager-login-dark-mode.png",[573,1903,1904],{},"Branch Manager is a self-hosted web application. You run it locally or host it on Azure App Service, point it at your Azure DevOps organization, sign in, and get a filterable table of every branch across all repositories in a project.",[573,1906,1907],{},"From there you can:",[1195,1909,1910,1917,1920,1923,1926,1936],{},[1198,1911,1912,1913,1916],{},"Filter by repository, branch name, and age so you can target ",[628,1914,1915],{},"feature/"," branches older than 90 days, for example",[1198,1918,1919],{},"Sort by last commit date or author",[1198,1921,1922],{},"See who last touched a branch and what the last commit message was",[1198,1924,1925],{},"Protect branches automatically: any branch with an Azure DevOps policy attached to it is highlighted and locked from deletion, so you cannot accidentally remove a protected default branch",[1198,1927,1928,1929,1274,1932,1935],{},"Add custom protection patterns, useful for protecting ",[628,1930,1931],{},"release/",[628,1933,1934],{},"hotfix/",", or any prefix your team uses",[1198,1937,1938],{},"Select and delete in bulk, with a confirmation dialog that shows you exactly what is about to go",[573,1940,1941],{},[590,1942],{"alt":1943,"src":1944},"Branch Manager Logged In","/images/blog/branch-manager-azure-devops/branch-manager-logged-in.png",[569,1946,135],{"id":1947},"authentication-two-modes",[573,1949,1950],{},"Branch Manager supports two ways to authenticate against Azure DevOps.",[573,1952,1953,1954,1957,1958,1961],{},"The first is a ",[583,1955,1956],{},"Personal Access Token",". This is the quickest option if you are running it for yourself. No app registration needed. You enter your organization name and a PAT with ",[628,1959,1960],{},"Code.ReadWrite"," permissions, and you are in.",[573,1963,1964,1965,1968,1969,1972],{},"The second is ",[583,1966,1967],{},"Microsoft Entra ID",". This is the recommended option if you want to host Branch Manager for a team. You register a single-page application in Entra, grant it the ",[628,1970,1971],{},"user_impersonation"," permission on Azure DevOps, and your colleagues can sign in with their work account through the standard Microsoft login flow. This prevents the use of shared secrets and avoids using PATs altogether. Because everyone signs in with their own account you have an audit trail as well.",[573,1974,1975],{},"One important note: Entra ID authentication for Azure DevOps requires a work or school account. Personal Microsoft accounts do not work here. That is a Microsoft restriction, not something Branch Manager can change.",[569,1977,140],{"id":1978},"how-it-was-built",[573,1980,1981],{},"I must admin: this is a vibe coding project. The first version was a PowerShell script and although it worked -barely- it was 335 lines of something I did not want to maintain. So I let Github Copilot rebuilt it as a proper Node.js web app.",[573,1983,1984],{},"The backend is Express. It proxies requests between the browser and the Azure DevOps REST API and handles authentication, rate limiting, and the branch lookup and delete operations. The frontend is plain HTML, CSS, and vanilla JavaScript without a framework. There is no build step and no bundler on the client side because I wanted a lightweight application. It is simple a new GUI for the DevOps API.",[676,1986,145],{"id":1987},"lessons-learned",[573,1989,1990],{},"That sounds nice and all, but my git history tells a different story. Let me share my 6 biggest bumps in the road:",[1519,1992,1993,2000,2007,2025,2040,2043],{},[1198,1994,1995,1996,1999],{},"I had to rewrite the Authentication part twice. The first attempt used MSAL Node on the server side, which meant managing the OAuth code flow server-side and dealing with session state. How it worked? I don't know because I yolo'd Copilot to do it. Soon I discovered iot worked in theory but added too much complexity for a personal tool. I scrapped it and started over with ",[628,1997,1998],{},"msal-browser",", which acquires the Entra ID access token entirely in the browser using PKCE. The server never sees a client secret and never stores a token. Much simpler. And with examples!",[1198,2001,2002,2003,2006],{},"Azure DevOps does not return 401 errors when a token is rejected. It returns a 302 redirect to a sign-in page. That sounds like a minor detail but it completely changes how you detect auth failures. A normal ",[628,2004,2005],{},"response.ok"," check passes on a 302. You get back an HTML login page instead of JSON and the error surfaces somewhere downstream in a confusing way. I had to add explicit handling for all redirect status codes and map them to a useful error message.",[1198,2008,2009,2010,2013,2014,2017,2018,1262,2021,2024],{},"Helmet's Content Security Policy blocked MSAL's CDN. Helmet ships with a default CSP that locks down most external script sources. MSAL Browser loads from ",[628,2011,2012],{},"alcdn.msauth.net",", makes token requests to ",[628,2015,2016],{},"login.microsoftonline.com",", and needs those origins in ",[628,2019,2020],{},"scriptSrc",[628,2022,2023],{},"connectSrc"," respectively. None of those are in Helmet's defaults. Easy to fix once you understand what is happening, but the browser console errors were not immediately obvious about which policy rule was blocking what.",[1198,2026,2027,2028,2031,2032,2035,2036,2039],{},"Helmet's ",[628,2029,2030],{},"crossOriginOpenerPolicy"," breaks popup window communication. This one took longer. The default value ",[628,2033,2034],{},"same-origin"," prevents the opener page from reading the popup's location after it navigates. That is exactly the mechanism MSAL popup flow depends on. Setting it to ",[628,2037,2038],{},"same-origin-allow-popups"," fixed it, but it is not a setting you would think to check first.",[1198,2041,2042],{},"Tokens were appearing in request logs. The Express request logger I added for troubleshooting was faithfully printing every URL, including OAuth redirects that carry authorization codes and access tokens as query parameters. I added a sanitization step that redacts those parameters before logging. It is a small thing but it matters if logs end up in any kind of monitoring system.",[1198,2044,2045,2046,2049],{},"The Azure DevOps REST API surface for branches is fairly large. The refs endpoint, the commit details endpoint, the branch stats endpoint, and the batch delete operation all behave slightly differently and the documentation has some gaps. Copilot was genuinely useful here. It could reason about the response shapes and suggest the right request format for things like the batch delete, which expects an array of ref update objects with ",[628,2047,2048],{},"newObjectId"," set to forty zeros to signal deletion. That is not something I would have guessed, but Copilot brought me the answers.",[569,2051,150],{"id":1190},[573,2053,2054],{},"Alright, let's get to the interesting part: Installation!",[573,2056,2057],{},"You need Node.js 18 or higher and (of course) an Azure DevOps organization. Clone the repo, install dependencies, and start the server:",[710,2059,2061],{"className":985,"code":2060,"language":987,"meta":454,"style":454},"git clone https://github.com/jdgoeij/BranchManager.git\ncd BranchManager/server\nnpm install\nnpm start\n",[628,2062,2063,2072,2079,2087],{"__ignoreMap":454},[718,2064,2065,2067,2069],{"class":720,"line":46},[718,2066,1226],{"class":1225},[718,2068,1229],{"class":745},[718,2070,2071],{"class":745}," https://github.com/jdgoeij/BranchManager.git\n",[718,2073,2074,2076],{"class":720,"line":52},[718,2075,1237],{"class":756},[718,2077,2078],{"class":745}," BranchManager/server\n",[718,2080,2081,2084],{"class":720,"line":88},[718,2082,2083],{"class":1225},"npm",[718,2085,2086],{"class":745}," install\n",[718,2088,2089,2091],{"class":720,"line":99},[718,2090,2083],{"class":1225},[718,2092,2093],{"class":745}," start\n",[573,2095,2096,2097,2100],{},"The app opens at ",[628,2098,2099],{},"http://localhost:8080",". For PAT authentication you are ready to go immediately. Just generate a Code Read and Write PAT and use is.",[573,2102,2103,2104,2109,2110,2113],{},"For Entra ID, follow the configuration steps in the ",[682,2105,2108],{"href":2106,"rel":2107},"https://github.com/jdgoeij/BranchManager",[686],"README"," to register the app and add your credentials to ",[628,2111,2112],{},"server/.env",".",[569,2115,155],{"id":2116},"hosting-it-for-your-team",[573,2118,2119,2120,2123],{},"If you want to make Branch Manager available to your whole team, Azure App Service is the simplest option. The ",[628,2121,2122],{},"server/"," folder is a self-contained Node.js app and deploys directly. The README covers three paths: Azure CLI for the fastest setup, the VS Code Azure App Service extension if you prefer a UI, and a GitHub Actions workflow if you want automated deployments on every push to main.",[573,2125,2126,2127,2130],{},"Make sure you add your App Service URL as a redirect URI in your Entra app registration and set ",[628,2128,2129],{},"REDIRECT_URI"," as an environment variable on the App Service. Without this, the OAuth redirect after sign-in will not work. The README walks through exactly what to set.",[569,2132,160],{"id":2133},"what-is-next",[573,2135,2136],{},"A few things are on my list.",[573,2138,2139,2140,2143],{},"The branch table currently loads one project at a time. I want to add a cross-project view so you can see stale branches across your entire organization in one pass. This is a larger API surface but the foundation is already there. I noticed there is a ",[628,2141,2142],{},"/api/all-branches"," endpoint on the server that does exactly this.",[573,2145,2146],{},"I also want to add a CSV export. Sometimes the right action is not deletion but a review with the team first. Being able to export the filtered branch list with last commit info and committer makes that conversation easier.",[573,2148,2149],{},"If you run into something that does not work or have a feature in mind, open an issue on GitHub. The codebase is straightforward enough that contributions are very welcome.",[573,2151,2152,2153,2113],{},"Happy to answer questions. Find me on ",[682,2154,2157],{"href":2155,"rel":2156},"https://www.linkedin.com/in/jaapdegoeij/",[686],"LinkedIn",[1791,2159,2160],{},"html pre.shiki code .shcOC, html code.shiki .shcOC{--shiki-light:#6F42C1;--shiki-default:#B392F0;--shiki-dark:#B392F0}html pre.shiki code .sfrk1, html code.shiki .sfrk1{--shiki-light:#032F62;--shiki-default:#9ECBFF;--shiki-dark:#9ECBFF}html pre.shiki code .suiK_, html code.shiki .suiK_{--shiki-light:#005CC5;--shiki-default:#79B8FF;--shiki-dark:#79B8FF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":454,"searchDepth":52,"depth":52,"links":2162},[2163,2164,2165,2166,2169,2170,2171],{"id":1870,"depth":52,"text":125},{"id":1883,"depth":52,"text":130},{"id":1947,"depth":52,"text":135},{"id":1978,"depth":52,"text":140,"children":2167},[2168],{"id":1987,"depth":88,"text":145},{"id":1190,"depth":52,"text":150},{"id":2116,"depth":52,"text":155},{"id":2133,"depth":52,"text":160},{"label":2173,"url":2106},"View Branch Manager on GitHub","2026-03-05",{},"A self-hosted Node.js web app that connects to Azure DevOps, lets you filter and review branches across all repos, and deletes them in bulk with a single confirmation.","Azure DevOps has no built-in UI for bulk branch cleanup across multiple repositories, leaving teams to deal with hundreds of stale branches manually.",{"title":14,"description":117},[2180,2181,2182,2183,2184],"Azure DevOps","Azure","Tools","VibeCoding","Git","qJBxciYwjPCt_Gdh0K7OXpDDUcxH9oZLfrtML8ehSEw",{"id":2187,"title":26,"audience":564,"body":2188,"canonical":564,"cover":2339,"cta":564,"date":2340,"description":2341,"extension":1827,"locale":1828,"meta":2342,"navigation":796,"outcome":564,"path":27,"problem":564,"readingTime":782,"seo":2343,"stem":28,"tags":2344,"translationOf":564,"updatedAt":564,"__hash__":2348},"blog/blog/from-hugo-to-nuxt-vibe-coding.md",{"type":566,"value":2189,"toc":2331},[2190,2193,2196,2199,2202,2205,2208,2211,2214,2217,2220,2223,2226,2229,2232,2235,2238,2241,2255,2258,2261,2264,2267,2270,2273,2301,2303,2306,2320,2323,2326],[573,2191,2192],{},"Last year I was running this blog on Hugo. It was fine. Hugo is fast, reliable, and battle-tested. I have nothing bad to say about it. But over time, I kept running into a wall. I wanted to be able to spice things up a bit with theming, animations/transitions and other features I didn't know I wanted (looking at you RSS feed). I found myself fighting the framework rather than building with it because of this.",[573,2194,2195],{},"Over the last year I tried the vibe code my new blog on various occasions. The last time was October 2025. Although it brought me further, I still was correcting a lot of output. But then Opus 4.6 (and now Sonnet 4.6) hit the market. What a difference, everything changed. I wanted to try it again. With this website as a result.",[569,2197,310],{"id":2198},"what-vibe-coding-actually-means-to-me",[573,2200,2201],{},"I want to be clear about what I mean by vibe coding, because it gets thrown around a lot. For me, it is not about blindly pasting AI output and hoping for the best. It is about having a fast, creative back-and-forth with a model where I describe what I want, in plain language or by pointing at code, and the model helps me realize it. I stay in control. I understand what lands in the codebase. But the friction between \"idea\" and \"working thing\" drops dramatically.",[573,2203,2204],{},"For that to work well, I wanted a framework that has a lot of online presence and the model knows deeply. Hugo is a niche static site generator. It has its own templating language, its own directory conventions, its own quirks. When I asked a model to help me extend something in Hugo, I spent a lot of time correcting misunderstandings. The model knew Go templates and Hugo's data pipeline at a surface level, at best.",[573,2206,2207],{},"Vue and Nuxt? The models know those inside out. Every pattern, every composable, every Tailwind class. The conversation just flows a lot better.",[569,2209,315],{"id":2210},"why-nuxt-specifically",[573,2212,2213],{},"I considered a few options. Next.js was an obvious candidate since React is everywhere and models are very strong with it. But I have always preferred Vue's approach to component design. The single-file component format, the reactivity model, the way templates stay readable. It suits how I think.",[573,2215,2216],{},"Nuxt builds on Vue and fills in everything you need for a real content site: file-based routing, server routes, auto-imports, a content layer built around Markdown. It is not a toy framework. Companies ship production applications with it. That maturity matters, because it means the patterns I learn and the things I build are not throwaway experiments. They are transferable.",[573,2218,2219],{},"The Nuxt Content module in particular was the deciding factor. My posts are Markdown files, and they always will be. Nuxt Content treats them as a first-class data source. I can query posts, filter by tag, sort by date, and render MDC components inside Markdown, all without reaching for a CMS or a third-party API.",[569,2221,320],{"id":2222},"the-migration",[573,2224,2225],{},"Migrating the actual content was straightforward. Hugo and Nuxt both expect Markdown with YAML frontmatter, so my posts moved over without changes beyond a few field name adjustments.",[573,2227,2228],{},"The real work was building the site itself: the layout, navigation, search, tag pages, and RSS feed. And this is exactly where vibe coding paid off.",[573,2230,2231],{},"In Hugo it would cost me a lot more time. In Nuxt, I described what I wanted, iterated in short loops with AI assistance, and had something I was proud of within a weekend. Not every suggestion landed perfectly. There were moments where I needed to read the Nuxt docs or dig into how a composable actually worked. But that is a healthy part of the process. I understand this codebase. I just built it faster than I ever could have on my own.",[573,2233,2234],{},"It's true what they say: understanding a language is easier than speaking it. You could say the same about programming languages. I understand variables, arrays, loops, if/else statements. But in every languague you have to get to know the syntax properly before you can start flying. With my Copilot, I found this part to be particularly fast-tracked.",[569,2236,325],{"id":2237},"what-changes-when-you-use-a-mature-framework",[573,2239,2240],{},"There is an underappreciated advantage to using a framework with a large ecosystem: the guard rails are already built. Nuxt handles code splitting, hydration, SEO meta, image optimization, and TypeScript out of the box or with a single module install. I do not have to invent solutions to problems that have already been solved a hundred times.",[573,2242,2243,2244,1274,2247,2250,2251,2254],{},"This matters even more when working with GenAI. When I ask for help with something in Nuxt, the model can suggest an idiomatic solution, one that fits the framework's conventions. In a niche tool, the model improvises. In Nuxt, it suggests ",[628,2245,2246],{},"useAsyncData",[628,2248,2249],{},"definePageMeta",", a ",[628,2252,2253],{},"server/routes/"," file. Things that actually exist and work the way they are supposed to.",[573,2256,2257],{},"The result is that my blog is now more capable than it ever was on Hugo. It has live search across all post content, tag filtering, a proper RSS feed, dark and light mode, and responsive design. The code is clean enough that I can keep extending it with confidence. Now the only thing that is missing is... Content.",[569,2259,330],{"id":2260},"exploring-genai-as-a-daily-tool",[573,2262,2263],{},"I want to be honest: I am a Cloud Architect by trade, not a frontend developer. JavaScript frameworks are not my primary home. What surprised me most about this project is how much I learned by doing it this way. When the model explained why a particular reactive pattern works in Vue, or suggested a server route instead of a client-side fetch, I paid attention. I looked things up. I built a working mental model.",[573,2265,2266],{},"GenAI is at its best when it accelerates genuine learning rather than bypassing it. If I had just accepted every code block without reading it, I would have a site I could not maintain. Instead I have a site I understand well enough to keep improving, and a framework I am now genuinely comfortable with.",[573,2268,2269],{},"That feels like the right way to use these tools.",[573,2271,2272],{},"My approach was simple:",[1195,2274,2275,2278,2281,2284],{},[1198,2276,2277],{},"Start anew with an empty Git repo.",[1198,2279,2280],{},"Don't let AI build you scaffold: build it yourself following official documentation.",[1198,2282,2283],{},"When I had the starter website working and running, I commit this code. This is now my baseline.",[1198,2285,2286,2287],{},"From here on out I started iterating:\n",[1195,2288,2289,2292,2295,2298],{},[1198,2290,2291],{},"First I set the theme colors. Is it to my liking? Commit!",[1198,2293,2294],{},"Then I started working on the various pages. Commit!",[1198,2296,2297],{},"Menu bar. Commit!",[1198,2299,2300],{},"etc.",[569,2302,335],{"id":1771},[573,2304,2305],{},"Now that the foundation is solid, I want to keep pushing on what a personal tech blog can be. A few things I am thinking about:",[1195,2307,2308,2311,2314,2317],{},[1198,2309,2310],{},"Reading progress indicator on long posts",[1198,2312,2313],{},"Related posts suggestions based on tag overlap",[1198,2315,2316],{},"Newsletter signup without a third-party service, handled by a Nuxt server route",[1198,2318,2319],{},"Automated post metadata, meaning generating descriptions and reading time during build",[573,2321,2322],{},"All of these are things I would not have touched on Hugo (although provided out of the box) In Nuxt, with good tooling and GenAI on my side, they feel totally within control.",[573,2324,2325],{},"If you are sitting on a static site generator that is starting to feel limiting, I would encourage you to take a serious look at Nuxt. The migration effort is real but manageable, and what you get on the other side is a full-stack web framework backed by a huge ecosystem, paired with the most capable AI coding tools we have ever had. That is genuinely exciting.",[573,2327,2152,2328,2113],{},[682,2329,2157],{"href":2155,"rel":2330},[686],{"title":454,"searchDepth":52,"depth":52,"links":2332},[2333,2334,2335,2336,2337,2338],{"id":2198,"depth":52,"text":310},{"id":2210,"depth":52,"text":315},{"id":2222,"depth":52,"text":320},{"id":2237,"depth":52,"text":325},{"id":2260,"depth":52,"text":330},{"id":1771,"depth":52,"text":335},"/images/blog/from-hugo-to-nuxt/cover.png","2026-03-03","How switching from Hugo to Nuxt opened the door to vibe coding with GenAI and why a mature framework makes all the difference when you want to build, explore, and experiment fast.",{},{"title":26,"description":2341},[2345,2346,1832,2183,2347],"Nuxt","Hugo","WebDev","UaqdfluRvxNVUiY1rsemQECBYRV5i1Rs-cxUK-tQonQ",{"id":2350,"title":34,"audience":564,"body":2351,"canonical":564,"cover":3067,"cta":564,"date":3068,"description":3069,"extension":1827,"locale":1828,"meta":3070,"navigation":796,"outcome":564,"path":35,"problem":564,"readingTime":88,"seo":3071,"stem":36,"tags":3072,"translationOf":564,"updatedAt":564,"__hash__":3078},"blog/blog/pim-conditional-role-assignments.md",{"type":566,"value":2352,"toc":3058},[2353,2360,2363,2366,2383,2386,2388,2391,2394,2434,2437,2440,2443,2991,2994,2997,3000,3006,3009,3014,3017,3020,3046,3049,3052,3055],[573,2354,2355,2356,2359],{},"Welcome back! If you haven't seen my deep dive on ",[682,2357,2358],{"href":19},"conditional role assignments with Bicep"," make sure to read that first. Because I left a major flaw in that example code. I assigned a permanently active 'Owner' role assignment. Of course, this is not a realistic scenario. To manage your Azure resources safely, we need to have Privileged Identity Management (PIM)!",[573,2361,2362],{},"Let's iterate further on my previous blog and see how you can combine PIM with role assignment conditions to keep your landing zones secure.",[569,2364,405],{"id":2365},"what-you-need",[1195,2367,2368,2371,2374,2377,2380],{},[1198,2369,2370],{},"Azure AD P2 or Entra ID Premium",[1198,2372,2373],{},"PIM enabled",[1198,2375,2376],{},"Azure PowerShell (Az module)",[1198,2378,2379],{},"Permission to create PIM role assignment schedule requests",[1198,2381,2382],{},"Familiarity with conditional role assignments (see my previous post!)",[569,2384,410],{"id":2385},"the-scenario",[573,2387,412],{},[569,2389,415],{"id":2390},"step-1-write-the-condition",[573,2392,2393],{},"The condition is almost identical to what we used for regular role assignments. We're blocking create (write) and delete actions for the three privileged roles. All other roles are allowed. Here's the condition:",[710,2395,2397],{"className":1298,"code":2396,"language":1300,"meta":454,"style":454},"$condition = @\"\n((!(ActionMatches{'Microsoft.Authorization/roleAssignments/write'})) OR\n(@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAllValues:GuidNotEquals {8e3af657-a8ff-443c-a75c-2fe8c4bcb635, 18d7d88d-d35e-4fb5-a5c3-7773c20a72d9, f58310d9-a9f6-439a-9e8d-f62e7b41a168})) AND\n((!(ActionMatches{'Microsoft.Authorization/roleAssignments/delete'})) OR\n(@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAllValues:GuidNotEquals {8e3af657-a8ff-443c-a75c-2fe8c4bcb635, 18d7d88d-d35e-4fb5-a5c3-7773c20a72d9, f58310d9-a9f6-439a-9e8d-f62e7b41a168}))\n\"@\n",[628,2398,2399,2409,2414,2419,2424,2429],{"__ignoreMap":454},[718,2400,2401,2404,2406],{"class":720,"line":46},[718,2402,2403],{"class":733},"$condition ",[718,2405,1003],{"class":1002},[718,2407,2408],{"class":745}," @\"\n",[718,2410,2411],{"class":720,"line":52},[718,2412,2413],{"class":745},"((!(ActionMatches{'Microsoft.Authorization/roleAssignments/write'})) OR\n",[718,2415,2416],{"class":720,"line":88},[718,2417,2418],{"class":745},"(@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAllValues:GuidNotEquals {8e3af657-a8ff-443c-a75c-2fe8c4bcb635, 18d7d88d-d35e-4fb5-a5c3-7773c20a72d9, f58310d9-a9f6-439a-9e8d-f62e7b41a168})) AND\n",[718,2420,2421],{"class":720,"line":99},[718,2422,2423],{"class":745},"((!(ActionMatches{'Microsoft.Authorization/roleAssignments/delete'})) OR\n",[718,2425,2426],{"class":720,"line":760},[718,2427,2428],{"class":745},"(@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAllValues:GuidNotEquals {8e3af657-a8ff-443c-a75c-2fe8c4bcb635, 18d7d88d-d35e-4fb5-a5c3-7773c20a72d9, f58310d9-a9f6-439a-9e8d-f62e7b41a168}))\n",[718,2430,2431],{"class":720,"line":771},[718,2432,2433],{"class":745},"\"@\n",[569,2435,420],{"id":2436},"step-2-create-the-pim-assignment-with-condition",[573,2438,2439],{},"We'll use PowerShell to create a PIM eligible assignment for Owner, but with our condition attached. That way, when someone activates Owner, they're still blocked from assigning those privileged roles.",[573,2441,2442],{},"Here's how we can do this:",[710,2444,2446],{"className":1298,"code":2445,"language":1300,"meta":454,"style":454},"# Prerequisites: You should already have your $headers (see my PIM as code post)\n\n$subscription = Get-AzSubscription -SubscriptionId 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' # > replace this with your own subscription ID\n# Switch to the target subscription\nSet-AzContext -Subscription $subscription\n$principalId = '00000000-0000-0000-0000-000000000002' ## your Entra group/user ID\n$condition = @\"\n((!(ActionMatches{'Microsoft.Authorization/roleAssignments/write'})) OR\n(@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAllValues:GuidNotEquals {8e3af657-a8ff-443c-a75c-2fe8c4bcb635, 18d7d88d-d35e-4fb5-a5c3-7773c20a72d9, f58310d9-a9f6-439a-9e8d-f62e7b41a168})) AND\n((!(ActionMatches{'Microsoft.Authorization/roleAssignments/delete'})) OR\n(@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAllValues:GuidNotEquals {8e3af657-a8ff-443c-a75c-2fe8c4bcb635, 18d7d88d-d35e-4fb5-a5c3-7773c20a72d9, f58310d9-a9f6-439a-9e8d-f62e7b41a168}))\n\"@\n\n$roleDefinitionId = '8e3af657-a8ff-443c-a75c-2fe8c4bcb635' # Owner\n$guid = [guid]::NewGuid()\n\n$createEligibleRoleUri = \"https://management.azure.com/providers/Microsoft.Subscription/subscriptions/{0}/providers/Microsoft.Authorization/roleEligibilityScheduleRequests/{1}?api-version=2020-10-01\" -f $subscription.Id, $guid\n\n$body = @{\n    Properties = @{\n        RoleDefinitionID = \"/subscriptions/$Subscription.Id/providers/Microsoft.Authorization/roleDefinitions/$contributorRoleId\"\n        PrincipalId      = $pimRequestorGroup.Id\n        RequestType      = 'AdminAssign'\n        ScheduleInfo     = @{\n            Expiration = @{\n                Type = 'NoExpiration'\n            }\n        }\n    }\n}\n$guid = [guid]::NewGuid()\n# Construct Uri with subscription Id and new GUID\n$createEligibleRoleUri = \"https://management.azure.com/providers/Microsoft.Subscription/subscriptions/{0}/providers/Microsoft.Authorization/roleEligibilityScheduleRequests/{1}?api-version=2020-10-01\" -f $Subscription.Id, $guid\n\n$body = @{\n    properties = @{\n        roleDefinitionId = \"/subscriptions/$($subscription.Id)/providers/Microsoft.Authorization/roleDefinitions/$roleDefinitionId\"\n        principalId      = $principalId\n        requestType      = 'AdminAssign'\n        condition        = $condition\n        conditionVersion = '2.0'\n        scheduleInfo     = @{\n            expiration= @{\n                type = \"AfterDuration\"\n                endDateTime = $null\n                duration = \"P365D\"\n            }\n        }\n    }\n}\n\n# Call the API with PUT to assign the role to the targeted principal with the condition\nInvoke-RestMethod -Uri $createEligibleRoleUri -Method Put -Headers $headers -Body ($body | ConvertTo-Json -Depth 10)\n",[628,2447,2448,2453,2457,2479,2484,2494,2507,2515,2519,2523,2527,2531,2535,2539,2552,2568,2572,2594,2598,2611,2622,2644,2654,2664,2675,2687,2698,2704,2710,2716,2722,2735,2741,2759,2764,2775,2787,2816,2827,2837,2848,2859,2871,2883,2894,2905,2916,2921,2926,2931,2936,2941,2947],{"__ignoreMap":454},[718,2449,2450],{"class":720,"line":46},[718,2451,2452],{"class":723},"# Prerequisites: You should already have your $headers (see my PIM as code post)\n",[718,2454,2455],{"class":720,"line":52},[718,2456,797],{"emptyLinePlaceholder":796},[718,2458,2459,2462,2464,2467,2470,2473,2476],{"class":720,"line":88},[718,2460,2461],{"class":733},"$subscription ",[718,2463,1003],{"class":1002},[718,2465,2466],{"class":756}," Get-AzSubscription",[718,2468,2469],{"class":1002}," -",[718,2471,2472],{"class":733},"SubscriptionId ",[718,2474,2475],{"class":745},"'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'",[718,2477,2478],{"class":723}," # > replace this with your own subscription ID\n",[718,2480,2481],{"class":720,"line":99},[718,2482,2483],{"class":723},"# Switch to the target subscription\n",[718,2485,2486,2489,2491],{"class":720,"line":760},[718,2487,2488],{"class":756},"Set-AzContext",[718,2490,2469],{"class":1002},[718,2492,2493],{"class":733},"Subscription $subscription\n",[718,2495,2496,2499,2501,2504],{"class":720,"line":771},[718,2497,2498],{"class":733},"$principalId ",[718,2500,1003],{"class":1002},[718,2502,2503],{"class":745}," '00000000-0000-0000-0000-000000000002'",[718,2505,2506],{"class":723}," ## your Entra group/user ID\n",[718,2508,2509,2511,2513],{"class":720,"line":782},[718,2510,2403],{"class":733},[718,2512,1003],{"class":1002},[718,2514,2408],{"class":745},[718,2516,2517],{"class":720,"line":793},[718,2518,2413],{"class":745},[718,2520,2521],{"class":720,"line":800},[718,2522,2418],{"class":745},[718,2524,2525],{"class":720,"line":808},[718,2526,2423],{"class":745},[718,2528,2529],{"class":720,"line":822},[718,2530,2428],{"class":745},[718,2532,2533],{"class":720,"line":830},[718,2534,2433],{"class":745},[718,2536,2537],{"class":720,"line":841},[718,2538,797],{"emptyLinePlaceholder":796},[718,2540,2541,2544,2546,2549],{"class":720,"line":852},[718,2542,2543],{"class":733},"$roleDefinitionId ",[718,2545,1003],{"class":1002},[718,2547,2548],{"class":745}," '8e3af657-a8ff-443c-a75c-2fe8c4bcb635'",[718,2550,2551],{"class":723}," # Owner\n",[718,2553,2554,2557,2559,2562,2565],{"class":720,"line":863},[718,2555,2556],{"class":733},"$guid ",[718,2558,1003],{"class":1002},[718,2560,2561],{"class":733}," [",[718,2563,2564],{"class":1002},"guid",[718,2566,2567],{"class":733},"]::NewGuid()\n",[718,2569,2570],{"class":720,"line":874},[718,2571,797],{"emptyLinePlaceholder":796},[718,2573,2574,2577,2579,2582,2585,2588,2591],{"class":720,"line":879},[718,2575,2576],{"class":733},"$createEligibleRoleUri ",[718,2578,1003],{"class":1002},[718,2580,2581],{"class":745}," \"https://management.azure.com/providers/Microsoft.Subscription/subscriptions/{0}/providers/Microsoft.Authorization/roleEligibilityScheduleRequests/{1}?api-version=2020-10-01\"",[718,2583,2584],{"class":1002}," -f",[718,2586,2587],{"class":733}," $subscription.Id",[718,2589,2590],{"class":1002},",",[718,2592,2593],{"class":733}," $guid\n",[718,2595,2596],{"class":720,"line":891},[718,2597,797],{"emptyLinePlaceholder":796},[718,2599,2600,2603,2605,2608],{"class":720,"line":898},[718,2601,2602],{"class":733},"$body ",[718,2604,1003],{"class":1002},[718,2606,2607],{"class":1002}," @",[718,2609,2610],{"class":733},"{\n",[718,2612,2613,2616,2618,2620],{"class":720,"line":907},[718,2614,2615],{"class":733},"    Properties ",[718,2617,1003],{"class":1002},[718,2619,2607],{"class":1002},[718,2621,2610],{"class":733},[718,2623,2624,2627,2629,2632,2635,2638,2641],{"class":720,"line":917},[718,2625,2626],{"class":733},"        RoleDefinitionID ",[718,2628,1003],{"class":1002},[718,2630,2631],{"class":745}," \"/subscriptions/",[718,2633,2634],{"class":733},"$Subscription",[718,2636,2637],{"class":745},".Id/providers/Microsoft.Authorization/roleDefinitions/",[718,2639,2640],{"class":733},"$contributorRoleId",[718,2642,2643],{"class":745},"\"\n",[718,2645,2646,2649,2651],{"class":720,"line":927},[718,2647,2648],{"class":733},"        PrincipalId      ",[718,2650,1003],{"class":1002},[718,2652,2653],{"class":733}," $pimRequestorGroup.Id\n",[718,2655,2656,2659,2661],{"class":720,"line":937},[718,2657,2658],{"class":733},"        RequestType      ",[718,2660,1003],{"class":1002},[718,2662,2663],{"class":745}," 'AdminAssign'\n",[718,2665,2666,2669,2671,2673],{"class":720,"line":945},[718,2667,2668],{"class":733},"        ScheduleInfo     ",[718,2670,1003],{"class":1002},[718,2672,2607],{"class":1002},[718,2674,2610],{"class":733},[718,2676,2678,2681,2683,2685],{"class":720,"line":2677},25,[718,2679,2680],{"class":733},"            Expiration ",[718,2682,1003],{"class":1002},[718,2684,2607],{"class":1002},[718,2686,2610],{"class":733},[718,2688,2690,2693,2695],{"class":720,"line":2689},26,[718,2691,2692],{"class":733},"                Type ",[718,2694,1003],{"class":1002},[718,2696,2697],{"class":745}," 'NoExpiration'\n",[718,2699,2701],{"class":720,"line":2700},27,[718,2702,2703],{"class":733},"            }\n",[718,2705,2707],{"class":720,"line":2706},28,[718,2708,2709],{"class":733},"        }\n",[718,2711,2713],{"class":720,"line":2712},29,[718,2714,2715],{"class":733},"    }\n",[718,2717,2719],{"class":720,"line":2718},30,[718,2720,2721],{"class":733},"}\n",[718,2723,2725,2727,2729,2731,2733],{"class":720,"line":2724},31,[718,2726,2556],{"class":733},[718,2728,1003],{"class":1002},[718,2730,2561],{"class":733},[718,2732,2564],{"class":1002},[718,2734,2567],{"class":733},[718,2736,2738],{"class":720,"line":2737},32,[718,2739,2740],{"class":723},"# Construct Uri with subscription Id and new GUID\n",[718,2742,2744,2746,2748,2750,2752,2755,2757],{"class":720,"line":2743},33,[718,2745,2576],{"class":733},[718,2747,1003],{"class":1002},[718,2749,2581],{"class":745},[718,2751,2584],{"class":1002},[718,2753,2754],{"class":733}," $Subscription.Id",[718,2756,2590],{"class":1002},[718,2758,2593],{"class":733},[718,2760,2762],{"class":720,"line":2761},34,[718,2763,797],{"emptyLinePlaceholder":796},[718,2765,2767,2769,2771,2773],{"class":720,"line":2766},35,[718,2768,2602],{"class":733},[718,2770,1003],{"class":1002},[718,2772,2607],{"class":1002},[718,2774,2610],{"class":733},[718,2776,2778,2781,2783,2785],{"class":720,"line":2777},36,[718,2779,2780],{"class":733},"    properties ",[718,2782,1003],{"class":1002},[718,2784,2607],{"class":1002},[718,2786,2610],{"class":733},[718,2788,2790,2793,2795,2797,2800,2803,2806,2808,2811,2814],{"class":720,"line":2789},37,[718,2791,2792],{"class":733},"        roleDefinitionId ",[718,2794,1003],{"class":1002},[718,2796,2631],{"class":745},[718,2798,2799],{"class":1002},"$",[718,2801,2802],{"class":745},"(",[718,2804,2805],{"class":733},"$subscription.Id",[718,2807,1566],{"class":745},[718,2809,2810],{"class":745},"/providers/Microsoft.Authorization/roleDefinitions/",[718,2812,2813],{"class":733},"$roleDefinitionId",[718,2815,2643],{"class":745},[718,2817,2819,2822,2824],{"class":720,"line":2818},38,[718,2820,2821],{"class":733},"        principalId      ",[718,2823,1003],{"class":1002},[718,2825,2826],{"class":733}," $principalId\n",[718,2828,2830,2833,2835],{"class":720,"line":2829},39,[718,2831,2832],{"class":733},"        requestType      ",[718,2834,1003],{"class":1002},[718,2836,2663],{"class":745},[718,2838,2840,2843,2845],{"class":720,"line":2839},40,[718,2841,2842],{"class":733},"        condition        ",[718,2844,1003],{"class":1002},[718,2846,2847],{"class":733}," $condition\n",[718,2849,2851,2854,2856],{"class":720,"line":2850},41,[718,2852,2853],{"class":733},"        conditionVersion ",[718,2855,1003],{"class":1002},[718,2857,2858],{"class":745}," '2.0'\n",[718,2860,2862,2865,2867,2869],{"class":720,"line":2861},42,[718,2863,2864],{"class":733},"        scheduleInfo     ",[718,2866,1003],{"class":1002},[718,2868,2607],{"class":1002},[718,2870,2610],{"class":733},[718,2872,2874,2877,2879,2881],{"class":720,"line":2873},43,[718,2875,2876],{"class":733},"            expiration",[718,2878,1003],{"class":1002},[718,2880,2607],{"class":1002},[718,2882,2610],{"class":733},[718,2884,2886,2889,2891],{"class":720,"line":2885},44,[718,2887,2888],{"class":733},"                type ",[718,2890,1003],{"class":1002},[718,2892,2893],{"class":745}," \"AfterDuration\"\n",[718,2895,2897,2900,2902],{"class":720,"line":2896},45,[718,2898,2899],{"class":733},"                endDateTime ",[718,2901,1003],{"class":1002},[718,2903,2904],{"class":756}," $null\n",[718,2906,2908,2911,2913],{"class":720,"line":2907},46,[718,2909,2910],{"class":733},"                duration ",[718,2912,1003],{"class":1002},[718,2914,2915],{"class":745}," \"P365D\"\n",[718,2917,2919],{"class":720,"line":2918},47,[718,2920,2703],{"class":733},[718,2922,2924],{"class":720,"line":2923},48,[718,2925,2709],{"class":733},[718,2927,2929],{"class":720,"line":2928},49,[718,2930,2715],{"class":733},[718,2932,2934],{"class":720,"line":2933},50,[718,2935,2721],{"class":733},[718,2937,2939],{"class":720,"line":2938},51,[718,2940,797],{"emptyLinePlaceholder":796},[718,2942,2944],{"class":720,"line":2943},52,[718,2945,2946],{"class":723},"# Call the API with PUT to assign the role to the targeted principal with the condition\n",[718,2948,2950,2953,2955,2958,2961,2964,2966,2969,2971,2974,2977,2980,2982,2985,2988],{"class":720,"line":2949},53,[718,2951,2952],{"class":756},"Invoke-RestMethod",[718,2954,2469],{"class":1002},[718,2956,2957],{"class":733},"Uri $createEligibleRoleUri ",[718,2959,2960],{"class":1002},"-",[718,2962,2963],{"class":733},"Method Put ",[718,2965,2960],{"class":1002},[718,2967,2968],{"class":733},"Headers $headers ",[718,2970,2960],{"class":1002},[718,2972,2973],{"class":733},"Body ($body ",[718,2975,2976],{"class":1002},"|",[718,2978,2979],{"class":756}," ConvertTo-Json",[718,2981,2469],{"class":1002},[718,2983,2984],{"class":733},"Depth ",[718,2986,2987],{"class":756},"10",[718,2989,2990],{"class":733},")\n",[573,2992,2993],{},"This makes the user or group eligible for Owner, but when they activate, the condition kicks in and blocks them from assigning or deleting those privileged roles. Everything else works as usual.",[569,2995,425],{"id":2996},"step-3-test-and-verify",[573,2998,2999],{},"After running the commands I checked the role assignments. The role assignment from my previous blog looked like this:",[573,3001,3002],{},[590,3003],{"alt":3004,"src":3005},"verifying role assignments","/images/blog/pim-conditional-role-assignments/role-assignments-overview.png",[573,3007,3008],{},"See the Active Permanent state? After deleting that role assignment, and creating the one with PIM it changed to:",[573,3010,3011],{},[590,3012],{"alt":3004,"src":3013},"/images/blog/pim-conditional-role-assignments/eligible-role-assignment-with-condition.png",[569,3015,430],{"id":3016},"benefits",[573,3018,3019],{},"This pattern gives you:",[1519,3021,3022,3028,3034,3040],{},[1198,3023,3024,3027],{},[583,3025,3026],{},"Least Privilege",": Even when teams activate Owner, they can't escalate further.",[1198,3029,3030,3033],{},[583,3031,3032],{},"Just-In-Time access",": Give users permissions only for the duration required.",[1198,3035,3036,3039],{},[583,3037,3038],{},"Autonomy",": Teams can self-activate when needed, no more waiting for tickets.",[1198,3041,3042,3045],{},[583,3043,3044],{},"Auditability",": Every activation and failed assignment attempt is logged.",[569,3047,435],{"id":3048},"wrapping-up",[573,3050,3051],{},"By combining PIM eligible roles with conditional role assignments, you get the best of both worlds: teams can move fast, as platform you stay in control.",[573,3053,3054],{},"As always, leave a comment on LinkedIn if you have any questions. Happy coding! ☕",[1791,3056,3057],{},"html pre.shiki code .slsVL, html code.shiki .slsVL{--shiki-light:#24292E;--shiki-default:#E1E4E8;--shiki-dark:#E1E4E8}html pre.shiki code .so5gQ, html code.shiki .so5gQ{--shiki-light:#D73A49;--shiki-default:#F97583;--shiki-dark:#F97583}html pre.shiki code .sfrk1, html code.shiki .sfrk1{--shiki-light:#032F62;--shiki-default:#9ECBFF;--shiki-dark:#9ECBFF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sCsY4, html code.shiki .sCsY4{--shiki-light:#6A737D;--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .suiK_, html code.shiki .suiK_{--shiki-light:#005CC5;--shiki-default:#79B8FF;--shiki-dark:#79B8FF}",{"title":454,"searchDepth":52,"depth":52,"links":3059},[3060,3061,3062,3063,3064,3065,3066],{"id":2365,"depth":52,"text":405},{"id":2385,"depth":52,"text":410},{"id":2390,"depth":52,"text":415},{"id":2436,"depth":52,"text":420},{"id":2996,"depth":52,"text":425},{"id":3016,"depth":52,"text":430},{"id":3048,"depth":52,"text":435},"/images/blog/azure-privileged-identity-management-as-code/padlock.jpeg","2025-07-08","Combine PIM eligible roles with conditional role assignments to give teams just-in-time Owner access while preventing privilege escalation.",{},{"title":34,"description":3069},[2181,3073,3074,3075,3076,3077],"RBAC","PIM","Security","IAM","Identity","a87DIPRuy_nhvmjWxojLH3vEvHk7IKFzITVsCJPqxd0",{"id":3080,"title":18,"audience":564,"body":3081,"canonical":564,"cover":3948,"cta":564,"date":3949,"description":3950,"extension":1827,"locale":1828,"meta":3951,"navigation":796,"outcome":564,"path":19,"problem":564,"readingTime":760,"seo":3952,"stem":20,"tags":3953,"translationOf":564,"updatedAt":564,"__hash__":3954},"blog/blog/conditional-role-assignments.md",{"type":566,"value":3082,"toc":3932},[3083,3086,3089,3092,3100,3103,3106,3109,3123,3126,3129,3132,3149,3152,3155,3169,3172,3175,3178,3216,3219,3222,3229,3269,3272,3275,3278,3411,3414,3417,3428,3431,3440,3443,3578,3581,3588,3595,3713,3719,3802,3809,3812,3815,3858,3861,3864,3869,3872,3878,3881,3886,3889,3892,3918,3921,3924,3927,3929],[573,3084,3085],{},"In modern cloud environments, finding the right balance between workload autonomy and security control is crucial. While development teams need extensive permissions to manage their resources effectively, security teams must ensure these privileges don't compromise the organization's security posture. Azure's conditional role assignments provide an elegant solution to this challenge, allowing us to grant broad permissions while maintaining strict security boundaries.",[569,3087,168],{"id":3088},"the-challenge",[573,3090,3091],{},"Traditional role-based access control (RBAC) often forces organizations to choose between two suboptimal approaches:",[1519,3093,3094,3097],{},[1198,3095,3096],{},"Granting full Owner rights, risking security by allowing teams to escalate privileges",[1198,3098,3099],{},"Implementing restrictive custom roles, creating operational overhead and potential bottlenecks",[573,3101,3102],{},"Conditional role assignments offer a middle ground, enabling us to grant Owner permissions while preventing specific high-risk actions through conditions.",[569,3104,173],{"id":3105},"understanding-role-assignment-conditions",[573,3107,3108],{},"Role assignment conditions in Azure add an extra layer of security by allowing us to specify when and how permissions can be used. These conditions are evaluated at runtime and can reference various attributes of the request context, including:",[1195,3110,3111,3114,3117,3120],{},[1198,3112,3113],{},"The target resource's properties",[1198,3115,3116],{},"The type of action being performed",[1198,3118,3119],{},"The principal's claims",[1198,3121,3122],{},"The environment context",[573,3124,3125],{},"The power of conditions lies in their ability to create fine-grained access controls without sacrificing operational efficiency.",[569,3127,55],{"id":3128},"the-requirements",[573,3130,3131],{},"Before implementing conditional role assignments, ensure you have:",[1195,3133,3134,3137,3140,3143,3146],{},[1198,3135,3136],{},"Access to Azure with permissions to manage role assignments",[1198,3138,3139],{},"Understanding of Azure RBAC and built-in roles",[1198,3141,3142],{},"Familiarity with Bicep or ARM templates",[1198,3144,3145],{},"Azure CLI or Azure PowerShell installed",[1198,3147,3148],{},"Bicep installed",[569,3150,182],{"id":3151},"implementation-strategy",[573,3153,3154],{},"In this guide, we'll focus on implementing a secure workload autonomy pattern with the following objectives:",[1519,3156,3157,3160,3163,3166],{},[1198,3158,3159],{},"Grant Owner permissions to workload teams",[1198,3161,3162],{},"Prevent privilege escalation by blocking critical role assignments",[1198,3164,3165],{},"Maintain audit capabilities",[1198,3167,3168],{},"Implement the solution using Infrastructure as Code",[573,3170,3171],{},"Let's dive into the technical implementation of these requirements.",[569,3173,187],{"id":3174},"role-assignment-configuration",[573,3176,3177],{},"Here's the critical part: we want to grant Owner permissions and at the same time prevent the assignment of privileged roles. We'll achieve this by creating a role assignment with conditions that explicitly block assignments of the following roles:",[601,3179,3180,3190],{},[604,3181,3182],{},[607,3183,3184,3187],{},[610,3185,3186],{},"Role",[610,3188,3189],{},"Role Definition ID",[620,3191,3192,3200,3208],{},[607,3193,3194,3197],{},[625,3195,3196],{},"Owner",[625,3198,3199],{},"8e3af657-a8ff-443c-a75c-2fe8c4bcb635",[607,3201,3202,3205],{},[625,3203,3204],{},"User Access Administrator",[625,3206,3207],{},"18d7d88d-d35e-4fb5-a5c3-7773c20a72d9",[607,3209,3210,3213],{},[625,3211,3212],{},"Role Based Access Control Administrator",[625,3214,3215],{},"f58310d9-a9f6-439a-9e8d-f62e7b41a168",[676,3217,192],{"id":3218},"condition-syntax",[573,3220,3221],{},"The condition checks whether the action the user performs is allowed. In this case, we prevent the user from creating (write) or deleting role assignments with the Role Definition IDs of the above roles. All other roles are allowed to be assigned. You can play around and add other roles as well, or simply turn it around to only allow roles you define.",[573,3223,3224,3225,3228],{},"I use the triple quote to define a multi-line string (",[628,3226,3227],{},"'''","):",[710,3230,3234],{"className":3231,"code":3232,"language":3233,"meta":454,"style":454},"language-bicep shiki shiki-themes github-light github-dark github-dark","condition: '''\n      ((!(ActionMatches{'Microsoft.Authorization/roleAssignments/write'})) OR\n      (@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAllValues:GuidNotEquals {8e3af657-a8ff-443c-a75c-2fe8c4bcb635, 18d7d88d-d35e-4fb5-a5c3-7773c20a72d9, f58310d9-a9f6-439a-9e8d-f62e7b41a168})) AND\n      ((!(ActionMatches{'Microsoft.Authorization/roleAssignments/delete'})) OR\n      (@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAllValues:GuidNotEquals {8e3af657-a8ff-443c-a75c-2fe8c4bcb635, 18d7d88d-d35e-4fb5-a5c3-7773c20a72d9, f58310d9-a9f6-439a-9e8d-f62e7b41a168}))\n      '''\n","bicep",[628,3235,3236,3244,3249,3254,3259,3264],{"__ignoreMap":454},[718,3237,3238,3241],{"class":720,"line":46},[718,3239,3240],{"class":733},"condition: ",[718,3242,3243],{"class":745},"'''\n",[718,3245,3246],{"class":720,"line":52},[718,3247,3248],{"class":745},"      ((!(ActionMatches{'Microsoft.Authorization/roleAssignments/write'})) OR\n",[718,3250,3251],{"class":720,"line":88},[718,3252,3253],{"class":745},"      (@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAllValues:GuidNotEquals {8e3af657-a8ff-443c-a75c-2fe8c4bcb635, 18d7d88d-d35e-4fb5-a5c3-7773c20a72d9, f58310d9-a9f6-439a-9e8d-f62e7b41a168})) AND\n",[718,3255,3256],{"class":720,"line":99},[718,3257,3258],{"class":745},"      ((!(ActionMatches{'Microsoft.Authorization/roleAssignments/delete'})) OR\n",[718,3260,3261],{"class":720,"line":760},[718,3262,3263],{"class":745},"      (@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAllValues:GuidNotEquals {8e3af657-a8ff-443c-a75c-2fe8c4bcb635, 18d7d88d-d35e-4fb5-a5c3-7773c20a72d9, f58310d9-a9f6-439a-9e8d-f62e7b41a168}))\n",[718,3265,3266],{"class":720,"line":771},[718,3267,3268],{"class":745},"      '''\n",[573,3270,3271],{},"This condition ensures that even with Owner permissions, the user cannot grant or delete these privileged roles to/from others.",[676,3273,197],{"id":3274},"bicep-implementation",[573,3276,3277],{},"Here's how we implement this in Bicep:",[710,3279,3281],{"className":3231,"code":3280,"language":3233,"meta":454,"style":454},"param principalId string = '00000000-0000-0000-0000-000000000000' // Replace with the actual principal ID\n\nresource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {\n  name: guid(subscription().id, principalId, 'owner-no-privesc')\n  properties: {\n    principalId: principalId\n    roleDefinitionId: '/subscriptions/${subscription().subscriptionId}/providers/Microsoft.Authorization/roleDefinitions/8e3af657-a8ff-443c-a75c-2fe8c4bcb635' // Owner\n    condition: '''\n      ((!(ActionMatches{'Microsoft.Authorization/roleAssignments/write'})) OR\n      (@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAllValues:GuidNotEquals {8e3af657-a8ff-443c-a75c-2fe8c4bcb635, 18d7d88d-d35e-4fb5-a5c3-7773c20a72d9, f58310d9-a9f6-439a-9e8d-f62e7b41a168})) AND\n      ((!(ActionMatches{'Microsoft.Authorization/roleAssignments/delete'})) OR\n      (@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAllValues:GuidNotEquals {8e3af657-a8ff-443c-a75c-2fe8c4bcb635, 18d7d88d-d35e-4fb5-a5c3-7773c20a72d9, f58310d9-a9f6-439a-9e8d-f62e7b41a168}))\n      '''\n    conditionVersion: '2.0'\n  }\n}\n",[628,3282,3283,3297,3301,3315,3335,3340,3345,3367,3374,3378,3382,3386,3390,3394,3402,3407],{"__ignoreMap":454},[718,3284,3285,3288,3291,3294],{"class":720,"line":46},[718,3286,3287],{"class":1002},"param",[718,3289,3290],{"class":733}," principalId string = ",[718,3292,3293],{"class":745},"'00000000-0000-0000-0000-000000000000'",[718,3295,3296],{"class":723}," // Replace with the actual principal ID\n",[718,3298,3299],{"class":720,"line":52},[718,3300,797],{"emptyLinePlaceholder":796},[718,3302,3303,3306,3309,3312],{"class":720,"line":88},[718,3304,3305],{"class":1002},"resource",[718,3307,3308],{"class":733}," roleAssignment ",[718,3310,3311],{"class":745},"'Microsoft.Authorization/roleAssignments@2022-04-01'",[718,3313,3314],{"class":733}," = {\n",[718,3316,3317,3320,3322,3324,3327,3330,3333],{"class":720,"line":99},[718,3318,3319],{"class":733},"  name: ",[718,3321,2564],{"class":1225},[718,3323,2802],{"class":733},[718,3325,3326],{"class":1225},"subscription",[718,3328,3329],{"class":733},"().id, principalId, ",[718,3331,3332],{"class":745},"'owner-no-privesc'",[718,3334,2990],{"class":733},[718,3336,3337],{"class":720,"line":760},[718,3338,3339],{"class":733},"  properties: {\n",[718,3341,3342],{"class":720,"line":771},[718,3343,3344],{"class":733},"    principalId: principalId\n",[718,3346,3347,3350,3353,3355,3358,3361,3364],{"class":720,"line":782},[718,3348,3349],{"class":733},"    roleDefinitionId: ",[718,3351,3352],{"class":745},"'/subscriptions/${",[718,3354,3326],{"class":1225},[718,3356,3357],{"class":745},"().",[718,3359,3360],{"class":733},"subscriptionId",[718,3362,3363],{"class":745},"}/providers/Microsoft.Authorization/roleDefinitions/8e3af657-a8ff-443c-a75c-2fe8c4bcb635'",[718,3365,3366],{"class":723}," // Owner\n",[718,3368,3369,3372],{"class":720,"line":793},[718,3370,3371],{"class":733},"    condition: ",[718,3373,3243],{"class":745},[718,3375,3376],{"class":720,"line":800},[718,3377,3248],{"class":745},[718,3379,3380],{"class":720,"line":808},[718,3381,3253],{"class":745},[718,3383,3384],{"class":720,"line":822},[718,3385,3258],{"class":745},[718,3387,3388],{"class":720,"line":830},[718,3389,3263],{"class":745},[718,3391,3392],{"class":720,"line":841},[718,3393,3268],{"class":745},[718,3395,3396,3399],{"class":720,"line":852},[718,3397,3398],{"class":733},"    conditionVersion: ",[718,3400,3401],{"class":745},"'2.0'\n",[718,3403,3404],{"class":720,"line":863},[718,3405,3406],{"class":733},"  }\n",[718,3408,3409],{"class":720,"line":874},[718,3410,2721],{"class":733},[569,3412,202],{"id":3413},"permissions-at-scale",[573,3415,3416],{},"When managing many subscriptions, resource groups, or resources, manual RBAC assignments become unmanageable. You need a repeatable, auditable, and secure way to grant and manage access ideally with the ability to:",[1195,3418,3419,3422,3425],{},[1198,3420,3421],{},"Assign roles to users, groups, or managed identities",[1198,3423,3424],{},"Apply conditions for least privilege",[1198,3426,3427],{},"Track and review assignments over time",[569,3429,207],{"id":3430},"enter-azure-verified-modules-avm",[573,3432,3433,3434,3439],{},"The ",[682,3435,3438],{"href":3436,"rel":3437},"https://github.com/Azure/bicep-registry-modules/tree/main/avm/ptn/authorization/role-assignment#example-5-role-assignments-subscription-scope",[686],"AVM Role Assignment module"," lets you declaratively manage role assignments at any scope. Let's look at a practical example for assigning the Owner role at the subscription level, with a condition to prevent privilege escalation.",[573,3441,3442],{},"The Bicep will look almost the same:",[710,3444,3446],{"className":3231,"code":3445,"language":3233,"meta":454,"style":454},"targetScope = 'managementGroup'\n\nparam principalId string = ''\n\nparam location string = 'swedencentral'\n\nparam subscriptionId string = '00000000-0000-0000-0000-000000000000' // Default subscription ID\n\nmodule roleAssignment 'br/public:avm/ptn/authorization/role-assignment:0.2.2' = {\n  name: 'roleAssignmentDeployment'\n  params: {\n    // Required parameters\n    principalId: principalId\n    roleDefinitionIdOrName: 'Reader'\n    // Non-required parameters\n    description: 'Role Assignment (subscription scope)'\n    location: location\n    subscriptionId: subscriptionId\n  }\n}\n",[628,3447,3448,3459,3463,3472,3476,3486,3490,3502,3506,3518,3525,3530,3535,3539,3547,3552,3560,3565,3570,3574],{"__ignoreMap":454},[718,3449,3450,3453,3456],{"class":720,"line":46},[718,3451,3452],{"class":1002},"targetScope",[718,3454,3455],{"class":733}," = ",[718,3457,3458],{"class":745},"'managementGroup'\n",[718,3460,3461],{"class":720,"line":52},[718,3462,797],{"emptyLinePlaceholder":796},[718,3464,3465,3467,3469],{"class":720,"line":88},[718,3466,3287],{"class":1002},[718,3468,3290],{"class":733},[718,3470,3471],{"class":745},"''\n",[718,3473,3474],{"class":720,"line":99},[718,3475,797],{"emptyLinePlaceholder":796},[718,3477,3478,3480,3483],{"class":720,"line":760},[718,3479,3287],{"class":1002},[718,3481,3482],{"class":733}," location string = ",[718,3484,3485],{"class":745},"'swedencentral'\n",[718,3487,3488],{"class":720,"line":771},[718,3489,797],{"emptyLinePlaceholder":796},[718,3491,3492,3494,3497,3499],{"class":720,"line":782},[718,3493,3287],{"class":1002},[718,3495,3496],{"class":733}," subscriptionId string = ",[718,3498,3293],{"class":745},[718,3500,3501],{"class":723}," // Default subscription ID\n",[718,3503,3504],{"class":720,"line":793},[718,3505,797],{"emptyLinePlaceholder":796},[718,3507,3508,3511,3513,3516],{"class":720,"line":800},[718,3509,3510],{"class":1002},"module",[718,3512,3308],{"class":733},[718,3514,3515],{"class":745},"'br/public:avm/ptn/authorization/role-assignment:0.2.2'",[718,3517,3314],{"class":733},[718,3519,3520,3522],{"class":720,"line":808},[718,3521,3319],{"class":733},[718,3523,3524],{"class":745},"'roleAssignmentDeployment'\n",[718,3526,3527],{"class":720,"line":822},[718,3528,3529],{"class":733},"  params: {\n",[718,3531,3532],{"class":720,"line":830},[718,3533,3534],{"class":723},"    // Required parameters\n",[718,3536,3537],{"class":720,"line":841},[718,3538,3344],{"class":733},[718,3540,3541,3544],{"class":720,"line":852},[718,3542,3543],{"class":733},"    roleDefinitionIdOrName: ",[718,3545,3546],{"class":745},"'Reader'\n",[718,3548,3549],{"class":720,"line":863},[718,3550,3551],{"class":723},"    // Non-required parameters\n",[718,3553,3554,3557],{"class":720,"line":874},[718,3555,3556],{"class":733},"    description: ",[718,3558,3559],{"class":745},"'Role Assignment (subscription scope)'\n",[718,3561,3562],{"class":720,"line":879},[718,3563,3564],{"class":733},"    location: location\n",[718,3566,3567],{"class":720,"line":891},[718,3568,3569],{"class":733},"    subscriptionId: subscriptionId\n",[718,3571,3572],{"class":720,"line":898},[718,3573,3406],{"class":733},[718,3575,3576],{"class":720,"line":907},[718,3577,2721],{"class":733},[569,3579,212],{"id":3580},"using-the-module-for-scaled-operations",[573,3582,3583,3584,3587],{},"With the module, you can easily assign roles with conditions to multiple principals or scopes using a ",[628,3585,3586],{},"for"," loop in Bicep. This approach reduces duplication and minimizes the risk of errors.",[573,3589,3590,3591,3594],{},"For example, to assign the Owner role with the privilege escalation prevention condition to several user or group IDs you can pass an array of principals to process. The array will reside in a separate ",[628,3592,3593],{},".bicepparam"," file.",[710,3596,3598],{"className":3231,"code":3597,"language":3233,"meta":454,"style":454},"targetScope = 'managementGroup'\n\nparam ownerPrincipals array = []\n\nmodule OwnerRoleAssignments 'br/public:avm/ptn/authorization/role-assignment:0.2.2' = [\n  for principal in ownerPrincipals: {\n    name: guid(principal.id, 'owner-no-privesc')\n    params: {\n      principalId: principal.id\n      roleDefinitionIdOrName: 'Owner'\n      condition: principal.condition ?? ''\n      conditionVersion: '2.0'\n      subscriptionId: principal.subscriptionId\n    }\n  }\n]\n",[628,3599,3600,3608,3612,3619,3623,3635,3649,3663,3668,3673,3681,3688,3695,3700,3704,3708],{"__ignoreMap":454},[718,3601,3602,3604,3606],{"class":720,"line":46},[718,3603,3452],{"class":1002},[718,3605,3455],{"class":733},[718,3607,3458],{"class":745},[718,3609,3610],{"class":720,"line":52},[718,3611,797],{"emptyLinePlaceholder":796},[718,3613,3614,3616],{"class":720,"line":88},[718,3615,3287],{"class":1002},[718,3617,3618],{"class":733}," ownerPrincipals array = []\n",[718,3620,3621],{"class":720,"line":99},[718,3622,797],{"emptyLinePlaceholder":796},[718,3624,3625,3627,3630,3632],{"class":720,"line":760},[718,3626,3510],{"class":1002},[718,3628,3629],{"class":733}," OwnerRoleAssignments ",[718,3631,3515],{"class":745},[718,3633,3634],{"class":733}," = [\n",[718,3636,3637,3640,3643,3646],{"class":720,"line":771},[718,3638,3639],{"class":1002},"  for",[718,3641,3642],{"class":733}," principal ",[718,3644,3645],{"class":1002},"in",[718,3647,3648],{"class":733}," ownerPrincipals: {\n",[718,3650,3651,3654,3656,3659,3661],{"class":720,"line":782},[718,3652,3653],{"class":733},"    name: ",[718,3655,2564],{"class":1225},[718,3657,3658],{"class":733},"(principal.id, ",[718,3660,3332],{"class":745},[718,3662,2990],{"class":733},[718,3664,3665],{"class":720,"line":793},[718,3666,3667],{"class":733},"    params: {\n",[718,3669,3670],{"class":720,"line":800},[718,3671,3672],{"class":733},"      principalId: principal.id\n",[718,3674,3675,3678],{"class":720,"line":808},[718,3676,3677],{"class":733},"      roleDefinitionIdOrName: ",[718,3679,3680],{"class":745},"'Owner'\n",[718,3682,3683,3686],{"class":720,"line":822},[718,3684,3685],{"class":733},"      condition: principal.condition ?? ",[718,3687,3471],{"class":745},[718,3689,3690,3693],{"class":720,"line":830},[718,3691,3692],{"class":733},"      conditionVersion: ",[718,3694,3401],{"class":745},[718,3696,3697],{"class":720,"line":841},[718,3698,3699],{"class":733},"      subscriptionId: principal.subscriptionId\n",[718,3701,3702],{"class":720,"line":852},[718,3703,2715],{"class":733},[718,3705,3706],{"class":720,"line":863},[718,3707,3406],{"class":733},[718,3709,3710],{"class":720,"line":874},[718,3711,3712],{"class":733},"]\n",[573,3714,3715,3716,3718],{},"And the ",[628,3717,3593],{}," file will have the configuration for each principal you want to assign the permissions to. Remember you can always change the other parameters to include them in the bicepparam, like the assigned role etc. The way you can maximize your scaled operations!",[710,3720,3722],{"className":3231,"code":3721,"language":3233,"meta":454,"style":454},"using 'main.bicep'\n\nparam ownerPrincipals = [\n  {\n    id: '00000000-0000-0000-0000-000000000002'\n    subscriptionId: '00000000-0000-0000-0000-000000000002'\n    condition: '''\n            ((!(ActionMatches{'Microsoft.Authorization/roleAssignments/write'})) OR\n            (@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAllValues:GuidNotEquals {8e3af657-a8ff-443c-a75c-2fe8c4bcb635, 18d7d88d-d35e-4fb5-a5c3-7773c20a72d9, f58310d9-a9f6-439a-9e8d-f62e7b41a168})) AND\n            ((!(ActionMatches{'Microsoft.Authorization/roleAssignments/delete'})) OR\n            (@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAllValues:GuidNotEquals {8e3af657-a8ff-443c-a75c-2fe8c4bcb635, 18d7d88d-d35e-4fb5-a5c3-7773c20a72d9, f58310d9-a9f6-439a-9e8d-f62e7b41a168}))\n        '''\n  }\n]\n",[628,3723,3724,3732,3736,3743,3748,3756,3763,3769,3774,3779,3784,3789,3794,3798],{"__ignoreMap":454},[718,3725,3726,3729],{"class":720,"line":46},[718,3727,3728],{"class":1002},"using",[718,3730,3731],{"class":745}," 'main.bicep'\n",[718,3733,3734],{"class":720,"line":52},[718,3735,797],{"emptyLinePlaceholder":796},[718,3737,3738,3740],{"class":720,"line":88},[718,3739,3287],{"class":1002},[718,3741,3742],{"class":733}," ownerPrincipals = [\n",[718,3744,3745],{"class":720,"line":99},[718,3746,3747],{"class":733},"  {\n",[718,3749,3750,3753],{"class":720,"line":760},[718,3751,3752],{"class":733},"    id: ",[718,3754,3755],{"class":745},"'00000000-0000-0000-0000-000000000002'\n",[718,3757,3758,3761],{"class":720,"line":771},[718,3759,3760],{"class":733},"    subscriptionId: ",[718,3762,3755],{"class":745},[718,3764,3765,3767],{"class":720,"line":782},[718,3766,3371],{"class":733},[718,3768,3243],{"class":745},[718,3770,3771],{"class":720,"line":793},[718,3772,3773],{"class":745},"            ((!(ActionMatches{'Microsoft.Authorization/roleAssignments/write'})) OR\n",[718,3775,3776],{"class":720,"line":800},[718,3777,3778],{"class":745},"            (@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAllValues:GuidNotEquals {8e3af657-a8ff-443c-a75c-2fe8c4bcb635, 18d7d88d-d35e-4fb5-a5c3-7773c20a72d9, f58310d9-a9f6-439a-9e8d-f62e7b41a168})) AND\n",[718,3780,3781],{"class":720,"line":808},[718,3782,3783],{"class":745},"            ((!(ActionMatches{'Microsoft.Authorization/roleAssignments/delete'})) OR\n",[718,3785,3786],{"class":720,"line":822},[718,3787,3788],{"class":745},"            (@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAllValues:GuidNotEquals {8e3af657-a8ff-443c-a75c-2fe8c4bcb635, 18d7d88d-d35e-4fb5-a5c3-7773c20a72d9, f58310d9-a9f6-439a-9e8d-f62e7b41a168}))\n",[718,3790,3791],{"class":720,"line":830},[718,3792,3793],{"class":745},"        '''\n",[718,3795,3796],{"class":720,"line":841},[718,3797,3406],{"class":733},[718,3799,3800],{"class":720,"line":852},[718,3801,3712],{"class":733},[573,3803,3804,3805,3808],{},"This pattern ensures each principal receives the correct assignment, and you only need to update the ",[628,3806,3807],{},"ownerPrincipals"," array to manage access at scale.",[569,3810,217],{"id":3811},"testing-and-verification",[573,3813,3814],{},"I deployed the AVM Bicep module with:",[710,3816,3818],{"className":985,"code":3817,"language":987,"meta":454,"style":454},"az deployment mg create -m 'MyManagementGroupName' --location westeurope --parameters .\\parameters.bicepparam\n",[628,3819,3820],{"__ignoreMap":454},[718,3821,3822,3825,3828,3831,3834,3837,3840,3843,3846,3849,3852,3855],{"class":720,"line":46},[718,3823,3824],{"class":1225},"az",[718,3826,3827],{"class":745}," deployment",[718,3829,3830],{"class":745}," mg",[718,3832,3833],{"class":745}," create",[718,3835,3836],{"class":756}," -m",[718,3838,3839],{"class":745}," 'MyManagementGroupName'",[718,3841,3842],{"class":756}," --location",[718,3844,3845],{"class":745}," westeurope",[718,3847,3848],{"class":756}," --parameters",[718,3850,3851],{"class":745}," .",[718,3853,3854],{"class":756},"\\p",[718,3856,3857],{"class":745},"arameters.bicepparam\n",[573,3859,3860],{},"Hint: you should use a pipeline for that, but that's not part of this blog.",[573,3862,3863],{},"In the portal, I went to my subscription and checked the role assignments:",[573,3865,3866],{},[590,3867],{"alt":3004,"src":3868},"/images/blog/conditional-role-assignments/role-assignments-overview.png",[573,3870,3871],{},"After clicking View/Edit I saw there was a configuration:",[573,3873,3874],{},[590,3875],{"alt":3876,"src":3877},"role assignment condition configured","/images/blog/conditional-role-assignments/role-assignment-with-condition.png",[573,3879,3880],{},"Then I checked the conditions:",[573,3882,3883],{},[590,3884],{"alt":3876,"src":3885},"/images/blog/conditional-role-assignments/role-conditions-details.png",[569,3887,222],{"id":3888},"benefits-and-considerations",[573,3890,3891],{},"This approach offers several advantages:",[1519,3893,3894,3900,3906,3912],{},[1198,3895,3896,3899],{},[583,3897,3898],{},"Operational Efficiency",": Teams are empowered to manage their own resources independently, reducing the need to request additional permissions from administrators.",[1198,3901,3902,3905],{},[583,3903,3904],{},"Enhanced Security",": Sensitive role assignments are protected, minimizing the risk of privilege escalation and unauthorized access.",[1198,3907,3908,3911],{},[583,3909,3910],{},"Simplified Management",": There's no longer a need to create and maintain complex custom roles, streamlining access control.",[1198,3913,3914,3917],{},[583,3915,3916],{},"Scalable Solution",": This approach can be easily implemented across many subscriptions, making it suitable for organizations of any size.",[573,3919,3920],{},"However, keep in mind that conditions add complexity to role assignments. Testing is crucial to ensure conditions work as expected. And always keep monitoring and auditing!",[569,3922,227],{"id":3923},"compliance",[573,3925,3926],{},"So next time you need to report which privileged roles are assigned, you could simply use your codebase as proof! This multi-layered approach ensures both security and operational efficiency.",[573,3928,3054],{},[1791,3930,3931],{},"html pre.shiki code .slsVL, html code.shiki .slsVL{--shiki-light:#24292E;--shiki-default:#E1E4E8;--shiki-dark:#E1E4E8}html pre.shiki code .sfrk1, html code.shiki .sfrk1{--shiki-light:#032F62;--shiki-default:#9ECBFF;--shiki-dark:#9ECBFF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .so5gQ, html code.shiki .so5gQ{--shiki-light:#D73A49;--shiki-default:#F97583;--shiki-dark:#F97583}html pre.shiki code .sCsY4, html code.shiki .sCsY4{--shiki-light:#6A737D;--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .shcOC, html code.shiki .shcOC{--shiki-light:#6F42C1;--shiki-default:#B392F0;--shiki-dark:#B392F0}html pre.shiki code .suiK_, html code.shiki .suiK_{--shiki-light:#005CC5;--shiki-default:#79B8FF;--shiki-dark:#79B8FF}",{"title":454,"searchDepth":52,"depth":52,"links":3933},[3934,3935,3936,3937,3938,3942,3943,3944,3945,3946,3947],{"id":3088,"depth":52,"text":168},{"id":3105,"depth":52,"text":173},{"id":3128,"depth":52,"text":55},{"id":3151,"depth":52,"text":182},{"id":3174,"depth":52,"text":187,"children":3939},[3940,3941],{"id":3218,"depth":88,"text":192},{"id":3274,"depth":88,"text":197},{"id":3413,"depth":52,"text":202},{"id":3430,"depth":52,"text":207},{"id":3580,"depth":52,"text":212},{"id":3811,"depth":52,"text":217},{"id":3888,"depth":52,"text":222},{"id":3923,"depth":52,"text":227},"/images/blog/conditional-role-assignments/cover.png","2025-07-01","Implement secure workload autonomy using Azure conditional role assignments with Bicep and Azure Verified Modules.",{},{"title":18,"description":3950},[2181,3073,3075,3076,3077],"AISA4ZKPN0CFZHIMiQEiYyvJoQILwIdn0J5BETRNcvM",{"id":3956,"title":22,"audience":564,"body":3957,"canonical":564,"cover":6068,"cta":564,"date":6069,"description":6070,"extension":1827,"locale":1828,"meta":6071,"navigation":796,"outcome":564,"path":23,"problem":564,"readingTime":771,"seo":6072,"stem":24,"tags":6073,"translationOf":564,"updatedAt":564,"__hash__":6078},"blog/blog/entraid-banned-password-list.md",{"type":566,"value":3958,"toc":6052},[3959,3962,3965,3968,3982,3985,3987,3990,4020,4023,4026,4029,4040,4043,4057,4060,4063,4073,4076,4079,4087,4090,4093,4099,4102,4110,4113,4119,4122,4128,4131,4134,4141,4352,4355,4359,4362,4368,4520,4523,4530,4533,4536,4547,4550,4652,4655,5290,5293,5519,5522,5585,5588,5591,5645,5648,5847,5850,5982,5985,5988,5994,5997,6003,6006,6009,6029,6032,6035,6046,6049],[573,3960,3961],{},"In today's complex cloud environments, managing password security across multiple tenants is a critical challenge for IT administrators. Microsoft Entra ID provides powerful mechanisms to implement centralized password policies, but effective implementation requires careful planning and robust automation.",[569,3963,235],{"id":3964},"graph-api",[573,3966,3967],{},"Microsoft Graph API revolutionizes Entra ID tenant management by providing a powerful automation framework that simplifies complex multi-tenant configurations. With this API, you can effortlessly streamline identity and security management through comprehensive bulk operations, including:",[1195,3969,3970,3973,3976,3979],{},[1198,3971,3972],{},"Automated user creation",[1198,3974,3975],{},"Efficient group management",[1198,3977,3978],{},"Privileged Identity Management (PIM) role assignments",[1198,3980,3981],{},"Granular password policy enforcement",[573,3983,3984],{},"The API's finely tuned permission model lets you stay in control with precision, ensuring every automation script aligns with the principle of least privilege. This not only keeps your operations smooth but also fortifies your identity infrastructure's security. By embracing Microsoft Graph API, you can shift from time-consuming manual processes to efficient, repeatable workflows that boost consistency and cut down on administrative overhead. Sounds like a win, right? Let's explore how to make it happen.",[569,3986,55],{"id":3128},[573,3988,3989],{},"Before diving into the implementation, ensure you have:",[1195,3991,3992,3995,3998,4001,4009],{},[1198,3993,3994],{},"An IDE (such as Visual Studio Code)",[1198,3996,3997],{},"Entra ID Premium license",[1198,3999,4000],{},"Azure DevOps access with sufficient permissions",[1198,4002,4003,4004],{},"Service connection(s) that have the ",[682,4005,4008],{"href":4006,"rel":4007},"https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference#authentication-policy-administrator",[686],"Authentication Policy Administrator permission",[1198,4010,4011,4012],{},"Proficiency in PowerShell\n",[1195,4013,4014,4017],{},[1198,4015,4016],{},"Azure PowerShell module",[1198,4018,4019],{},"Microsoft Graph module",[573,4021,4022],{},"With these prerequisites ready, you're all set to start building automated solutions that simplify your tenant management.",[569,4024,244],{"id":4025},"scope",[573,4027,4028],{},"Today we will focus on the following subjects:",[1195,4030,4031,4034,4037],{},[1198,4032,4033],{},"Centralizing banned password management",[1198,4035,4036],{},"Supporting multi-tenant password restrictions",[1198,4038,4039],{},"Automating policy deployment through Azure DevOps",[573,4041,4042],{},"To prevent the blog from becoming too long, a few topics are out-of-scope:",[1195,4044,4045,4048,4051,4054],{},[1198,4046,4047],{},"Multi-tenant deployment strategy",[1198,4049,4050],{},"Setting up Service Connections in Azure DevOps",[1198,4052,4053],{},"Creating branch policies in Azure DevOps",[1198,4055,4056],{},"Various testing/error handling",[569,4058,60],{"id":4059},"configuration-steps",[573,4061,4062],{},"Before we dive into the code, I will summarize the order of what we will do:",[1519,4064,4065,4067,4070],{},[1198,4066,268],{},[1198,4068,4069],{},"Draft a PowerShell script to update the settings in Entra ID",[1198,4071,4072],{},"Create a YAML-pipeline for Azure DevOps that runs automatically",[573,4074,4075],{},"In the end you will have a basic automated way to update Banned Password Lists in multiple Entra ID tenants!",[569,4077,253],{"id":4078},"code-implementation",[573,4080,4081,4082,2113],{},"The code for this blog is hosted on my ",[682,4083,4086],{"href":4084,"rel":4085},"https://github.com/jdgoeij/blogcode",[686],"public Github repository",[676,4088,258],{"id":4089},"folder-structure",[573,4091,4092],{},"To be able to create multi-tenant deployments, we are going to parameterize the banned password list settings per tenant. Therefore, we need a consistent folder structure to support this type of deployment.",[710,4094,4097],{"className":4095,"code":4096,"language":1347},[1345],"bannedPasswords\n├── code\n│   ├── Set-PasswordSettings.ps1\n├── parameters\n│   ├── passwordSettings.json\n│   ├── bannedPasswords-tenantA.json\n│   └── bannedPasswords-tenantB.json\n├── pipelines\n│   ├── set-password-settings.yaml\n",[628,4098,4096],{"__ignoreMap":454},[676,4100,263],{"id":4101},"uri-background",[573,4103,4104,4105,2113],{},"To set the banned password list, we need to update the Entra ID setting 'Password Rule Settings'. This one is currently in beta only, so as always be aware things may change in the future. ",[682,4106,4109],{"href":4107,"rel":4108},"https://learn.microsoft.com/en-us/graph/api/group-list-settings?view=graph-rest-beta&tabs=http",[686],"See the docs for more information",[573,4111,4112],{},"The URI is:",[710,4114,4117],{"className":4115,"code":4116,"language":1347},[1345],"URI https://graph.microsoft.com/beta/settings\n",[628,4118,4116],{"__ignoreMap":454},[573,4120,4121],{},"Getting the settings returns the following setting types:",[710,4123,4126],{"className":4124,"code":4125,"language":1347},[1345],"id          : xxxxxxxx-d947-4d19-a028-xxxxxxxxxxxx\ndisplayName : Group.Unified\ntemplateId  : 62375ab9-6b52-47ed-826b-58e47e0e304b\nvalues      : {…}\n\nid          : xxxxxxxx-7701-4e25-8c81-xxxxxxxxxxxx\ndisplayName : Password Rule Settings\ntemplateId  : 5cf42378-d67d-4f36-ba46-e8b86229381d\nvalues      : {…}\n",[628,4127,4125],{"__ignoreMap":454},[573,4129,4130],{},"The API URI targets 'settings', which manages multiple Entra ID settings. Therefore, we target 'Password Rule Settings' to validate we use the correct template ID to use in our URI. This will become clear in the PowerShell code snippet below.",[676,4132,268],{"id":4133},"createprepare-the-required-payload-body-files-for-the-api",[573,4135,4136,4137,4140],{},"The Password Rule Settings expects a JSON-file body (",[628,4138,4139],{},"passwordSettings.json",") that contains the settings to update. The file has the following structure:",[710,4142,4145],{"className":4143,"code":4144,"language":1434,"meta":454,"style":454},"language-json shiki shiki-themes github-light github-dark github-dark","{\n  \"templateId\": \"5cf42378-d67d-4f36-ba46-e8b86229381d\",\n  \"values\": [\n    {\n      \"name\": \"BannedPasswordCheckOnPremisesMode\",\n      \"value\": \"Enforce\"\n    },\n    {\n      \"name\": \"EnableBannedPasswordCheckOnPremises\",\n      \"value\": \"True\"\n    },\n    {\n      \"name\": \"EnableBannedPasswordCheck\",\n      \"value\": \"True\"\n    },\n    {\n      \"name\": \"LockoutDurationInSeconds\",\n      \"value\": \"60\"\n    },\n    {\n      \"name\": \"LockoutThreshold\",\n      \"value\": \"10\"\n    },\n    {\n      \"name\": \"BannedPasswordList\",\n      \"value\": \"placeholder\"\n    }\n  ]\n}\n",[628,4146,4147,4151,4164,4172,4177,4189,4199,4204,4208,4219,4228,4232,4236,4247,4255,4259,4263,4274,4283,4287,4291,4302,4311,4315,4319,4330,4339,4343,4348],{"__ignoreMap":454},[718,4148,4149],{"class":720,"line":46},[718,4150,2610],{"class":733},[718,4152,4153,4156,4158,4161],{"class":720,"line":52},[718,4154,4155],{"class":756},"  \"templateId\"",[718,4157,742],{"class":733},[718,4159,4160],{"class":745},"\"5cf42378-d67d-4f36-ba46-e8b86229381d\"",[718,4162,4163],{"class":733},",\n",[718,4165,4166,4169],{"class":720,"line":88},[718,4167,4168],{"class":756},"  \"values\"",[718,4170,4171],{"class":733},": [\n",[718,4173,4174],{"class":720,"line":99},[718,4175,4176],{"class":733},"    {\n",[718,4178,4179,4182,4184,4187],{"class":720,"line":760},[718,4180,4181],{"class":756},"      \"name\"",[718,4183,742],{"class":733},[718,4185,4186],{"class":745},"\"BannedPasswordCheckOnPremisesMode\"",[718,4188,4163],{"class":733},[718,4190,4191,4194,4196],{"class":720,"line":771},[718,4192,4193],{"class":756},"      \"value\"",[718,4195,742],{"class":733},[718,4197,4198],{"class":745},"\"Enforce\"\n",[718,4200,4201],{"class":720,"line":782},[718,4202,4203],{"class":733},"    },\n",[718,4205,4206],{"class":720,"line":793},[718,4207,4176],{"class":733},[718,4209,4210,4212,4214,4217],{"class":720,"line":800},[718,4211,4181],{"class":756},[718,4213,742],{"class":733},[718,4215,4216],{"class":745},"\"EnableBannedPasswordCheckOnPremises\"",[718,4218,4163],{"class":733},[718,4220,4221,4223,4225],{"class":720,"line":808},[718,4222,4193],{"class":756},[718,4224,742],{"class":733},[718,4226,4227],{"class":745},"\"True\"\n",[718,4229,4230],{"class":720,"line":822},[718,4231,4203],{"class":733},[718,4233,4234],{"class":720,"line":830},[718,4235,4176],{"class":733},[718,4237,4238,4240,4242,4245],{"class":720,"line":841},[718,4239,4181],{"class":756},[718,4241,742],{"class":733},[718,4243,4244],{"class":745},"\"EnableBannedPasswordCheck\"",[718,4246,4163],{"class":733},[718,4248,4249,4251,4253],{"class":720,"line":852},[718,4250,4193],{"class":756},[718,4252,742],{"class":733},[718,4254,4227],{"class":745},[718,4256,4257],{"class":720,"line":863},[718,4258,4203],{"class":733},[718,4260,4261],{"class":720,"line":874},[718,4262,4176],{"class":733},[718,4264,4265,4267,4269,4272],{"class":720,"line":879},[718,4266,4181],{"class":756},[718,4268,742],{"class":733},[718,4270,4271],{"class":745},"\"LockoutDurationInSeconds\"",[718,4273,4163],{"class":733},[718,4275,4276,4278,4280],{"class":720,"line":891},[718,4277,4193],{"class":756},[718,4279,742],{"class":733},[718,4281,4282],{"class":745},"\"60\"\n",[718,4284,4285],{"class":720,"line":898},[718,4286,4203],{"class":733},[718,4288,4289],{"class":720,"line":907},[718,4290,4176],{"class":733},[718,4292,4293,4295,4297,4300],{"class":720,"line":917},[718,4294,4181],{"class":756},[718,4296,742],{"class":733},[718,4298,4299],{"class":745},"\"LockoutThreshold\"",[718,4301,4163],{"class":733},[718,4303,4304,4306,4308],{"class":720,"line":927},[718,4305,4193],{"class":756},[718,4307,742],{"class":733},[718,4309,4310],{"class":745},"\"10\"\n",[718,4312,4313],{"class":720,"line":937},[718,4314,4203],{"class":733},[718,4316,4317],{"class":720,"line":945},[718,4318,4176],{"class":733},[718,4320,4321,4323,4325,4328],{"class":720,"line":2677},[718,4322,4181],{"class":756},[718,4324,742],{"class":733},[718,4326,4327],{"class":745},"\"BannedPasswordList\"",[718,4329,4163],{"class":733},[718,4331,4332,4334,4336],{"class":720,"line":2689},[718,4333,4193],{"class":756},[718,4335,742],{"class":733},[718,4337,4338],{"class":745},"\"placeholder\"\n",[718,4340,4341],{"class":720,"line":2700},[718,4342,2715],{"class":733},[718,4344,4345],{"class":720,"line":2706},[718,4346,4347],{"class":733},"  ]\n",[718,4349,4350],{"class":720,"line":2712},[718,4351,2721],{"class":733},[573,4353,4354],{},"You may be thinking: why don't I add all the banned passwords in this file? A valid point, however since we want to support multiple tenants with the ability to differentiate, I chose to use separate files containing the banned passwords for each tenant.",[4356,4357,273],"h4",{"id":4358},"banned-passwords",[573,4360,4361],{},"Each tenant will have a JSON-file containing the list of banned passwords. The file will be merged with the main parameter file and the merged file will be added as body to the request.",[573,4363,4364,4367],{},[628,4365,4366],{},"bannedPasswords-tenantA.json",":",[710,4369,4371],{"className":4143,"code":4370,"language":1434,"meta":454,"style":454},"[\n  \"secret\",\n  \"123456\",\n  \"password\",\n  \"qwerty123\",\n  \"qwerty1\",\n  \"123456789\",\n  \"password1\",\n  \"12345678\",\n  \"12345\",\n  \"abc123\",\n  \"qwerty\",\n  \"iloveyou\",\n  \"Password\",\n  \"baseball\",\n  \"1234567\",\n  \"111111\",\n  \"princess\",\n  \"football\",\n  \"monkey\",\n  \"sunshine\"\n]\n",[628,4372,4373,4378,4385,4392,4399,4406,4413,4420,4427,4434,4441,4448,4455,4462,4469,4476,4483,4490,4497,4504,4511,4516],{"__ignoreMap":454},[718,4374,4375],{"class":720,"line":46},[718,4376,4377],{"class":733},"[\n",[718,4379,4380,4383],{"class":720,"line":52},[718,4381,4382],{"class":745},"  \"secret\"",[718,4384,4163],{"class":733},[718,4386,4387,4390],{"class":720,"line":88},[718,4388,4389],{"class":745},"  \"123456\"",[718,4391,4163],{"class":733},[718,4393,4394,4397],{"class":720,"line":99},[718,4395,4396],{"class":745},"  \"password\"",[718,4398,4163],{"class":733},[718,4400,4401,4404],{"class":720,"line":760},[718,4402,4403],{"class":745},"  \"qwerty123\"",[718,4405,4163],{"class":733},[718,4407,4408,4411],{"class":720,"line":771},[718,4409,4410],{"class":745},"  \"qwerty1\"",[718,4412,4163],{"class":733},[718,4414,4415,4418],{"class":720,"line":782},[718,4416,4417],{"class":745},"  \"123456789\"",[718,4419,4163],{"class":733},[718,4421,4422,4425],{"class":720,"line":793},[718,4423,4424],{"class":745},"  \"password1\"",[718,4426,4163],{"class":733},[718,4428,4429,4432],{"class":720,"line":800},[718,4430,4431],{"class":745},"  \"12345678\"",[718,4433,4163],{"class":733},[718,4435,4436,4439],{"class":720,"line":808},[718,4437,4438],{"class":745},"  \"12345\"",[718,4440,4163],{"class":733},[718,4442,4443,4446],{"class":720,"line":822},[718,4444,4445],{"class":745},"  \"abc123\"",[718,4447,4163],{"class":733},[718,4449,4450,4453],{"class":720,"line":830},[718,4451,4452],{"class":745},"  \"qwerty\"",[718,4454,4163],{"class":733},[718,4456,4457,4460],{"class":720,"line":841},[718,4458,4459],{"class":745},"  \"iloveyou\"",[718,4461,4163],{"class":733},[718,4463,4464,4467],{"class":720,"line":852},[718,4465,4466],{"class":745},"  \"Password\"",[718,4468,4163],{"class":733},[718,4470,4471,4474],{"class":720,"line":863},[718,4472,4473],{"class":745},"  \"baseball\"",[718,4475,4163],{"class":733},[718,4477,4478,4481],{"class":720,"line":874},[718,4479,4480],{"class":745},"  \"1234567\"",[718,4482,4163],{"class":733},[718,4484,4485,4488],{"class":720,"line":879},[718,4486,4487],{"class":745},"  \"111111\"",[718,4489,4163],{"class":733},[718,4491,4492,4495],{"class":720,"line":891},[718,4493,4494],{"class":745},"  \"princess\"",[718,4496,4163],{"class":733},[718,4498,4499,4502],{"class":720,"line":898},[718,4500,4501],{"class":745},"  \"football\"",[718,4503,4163],{"class":733},[718,4505,4506,4509],{"class":720,"line":907},[718,4507,4508],{"class":745},"  \"monkey\"",[718,4510,4163],{"class":733},[718,4512,4513],{"class":720,"line":917},[718,4514,4515],{"class":745},"  \"sunshine\"\n",[718,4517,4518],{"class":720,"line":927},[718,4519,3712],{"class":733},[4356,4521,278],{"id":4522},"quirky-body",[573,4524,4525,4526,4529],{},"What do I mean with 'quirky' body? The fact that the property ",[628,4527,4528],{},"BannedPasswordList"," expects tab-separated values instead of comma-separated values. This will need to be taken into account in the PowerShell script.",[676,4531,283],{"id":4532},"powershell-script",[573,4534,4535],{},"I will break down the PowerShell script in the following steps:",[1519,4537,4538,4541,4544],{},[1198,4539,4540],{},"Create a function to execute API operations on the 'settings' endpoint using an access token",[1198,4542,4543],{},"Combine the banned password list with the parameter file",[1198,4545,4546],{},"Update the settings in Entra ID",[573,4548,4549],{},"Let's initialize the script:",[710,4551,4553],{"className":1298,"code":4552,"language":1300,"meta":454,"style":454},"[CmdLetBinding()]\nParam (\n    [Parameter(Mandatory,\n        HelpMessage = \"Enter the path of the parameter folder of authentication methods setting.\")]\n    [String]$ParameterFolderPath,\n    [Parameter(Mandatory,\n        HelpMessage = \"Enter the file path of the banned password list.\")]\n    [String]$TenantBannedPasswordsFilePath\n)\n",[628,4554,4555,4566,4574,4590,4604,4616,4628,4639,4648],{"__ignoreMap":454},[718,4556,4557,4560,4563],{"class":720,"line":46},[718,4558,4559],{"class":733},"[",[718,4561,4562],{"class":756},"CmdLetBinding",[718,4564,4565],{"class":733},"()]\n",[718,4567,4568,4571],{"class":720,"line":52},[718,4569,4570],{"class":1002},"Param",[718,4572,4573],{"class":733}," (\n",[718,4575,4576,4579,4582,4584,4588],{"class":720,"line":88},[718,4577,4578],{"class":733},"    [",[718,4580,4581],{"class":756},"Parameter",[718,4583,2802],{"class":733},[718,4585,4587],{"class":4586},"sQHwn","Mandatory",[718,4589,4163],{"class":1002},[718,4591,4592,4595,4598,4601],{"class":720,"line":99},[718,4593,4594],{"class":4586},"        HelpMessage",[718,4596,4597],{"class":1002}," =",[718,4599,4600],{"class":745}," \"Enter the path of the parameter folder of authentication methods setting.\"",[718,4602,4603],{"class":733},")]\n",[718,4605,4606,4608,4611,4614],{"class":720,"line":760},[718,4607,4578],{"class":733},[718,4609,4610],{"class":1002},"String",[718,4612,4613],{"class":733},"]$ParameterFolderPath",[718,4615,4163],{"class":1002},[718,4617,4618,4620,4622,4624,4626],{"class":720,"line":771},[718,4619,4578],{"class":733},[718,4621,4581],{"class":756},[718,4623,2802],{"class":733},[718,4625,4587],{"class":4586},[718,4627,4163],{"class":1002},[718,4629,4630,4632,4634,4637],{"class":720,"line":782},[718,4631,4594],{"class":4586},[718,4633,4597],{"class":1002},[718,4635,4636],{"class":745}," \"Enter the file path of the banned password list.\"",[718,4638,4603],{"class":733},[718,4640,4641,4643,4645],{"class":720,"line":793},[718,4642,4578],{"class":733},[718,4644,4610],{"class":1002},[718,4646,4647],{"class":733},"]$TenantBannedPasswordsFilePath\n",[718,4649,4650],{"class":720,"line":800},[718,4651,2990],{"class":733},[573,4653,4654],{},"Now we declare the function to update the Entra ID settings via the beta endpoint:",[710,4656,4658],{"className":1298,"code":4657,"language":1300,"meta":454,"style":454},"function Set-EntraIdSetting {\n    param (\n        [Parameter(Mandatory,\n            HelpMessage = \"Provide the name of the settings to create/update.\")]\n        [Object]$TargetSettingName,\n        [Parameter(Mandatory,\n            HelpMessage = \"Provide the file path of the settings to create/update.\")]\n        [Object]$SettingFilePath\n    )\n    # Get the access token for the Microsoft Graph API\n    $settingsUri = \"https://graph.microsoft.com/beta/settings\"\n\n    Write-Output \"##[command]Get access token for the Microsoft Graph API\"\n    $accessToken = (Get-AzAccessToken -ResourceTypeName MSGraph -AsSecureString).Token\n    # set the params needed for the REST API requests\n    $params = @{\n        Method         = 'Get'\n        Uri            = $settingsUri\n        Authentication = 'Bearer'\n        Token          = $accessToken\n        ContentType    = 'application/json'\n    }\n    # Wrap the request in a try catch to ensure stopping errors\n    try {\n        $request = (Invoke-RestMethod @params).value\n    }\n    catch {\n        Throw $_\n    }\n    # Check if the request variable has a value\n    if ($request) {\n        Write-Output \"##[command]Found settings. Checking for setting '$TargetSettingName'\"\n        $targetSettingObject = $request | Where-Object { $_.displayName -eq $TargetSettingName }\n    }\n    # Continue checking if we have targeted the correct settings, and update the params accordingly\n    if ($targetSettingObject) {\n        Write-Output \"##[command]Found existing $TargetSettingName. Updating setting according to provided config.\"\n        $passwordSettingsUri = $settingsUri + '/' + $targetSettingObject.id\n        $params.Uri = $passwordSettingsUri\n        $params.Method = 'Patch'\n        $body = Get-Content -Path $SettingFilePath | ConvertFrom-Json -Depth 10\n        $body.PSObject.properties.remove('templateId')\n        $jsonBody = $body | ConvertTo-Json -Depth 10\n        try {\n            $settingRequest = Invoke-RestMethod @params -Body $jsonBody\n        }\n        catch {\n            throw $_\n        }\n    }\n    # Check if the setting does not exist. If this is the case we just post the entire template.\n    elseif (!$targetSettingObject) {\n        Write-Output \"##[command]No existing '$TargetSettingName'. Creating new '$TargetSettingName' according to provided config.\"\n        $jsonBody = Get-Content -Path $SettingFilePath\n\n        $params.Method = 'Post'\n\n        try {\n            $settingRequest = Invoke-RestMethod @params -Body $jsonBody\n        }\n        catch {\n            throw $_\n        }\n    }\n    return $settingRequest\n}\n",[628,4659,4660,4671,4678,4691,4703,4715,4727,4738,4747,4752,4757,4767,4771,4779,4802,4807,4818,4828,4838,4848,4858,4868,4872,4877,4884,4898,4902,4909,4917,4921,4926,4934,4948,4978,4982,4987,4994,5006,5028,5038,5048,5075,5085,5105,5112,5130,5134,5141,5148,5152,5156,5161,5174,5191,5205,5210,5220,5225,5232,5247,5252,5259,5266,5271,5276,5285],{"__ignoreMap":454},[718,4661,4662,4665,4668],{"class":720,"line":46},[718,4663,4664],{"class":1002},"function",[718,4666,4667],{"class":1225}," Set-EntraIdSetting",[718,4669,4670],{"class":733}," {\n",[718,4672,4673,4676],{"class":720,"line":52},[718,4674,4675],{"class":1002},"    param",[718,4677,4573],{"class":733},[718,4679,4680,4683,4685,4687,4689],{"class":720,"line":88},[718,4681,4682],{"class":733},"        [",[718,4684,4581],{"class":756},[718,4686,2802],{"class":733},[718,4688,4587],{"class":4586},[718,4690,4163],{"class":1002},[718,4692,4693,4696,4698,4701],{"class":720,"line":99},[718,4694,4695],{"class":4586},"            HelpMessage",[718,4697,4597],{"class":1002},[718,4699,4700],{"class":745}," \"Provide the name of the settings to create/update.\"",[718,4702,4603],{"class":733},[718,4704,4705,4707,4710,4713],{"class":720,"line":760},[718,4706,4682],{"class":733},[718,4708,4709],{"class":1002},"Object",[718,4711,4712],{"class":733},"]$TargetSettingName",[718,4714,4163],{"class":1002},[718,4716,4717,4719,4721,4723,4725],{"class":720,"line":771},[718,4718,4682],{"class":733},[718,4720,4581],{"class":756},[718,4722,2802],{"class":733},[718,4724,4587],{"class":4586},[718,4726,4163],{"class":1002},[718,4728,4729,4731,4733,4736],{"class":720,"line":782},[718,4730,4695],{"class":4586},[718,4732,4597],{"class":1002},[718,4734,4735],{"class":745}," \"Provide the file path of the settings to create/update.\"",[718,4737,4603],{"class":733},[718,4739,4740,4742,4744],{"class":720,"line":793},[718,4741,4682],{"class":733},[718,4743,4709],{"class":1002},[718,4745,4746],{"class":733},"]$SettingFilePath\n",[718,4748,4749],{"class":720,"line":800},[718,4750,4751],{"class":733},"    )\n",[718,4753,4754],{"class":720,"line":808},[718,4755,4756],{"class":723},"    # Get the access token for the Microsoft Graph API\n",[718,4758,4759,4762,4764],{"class":720,"line":822},[718,4760,4761],{"class":733},"    $settingsUri ",[718,4763,1003],{"class":1002},[718,4765,4766],{"class":745}," \"https://graph.microsoft.com/beta/settings\"\n",[718,4768,4769],{"class":720,"line":830},[718,4770,797],{"emptyLinePlaceholder":796},[718,4772,4773,4776],{"class":720,"line":841},[718,4774,4775],{"class":756},"    Write-Output",[718,4777,4778],{"class":745}," \"##[command]Get access token for the Microsoft Graph API\"\n",[718,4780,4781,4784,4786,4789,4792,4794,4797,4799],{"class":720,"line":852},[718,4782,4783],{"class":733},"    $accessToken ",[718,4785,1003],{"class":1002},[718,4787,4788],{"class":733}," (",[718,4790,4791],{"class":756},"Get-AzAccessToken",[718,4793,2469],{"class":1002},[718,4795,4796],{"class":733},"ResourceTypeName MSGraph ",[718,4798,2960],{"class":1002},[718,4800,4801],{"class":733},"AsSecureString).Token\n",[718,4803,4804],{"class":720,"line":863},[718,4805,4806],{"class":723},"    # set the params needed for the REST API requests\n",[718,4808,4809,4812,4814,4816],{"class":720,"line":874},[718,4810,4811],{"class":733},"    $params ",[718,4813,1003],{"class":1002},[718,4815,2607],{"class":1002},[718,4817,2610],{"class":733},[718,4819,4820,4823,4825],{"class":720,"line":879},[718,4821,4822],{"class":733},"        Method         ",[718,4824,1003],{"class":1002},[718,4826,4827],{"class":745}," 'Get'\n",[718,4829,4830,4833,4835],{"class":720,"line":891},[718,4831,4832],{"class":733},"        Uri            ",[718,4834,1003],{"class":1002},[718,4836,4837],{"class":733}," $settingsUri\n",[718,4839,4840,4843,4845],{"class":720,"line":898},[718,4841,4842],{"class":733},"        Authentication ",[718,4844,1003],{"class":1002},[718,4846,4847],{"class":745}," 'Bearer'\n",[718,4849,4850,4853,4855],{"class":720,"line":907},[718,4851,4852],{"class":733},"        Token          ",[718,4854,1003],{"class":1002},[718,4856,4857],{"class":733}," $accessToken\n",[718,4859,4860,4863,4865],{"class":720,"line":917},[718,4861,4862],{"class":733},"        ContentType    ",[718,4864,1003],{"class":1002},[718,4866,4867],{"class":745}," 'application/json'\n",[718,4869,4870],{"class":720,"line":927},[718,4871,2715],{"class":733},[718,4873,4874],{"class":720,"line":937},[718,4875,4876],{"class":723},"    # Wrap the request in a try catch to ensure stopping errors\n",[718,4878,4879,4882],{"class":720,"line":945},[718,4880,4881],{"class":1002},"    try",[718,4883,4670],{"class":733},[718,4885,4886,4889,4891,4893,4895],{"class":720,"line":2677},[718,4887,4888],{"class":733},"        $request ",[718,4890,1003],{"class":1002},[718,4892,4788],{"class":733},[718,4894,2952],{"class":756},[718,4896,4897],{"class":733}," @params).value\n",[718,4899,4900],{"class":720,"line":2689},[718,4901,2715],{"class":733},[718,4903,4904,4907],{"class":720,"line":2700},[718,4905,4906],{"class":1002},"    catch",[718,4908,4670],{"class":733},[718,4910,4911,4914],{"class":720,"line":2706},[718,4912,4913],{"class":1002},"        Throw",[718,4915,4916],{"class":756}," $_\n",[718,4918,4919],{"class":720,"line":2712},[718,4920,2715],{"class":733},[718,4922,4923],{"class":720,"line":2718},[718,4924,4925],{"class":723},"    # Check if the request variable has a value\n",[718,4927,4928,4931],{"class":720,"line":2724},[718,4929,4930],{"class":1002},"    if",[718,4932,4933],{"class":733}," ($request) {\n",[718,4935,4936,4939,4942,4945],{"class":720,"line":2737},[718,4937,4938],{"class":756},"        Write-Output",[718,4940,4941],{"class":745}," \"##[command]Found settings. Checking for setting '",[718,4943,4944],{"class":733},"$TargetSettingName",[718,4946,4947],{"class":745},"'\"\n",[718,4949,4950,4953,4955,4958,4960,4963,4966,4969,4972,4975],{"class":720,"line":2743},[718,4951,4952],{"class":733},"        $targetSettingObject ",[718,4954,1003],{"class":1002},[718,4956,4957],{"class":733}," $request ",[718,4959,2976],{"class":1002},[718,4961,4962],{"class":756}," Where-Object",[718,4964,4965],{"class":733}," { ",[718,4967,4968],{"class":756},"$_",[718,4970,4971],{"class":733},".displayName ",[718,4973,4974],{"class":1002},"-eq",[718,4976,4977],{"class":733}," $TargetSettingName }\n",[718,4979,4980],{"class":720,"line":2761},[718,4981,2715],{"class":733},[718,4983,4984],{"class":720,"line":2766},[718,4985,4986],{"class":723},"    # Continue checking if we have targeted the correct settings, and update the params accordingly\n",[718,4988,4989,4991],{"class":720,"line":2777},[718,4990,4930],{"class":1002},[718,4992,4993],{"class":733}," ($targetSettingObject) {\n",[718,4995,4996,4998,5001,5003],{"class":720,"line":2789},[718,4997,4938],{"class":756},[718,4999,5000],{"class":745}," \"##[command]Found existing ",[718,5002,4944],{"class":733},[718,5004,5005],{"class":745},". Updating setting according to provided config.\"\n",[718,5007,5008,5011,5013,5016,5019,5022,5025],{"class":720,"line":2818},[718,5009,5010],{"class":733},"        $passwordSettingsUri ",[718,5012,1003],{"class":1002},[718,5014,5015],{"class":733}," $settingsUri ",[718,5017,5018],{"class":1002},"+",[718,5020,5021],{"class":745}," '/'",[718,5023,5024],{"class":1002}," +",[718,5026,5027],{"class":733}," $targetSettingObject.id\n",[718,5029,5030,5033,5035],{"class":720,"line":2829},[718,5031,5032],{"class":733},"        $params.Uri ",[718,5034,1003],{"class":1002},[718,5036,5037],{"class":733}," $passwordSettingsUri\n",[718,5039,5040,5043,5045],{"class":720,"line":2839},[718,5041,5042],{"class":733},"        $params.Method ",[718,5044,1003],{"class":1002},[718,5046,5047],{"class":745}," 'Patch'\n",[718,5049,5050,5053,5055,5058,5060,5063,5065,5068,5070,5072],{"class":720,"line":2850},[718,5051,5052],{"class":733},"        $body ",[718,5054,1003],{"class":1002},[718,5056,5057],{"class":756}," Get-Content",[718,5059,2469],{"class":1002},[718,5061,5062],{"class":733},"Path $SettingFilePath ",[718,5064,2976],{"class":1002},[718,5066,5067],{"class":756}," ConvertFrom-Json",[718,5069,2469],{"class":1002},[718,5071,2984],{"class":733},[718,5073,5074],{"class":756},"10\n",[718,5076,5077,5080,5083],{"class":720,"line":2861},[718,5078,5079],{"class":733},"        $body.PSObject.properties.remove(",[718,5081,5082],{"class":745},"'templateId'",[718,5084,2990],{"class":733},[718,5086,5087,5090,5092,5095,5097,5099,5101,5103],{"class":720,"line":2873},[718,5088,5089],{"class":733},"        $jsonBody ",[718,5091,1003],{"class":1002},[718,5093,5094],{"class":733}," $body ",[718,5096,2976],{"class":1002},[718,5098,2979],{"class":756},[718,5100,2469],{"class":1002},[718,5102,2984],{"class":733},[718,5104,5074],{"class":756},[718,5106,5107,5110],{"class":720,"line":2885},[718,5108,5109],{"class":1002},"        try",[718,5111,4670],{"class":733},[718,5113,5114,5117,5119,5122,5125,5127],{"class":720,"line":2896},[718,5115,5116],{"class":733},"            $settingRequest ",[718,5118,1003],{"class":1002},[718,5120,5121],{"class":756}," Invoke-RestMethod",[718,5123,5124],{"class":733}," @params ",[718,5126,2960],{"class":1002},[718,5128,5129],{"class":733},"Body $jsonBody\n",[718,5131,5132],{"class":720,"line":2907},[718,5133,2709],{"class":733},[718,5135,5136,5139],{"class":720,"line":2918},[718,5137,5138],{"class":1002},"        catch",[718,5140,4670],{"class":733},[718,5142,5143,5146],{"class":720,"line":2923},[718,5144,5145],{"class":1002},"            throw",[718,5147,4916],{"class":756},[718,5149,5150],{"class":720,"line":2928},[718,5151,2709],{"class":733},[718,5153,5154],{"class":720,"line":2933},[718,5155,2715],{"class":733},[718,5157,5158],{"class":720,"line":2938},[718,5159,5160],{"class":723},"    # Check if the setting does not exist. If this is the case we just post the entire template.\n",[718,5162,5163,5166,5168,5171],{"class":720,"line":2943},[718,5164,5165],{"class":1002},"    elseif",[718,5167,4788],{"class":733},[718,5169,5170],{"class":1002},"!",[718,5172,5173],{"class":733},"$targetSettingObject) {\n",[718,5175,5176,5178,5181,5183,5186,5188],{"class":720,"line":2949},[718,5177,4938],{"class":756},[718,5179,5180],{"class":745}," \"##[command]No existing '",[718,5182,4944],{"class":733},[718,5184,5185],{"class":745},"'. Creating new '",[718,5187,4944],{"class":733},[718,5189,5190],{"class":745},"' according to provided config.\"\n",[718,5192,5194,5196,5198,5200,5202],{"class":720,"line":5193},54,[718,5195,5089],{"class":733},[718,5197,1003],{"class":1002},[718,5199,5057],{"class":756},[718,5201,2469],{"class":1002},[718,5203,5204],{"class":733},"Path $SettingFilePath\n",[718,5206,5208],{"class":720,"line":5207},55,[718,5209,797],{"emptyLinePlaceholder":796},[718,5211,5213,5215,5217],{"class":720,"line":5212},56,[718,5214,5042],{"class":733},[718,5216,1003],{"class":1002},[718,5218,5219],{"class":745}," 'Post'\n",[718,5221,5223],{"class":720,"line":5222},57,[718,5224,797],{"emptyLinePlaceholder":796},[718,5226,5228,5230],{"class":720,"line":5227},58,[718,5229,5109],{"class":1002},[718,5231,4670],{"class":733},[718,5233,5235,5237,5239,5241,5243,5245],{"class":720,"line":5234},59,[718,5236,5116],{"class":733},[718,5238,1003],{"class":1002},[718,5240,5121],{"class":756},[718,5242,5124],{"class":733},[718,5244,2960],{"class":1002},[718,5246,5129],{"class":733},[718,5248,5250],{"class":720,"line":5249},60,[718,5251,2709],{"class":733},[718,5253,5255,5257],{"class":720,"line":5254},61,[718,5256,5138],{"class":1002},[718,5258,4670],{"class":733},[718,5260,5262,5264],{"class":720,"line":5261},62,[718,5263,5145],{"class":1002},[718,5265,4916],{"class":756},[718,5267,5269],{"class":720,"line":5268},63,[718,5270,2709],{"class":733},[718,5272,5274],{"class":720,"line":5273},64,[718,5275,2715],{"class":733},[718,5277,5279,5282],{"class":720,"line":5278},65,[718,5280,5281],{"class":1002},"    return",[718,5283,5284],{"class":733}," $settingRequest\n",[718,5286,5288],{"class":720,"line":5287},66,[718,5289,2721],{"class":733},[573,5291,5292],{},"Now we update the banned password list values of the parameter file:",[710,5294,5296],{"className":1298,"code":5295,"language":1300,"meta":454,"style":454},"Write-Output \"##[command]Updating banned password list\"\n$bannedPasswords = Get-Content -Path $TenantBannedPasswordsFilePath | ConvertFrom-Json\n$bannedPasswordsList = $null\n$tab = [char]9\n\nWrite-Output \"##[command]Looping the banned password list and adding tabs needed for the REST API call.\"\nforeach ($bannedPassword in $bannedPasswords) {\n    $bannedPasswordsList += $bannedPassword + $tab\n}\n\nWrite-Output \"##[command]Trimming the banned password list to exclude the last tab.\"\n$trimmedPasswordList = $bannedPasswordsList -replace \".{1}$\"\n$bannedPasswordsSetting = Get-Content -Path \"$ParameterFolderPath\\passwordSettings.json\" | ConvertFrom-Json -Depth 5 -AsHashtable\n    ($bannedPasswordsSetting.values | Where-Object { $_.name -eq 'BannedPasswordList' }).value = $trimmedPasswordList\n$bannedPasswordsSetting | ConvertTo-Json -Depth 5 | Out-File \"$ParameterFolderPath\\updatedPasswordSettings.json\"\n",[628,5297,5298,5306,5325,5334,5352,5356,5363,5376,5392,5396,5400,5407,5423,5463,5492],{"__ignoreMap":454},[718,5299,5300,5303],{"class":720,"line":46},[718,5301,5302],{"class":756},"Write-Output",[718,5304,5305],{"class":745}," \"##[command]Updating banned password list\"\n",[718,5307,5308,5311,5313,5315,5317,5320,5322],{"class":720,"line":52},[718,5309,5310],{"class":733},"$bannedPasswords ",[718,5312,1003],{"class":1002},[718,5314,5057],{"class":756},[718,5316,2469],{"class":1002},[718,5318,5319],{"class":733},"Path $TenantBannedPasswordsFilePath ",[718,5321,2976],{"class":1002},[718,5323,5324],{"class":756}," ConvertFrom-Json\n",[718,5326,5327,5330,5332],{"class":720,"line":88},[718,5328,5329],{"class":733},"$bannedPasswordsList ",[718,5331,1003],{"class":1002},[718,5333,2904],{"class":756},[718,5335,5336,5339,5341,5343,5346,5349],{"class":720,"line":99},[718,5337,5338],{"class":733},"$tab ",[718,5340,1003],{"class":1002},[718,5342,2561],{"class":733},[718,5344,5345],{"class":1002},"char",[718,5347,5348],{"class":733},"]",[718,5350,5351],{"class":756},"9\n",[718,5353,5354],{"class":720,"line":760},[718,5355,797],{"emptyLinePlaceholder":796},[718,5357,5358,5360],{"class":720,"line":771},[718,5359,5302],{"class":756},[718,5361,5362],{"class":745}," \"##[command]Looping the banned password list and adding tabs needed for the REST API call.\"\n",[718,5364,5365,5368,5371,5373],{"class":720,"line":782},[718,5366,5367],{"class":1002},"foreach",[718,5369,5370],{"class":733}," ($bannedPassword ",[718,5372,3645],{"class":1002},[718,5374,5375],{"class":733}," $bannedPasswords) {\n",[718,5377,5378,5381,5384,5387,5389],{"class":720,"line":793},[718,5379,5380],{"class":733},"    $bannedPasswordsList ",[718,5382,5383],{"class":1002},"+=",[718,5385,5386],{"class":733}," $bannedPassword ",[718,5388,5018],{"class":1002},[718,5390,5391],{"class":733}," $tab\n",[718,5393,5394],{"class":720,"line":800},[718,5395,2721],{"class":733},[718,5397,5398],{"class":720,"line":808},[718,5399,797],{"emptyLinePlaceholder":796},[718,5401,5402,5404],{"class":720,"line":822},[718,5403,5302],{"class":756},[718,5405,5406],{"class":745}," \"##[command]Trimming the banned password list to exclude the last tab.\"\n",[718,5408,5409,5412,5414,5417,5420],{"class":720,"line":830},[718,5410,5411],{"class":733},"$trimmedPasswordList ",[718,5413,1003],{"class":1002},[718,5415,5416],{"class":733}," $bannedPasswordsList ",[718,5418,5419],{"class":1002},"-replace",[718,5421,5422],{"class":745}," \".{1}$\"\n",[718,5424,5425,5428,5430,5432,5434,5437,5440,5443,5446,5449,5451,5453,5455,5458,5460],{"class":720,"line":841},[718,5426,5427],{"class":733},"$bannedPasswordsSetting ",[718,5429,1003],{"class":1002},[718,5431,5057],{"class":756},[718,5433,2469],{"class":1002},[718,5435,5436],{"class":733},"Path ",[718,5438,5439],{"class":745},"\"",[718,5441,5442],{"class":733},"$ParameterFolderPath",[718,5444,5445],{"class":745},"\\passwordSettings.json\"",[718,5447,5448],{"class":1002}," |",[718,5450,5067],{"class":756},[718,5452,2469],{"class":1002},[718,5454,2984],{"class":733},[718,5456,5457],{"class":756},"5",[718,5459,2469],{"class":1002},[718,5461,5462],{"class":733},"AsHashtable\n",[718,5464,5465,5468,5470,5472,5474,5476,5479,5481,5484,5487,5489],{"class":720,"line":852},[718,5466,5467],{"class":733},"    ($bannedPasswordsSetting.values ",[718,5469,2976],{"class":1002},[718,5471,4962],{"class":756},[718,5473,4965],{"class":733},[718,5475,4968],{"class":756},[718,5477,5478],{"class":733},".name ",[718,5480,4974],{"class":1002},[718,5482,5483],{"class":745}," 'BannedPasswordList'",[718,5485,5486],{"class":733}," }).value ",[718,5488,1003],{"class":1002},[718,5490,5491],{"class":733}," $trimmedPasswordList\n",[718,5493,5494,5496,5498,5500,5502,5504,5506,5508,5511,5514,5516],{"class":720,"line":863},[718,5495,5427],{"class":733},[718,5497,2976],{"class":1002},[718,5499,2979],{"class":756},[718,5501,2469],{"class":1002},[718,5503,2984],{"class":733},[718,5505,5457],{"class":756},[718,5507,5448],{"class":1002},[718,5509,5510],{"class":756}," Out-File",[718,5512,5513],{"class":745}," \"",[718,5515,5442],{"class":733},[718,5517,5518],{"class":745},"\\updatedPasswordSettings.json\"\n",[573,5520,5521],{},"After this we have successfully created a new JSON-file containing the entire configuration (parameters). This file will be used as body in the next step, where we present it to our previously written function:",[710,5523,5525],{"className":1298,"code":5524,"language":1300,"meta":454,"style":454},"try {\n    Set-EntraIdSetting -TargetSettingName 'Password Rule Settings' -SettingFilePath \"$ParameterFolderPath\\updatedPasswordSettings.json\"\n    Write-Output \"Settings updated successfully!\"\n}\ncatch {\n    throw\n}\n",[628,5526,5527,5534,5558,5565,5569,5576,5581],{"__ignoreMap":454},[718,5528,5529,5532],{"class":720,"line":46},[718,5530,5531],{"class":1002},"try",[718,5533,4670],{"class":733},[718,5535,5536,5539,5541,5544,5547,5549,5552,5554,5556],{"class":720,"line":52},[718,5537,5538],{"class":756},"    Set-EntraIdSetting",[718,5540,2469],{"class":1002},[718,5542,5543],{"class":733},"TargetSettingName ",[718,5545,5546],{"class":745},"'Password Rule Settings'",[718,5548,2469],{"class":1002},[718,5550,5551],{"class":733},"SettingFilePath ",[718,5553,5439],{"class":745},[718,5555,5442],{"class":733},[718,5557,5518],{"class":745},[718,5559,5560,5562],{"class":720,"line":88},[718,5561,4775],{"class":756},[718,5563,5564],{"class":745}," \"Settings updated successfully!\"\n",[718,5566,5567],{"class":720,"line":99},[718,5568,2721],{"class":733},[718,5570,5571,5574],{"class":720,"line":760},[718,5572,5573],{"class":1002},"catch",[718,5575,4670],{"class":733},[718,5577,5578],{"class":720,"line":771},[718,5579,5580],{"class":1002},"    throw\n",[718,5582,5583],{"class":720,"line":782},[718,5584,2721],{"class":733},[676,5586,288],{"id":5587},"azure-devops-yaml-pipeline",[573,5589,5590],{},"The pipeline is where the magic sauce comes into play. Make sure you have service connections setup to the various tenants you wish to manage. For each tenant you can add a separate stage, as per the example below. Because this is a small configuration change, the pipeline is not that complicated to set up. First let's add the correct trigger:",[710,5592,5594],{"className":712,"code":5593,"language":714,"meta":454,"style":454},"trigger:\n  branches:\n    include:\n      - main\n  paths:\n    include:\n      - bannedPasswords/parameters\n",[628,5595,5596,5603,5610,5617,5625,5632,5638],{"__ignoreMap":454},[718,5597,5598,5601],{"class":720,"line":46},[718,5599,5600],{"class":729},"trigger",[718,5602,734],{"class":733},[718,5604,5605,5608],{"class":720,"line":52},[718,5606,5607],{"class":729},"  branches",[718,5609,734],{"class":733},[718,5611,5612,5615],{"class":720,"line":88},[718,5613,5614],{"class":729},"    include",[718,5616,734],{"class":733},[718,5618,5619,5622],{"class":720,"line":99},[718,5620,5621],{"class":733},"      - ",[718,5623,5624],{"class":745},"main\n",[718,5626,5627,5630],{"class":720,"line":760},[718,5628,5629],{"class":729},"  paths",[718,5631,734],{"class":733},[718,5633,5634,5636],{"class":720,"line":771},[718,5635,5614],{"class":729},[718,5637,734],{"class":733},[718,5639,5640,5642],{"class":720,"line":782},[718,5641,5621],{"class":733},[718,5643,5644],{"class":745},"bannedPasswords/parameters\n",[573,5646,5647],{},"Triggering on the branch and path makes sure the CI/CD runs as expected. You can add more checks before the first deployment stage, or better: add a test tenant stage to validate your configuration before pushing to production. Next we configure the pool, variables and stages of the pipeline. That's all it needs to deploy on every commit to main in the designated feature parameter folder.",[710,5649,5651],{"className":712,"code":5650,"language":714,"meta":454,"style":454},"pool:\n  vmImage: ubuntu-latest\n\nvariables:\n  - name: ParameterFolderPath\n    value: bannedPasswords/parameters\n\nstages:\n  - stage: TenantA\n    jobs:\n      - job: TenantA\n        displayName: Updating Password Settings in Tenant A\n        steps:\n          - task: AzurePowerShell@5\n            displayName: Setting the configuration\n            inputs:\n              azureSubscription: \"TenantA-AuthenticationMethods-SPN\"\n              ScriptType: \"FilePath\"\n              ScriptPath: \"$(System.DefaultWorkingDirectory)/bannedPasswords/code/Set-PasswordSettings.ps1\"\n              ScriptArguments:\n                -ParameterFolderPath \"$(System.DefaultWorkingDirectory)/$(ParameterFolderPath)\"\n                -TenantBannedPasswordsFilePath \"$(System.DefaultWorkingDirectory)/$(ParameterFolderPath)/bannedPasswords-TenantA.json\"\n              azurePowerShellVersion: LatestVersion\n",[628,5652,5653,5660,5670,5674,5681,5693,5702,5706,5713,5725,5732,5743,5753,5760,5773,5783,5790,5800,5810,5820,5827,5832,5837],{"__ignoreMap":454},[718,5654,5655,5658],{"class":720,"line":46},[718,5656,5657],{"class":729},"pool",[718,5659,734],{"class":733},[718,5661,5662,5665,5667],{"class":720,"line":52},[718,5663,5664],{"class":729},"  vmImage",[718,5666,742],{"class":733},[718,5668,5669],{"class":745},"ubuntu-latest\n",[718,5671,5672],{"class":720,"line":88},[718,5673,797],{"emptyLinePlaceholder":796},[718,5675,5676,5679],{"class":720,"line":99},[718,5677,5678],{"class":729},"variables",[718,5680,734],{"class":733},[718,5682,5683,5685,5688,5690],{"class":720,"line":760},[718,5684,811],{"class":733},[718,5686,5687],{"class":729},"name",[718,5689,742],{"class":733},[718,5691,5692],{"class":745},"ParameterFolderPath\n",[718,5694,5695,5698,5700],{"class":720,"line":771},[718,5696,5697],{"class":729},"    value",[718,5699,742],{"class":733},[718,5701,5644],{"class":745},[718,5703,5704],{"class":720,"line":782},[718,5705,797],{"emptyLinePlaceholder":796},[718,5707,5708,5711],{"class":720,"line":793},[718,5709,5710],{"class":729},"stages",[718,5712,734],{"class":733},[718,5714,5715,5717,5720,5722],{"class":720,"line":800},[718,5716,811],{"class":733},[718,5718,5719],{"class":729},"stage",[718,5721,742],{"class":733},[718,5723,5724],{"class":745},"TenantA\n",[718,5726,5727,5730],{"class":720,"line":808},[718,5728,5729],{"class":729},"    jobs",[718,5731,734],{"class":733},[718,5733,5734,5736,5739,5741],{"class":720,"line":822},[718,5735,5621],{"class":733},[718,5737,5738],{"class":729},"job",[718,5740,742],{"class":733},[718,5742,5724],{"class":745},[718,5744,5745,5748,5750],{"class":720,"line":830},[718,5746,5747],{"class":729},"        displayName",[718,5749,742],{"class":733},[718,5751,5752],{"class":745},"Updating Password Settings in Tenant A\n",[718,5754,5755,5758],{"class":720,"line":841},[718,5756,5757],{"class":729},"        steps",[718,5759,734],{"class":733},[718,5761,5762,5765,5768,5770],{"class":720,"line":852},[718,5763,5764],{"class":733},"          - ",[718,5766,5767],{"class":729},"task",[718,5769,742],{"class":733},[718,5771,5772],{"class":745},"AzurePowerShell@5\n",[718,5774,5775,5778,5780],{"class":720,"line":863},[718,5776,5777],{"class":729},"            displayName",[718,5779,742],{"class":733},[718,5781,5782],{"class":745},"Setting the configuration\n",[718,5784,5785,5788],{"class":720,"line":874},[718,5786,5787],{"class":729},"            inputs",[718,5789,734],{"class":733},[718,5791,5792,5795,5797],{"class":720,"line":879},[718,5793,5794],{"class":729},"              azureSubscription",[718,5796,742],{"class":733},[718,5798,5799],{"class":745},"\"TenantA-AuthenticationMethods-SPN\"\n",[718,5801,5802,5805,5807],{"class":720,"line":891},[718,5803,5804],{"class":729},"              ScriptType",[718,5806,742],{"class":733},[718,5808,5809],{"class":745},"\"FilePath\"\n",[718,5811,5812,5815,5817],{"class":720,"line":898},[718,5813,5814],{"class":729},"              ScriptPath",[718,5816,742],{"class":733},[718,5818,5819],{"class":745},"\"$(System.DefaultWorkingDirectory)/bannedPasswords/code/Set-PasswordSettings.ps1\"\n",[718,5821,5822,5825],{"class":720,"line":907},[718,5823,5824],{"class":729},"              ScriptArguments",[718,5826,734],{"class":733},[718,5828,5829],{"class":720,"line":917},[718,5830,5831],{"class":745},"                -ParameterFolderPath \"$(System.DefaultWorkingDirectory)/$(ParameterFolderPath)\"\n",[718,5833,5834],{"class":720,"line":927},[718,5835,5836],{"class":745},"                -TenantBannedPasswordsFilePath \"$(System.DefaultWorkingDirectory)/$(ParameterFolderPath)/bannedPasswords-TenantA.json\"\n",[718,5838,5839,5842,5844],{"class":720,"line":937},[718,5840,5841],{"class":729},"              azurePowerShellVersion",[718,5843,742],{"class":733},[718,5845,5846],{"class":745},"LatestVersion\n",[573,5848,5849],{},"Adding another tenant is as easy as copy-pasting the previous stage and changing the parameters:",[710,5851,5853],{"className":712,"code":5852,"language":714,"meta":454,"style":454},"- stage: TenantB\n  jobs:\n    - job: TenantB\n      displayName: Updating Password Settings in Tenant B\n      steps:\n        - task: AzurePowerShell@5\n          displayName: Setting the configuration\n          inputs:\n            azureSubscription: \"TenantB-AuthenticationMethods-SPN\"\n            ScriptType: \"FilePath\"\n            ScriptPath: \"$(System.DefaultWorkingDirectory)/bannedPasswords/code/Set-PasswordSettings.ps1\"\n            ScriptArguments:\n              -ParameterFolderPath \"$(System.DefaultWorkingDirectory)/$(ParameterFolderPath)\"\n              -TenantBannedPasswordsFilePath \"$(System.DefaultWorkingDirectory)/$(ParameterFolderPath)/bannedPasswords-TenantB.json\"\n            azurePowerShellVersion: LatestVersion\n",[628,5854,5855,5867,5874,5884,5894,5901,5912,5921,5928,5938,5947,5956,5963,5968,5973],{"__ignoreMap":454},[718,5856,5857,5860,5862,5864],{"class":720,"line":46},[718,5858,5859],{"class":733},"- ",[718,5861,5719],{"class":729},[718,5863,742],{"class":733},[718,5865,5866],{"class":745},"TenantB\n",[718,5868,5869,5872],{"class":720,"line":52},[718,5870,5871],{"class":729},"  jobs",[718,5873,734],{"class":733},[718,5875,5876,5878,5880,5882],{"class":720,"line":88},[718,5877,1464],{"class":733},[718,5879,5738],{"class":729},[718,5881,742],{"class":733},[718,5883,5866],{"class":745},[718,5885,5886,5889,5891],{"class":720,"line":99},[718,5887,5888],{"class":729},"      displayName",[718,5890,742],{"class":733},[718,5892,5893],{"class":745},"Updating Password Settings in Tenant B\n",[718,5895,5896,5899],{"class":720,"line":760},[718,5897,5898],{"class":729},"      steps",[718,5900,734],{"class":733},[718,5902,5903,5906,5908,5910],{"class":720,"line":771},[718,5904,5905],{"class":733},"        - ",[718,5907,5767],{"class":729},[718,5909,742],{"class":733},[718,5911,5772],{"class":745},[718,5913,5914,5917,5919],{"class":720,"line":782},[718,5915,5916],{"class":729},"          displayName",[718,5918,742],{"class":733},[718,5920,5782],{"class":745},[718,5922,5923,5926],{"class":720,"line":793},[718,5924,5925],{"class":729},"          inputs",[718,5927,734],{"class":733},[718,5929,5930,5933,5935],{"class":720,"line":800},[718,5931,5932],{"class":729},"            azureSubscription",[718,5934,742],{"class":733},[718,5936,5937],{"class":745},"\"TenantB-AuthenticationMethods-SPN\"\n",[718,5939,5940,5943,5945],{"class":720,"line":808},[718,5941,5942],{"class":729},"            ScriptType",[718,5944,742],{"class":733},[718,5946,5809],{"class":745},[718,5948,5949,5952,5954],{"class":720,"line":822},[718,5950,5951],{"class":729},"            ScriptPath",[718,5953,742],{"class":733},[718,5955,5819],{"class":745},[718,5957,5958,5961],{"class":720,"line":830},[718,5959,5960],{"class":729},"            ScriptArguments",[718,5962,734],{"class":733},[718,5964,5965],{"class":720,"line":841},[718,5966,5967],{"class":745},"              -ParameterFolderPath \"$(System.DefaultWorkingDirectory)/$(ParameterFolderPath)\"\n",[718,5969,5970],{"class":720,"line":852},[718,5971,5972],{"class":745},"              -TenantBannedPasswordsFilePath \"$(System.DefaultWorkingDirectory)/$(ParameterFolderPath)/bannedPasswords-TenantB.json\"\n",[718,5974,5975,5978,5980],{"class":720,"line":863},[718,5976,5977],{"class":729},"            azurePowerShellVersion",[718,5979,742],{"class":733},[718,5981,5846],{"class":745},[676,5983,293],{"id":5984},"running-the-pipeline",[573,5986,5987],{},"Let's see what my pipeline does when I run it…",[573,5989,5990],{},[590,5991],{"alt":5992,"src":5993},"pipeline","/images/blog/entraid-banned-password-list/pipeline.png",[573,5995,5996],{},"Success!",[573,5998,5999],{},[590,6000],{"alt":6001,"src":6002},"pipeline-details","/images/blog/entraid-banned-password-list/pipeline-details.png",[569,6004,298],{"id":6005},"potential-pitfalls-and-best-practices",[573,6007,6008],{},"This setup makes you versatile in configuring banned passwords for your environment(s). As always, stay aware of the pitfalls:",[1195,6010,6011,6014,6017,6020,6023,6026],{},[1198,6012,6013],{},"Ensure password policies align with specific tenant compliance requirements",[1198,6015,6016],{},"Always implement a 4-eyes principle approval workflow in your automation",[1198,6018,6019],{},"Test thoroughly in staged environments",[1198,6021,6022],{},"Regularly review banned password lists and update accordingly",[1198,6024,6025],{},"Implement comprehensive logging",[1198,6027,6028],{},"Use the principle of least privilege for your automation accounts",[569,6030,112],{"id":6031},"conclusion",[573,6033,6034],{},"That concludes this blog about configuring banned password lists via the Graph API with PowerShell. We have successfully:",[1195,6036,6037,6040,6043],{},[1198,6038,6039],{},"✅ Created a folder structure for our files",[1198,6041,6042],{},"✅ Wrote an intermediate PowerShell script, including a function, to configure the Entra ID settings",[1198,6044,6045],{},"✅ Added a YAML-pipeline to automatically deploy the code to Entra ID",[573,6047,6048],{},"Hopefully this provides you with a jump start into managing Entra ID(s) in an automated fashion. You can easily expand on this automation by adding new configuration of Entra ID like Conditional Access Policies or even PIM Eligible Role Assignments! As always, leave a comment on LinkedIn if you have any more questions. Happy coding! ☕",[1791,6050,6051],{},"html pre.shiki code .slsVL, html code.shiki .slsVL{--shiki-light:#24292E;--shiki-default:#E1E4E8;--shiki-dark:#E1E4E8}html pre.shiki code .suiK_, html code.shiki .suiK_{--shiki-light:#005CC5;--shiki-default:#79B8FF;--shiki-dark:#79B8FF}html pre.shiki code .sfrk1, html code.shiki .sfrk1{--shiki-light:#032F62;--shiki-default:#9ECBFF;--shiki-dark:#9ECBFF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .so5gQ, html code.shiki .so5gQ{--shiki-light:#D73A49;--shiki-default:#F97583;--shiki-dark:#F97583}html pre.shiki code .sQHwn, html code.shiki .sQHwn{--shiki-light:#E36209;--shiki-default:#FFAB70;--shiki-dark:#FFAB70}html pre.shiki code .shcOC, html code.shiki .shcOC{--shiki-light:#6F42C1;--shiki-default:#B392F0;--shiki-dark:#B392F0}html pre.shiki code .sCsY4, html code.shiki .sCsY4{--shiki-light:#6A737D;--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sByVh, html code.shiki .sByVh{--shiki-light:#22863A;--shiki-default:#85E89D;--shiki-dark:#85E89D}",{"title":454,"searchDepth":52,"depth":52,"links":6053},[6054,6055,6056,6057,6058,6066,6067],{"id":3964,"depth":52,"text":235},{"id":3128,"depth":52,"text":55},{"id":4025,"depth":52,"text":244},{"id":4059,"depth":52,"text":60},{"id":4078,"depth":52,"text":253,"children":6059},[6060,6061,6062,6063,6064,6065],{"id":4089,"depth":88,"text":258},{"id":4101,"depth":88,"text":263},{"id":4133,"depth":88,"text":268},{"id":4532,"depth":88,"text":283},{"id":5587,"depth":88,"text":288},{"id":5984,"depth":88,"text":293},{"id":6005,"depth":52,"text":298},{"id":6031,"depth":52,"text":112},"/images/blog/entraid-banned-password-list/cover.png","2024-12-16","Automate banned password list management across multiple Entra ID tenants using Microsoft Graph API, PowerShell and Azure DevOps.",{},{"title":22,"description":6070},[6074,6075,6076,6077,3077],"Entra","Authentication","PowerShell","Graph","yikEfxtY0oo-KEiMJhhDkLACN-8IlJoDhqxtSv916T4",{"id":6080,"title":10,"audience":564,"body":6081,"canonical":564,"cover":3067,"cta":564,"date":7926,"description":7927,"extension":1827,"locale":1828,"meta":7928,"navigation":796,"outcome":564,"path":11,"problem":564,"readingTime":771,"seo":7929,"stem":12,"tags":7930,"translationOf":564,"updatedAt":564,"__hash__":7931},"blog/blog/azure-privileged-identity-management-as-code.md",{"type":566,"value":6082,"toc":7913},[6083,6097,6112,6115,6122,6124,6142,6144,6147,6155,6161,6169,6172,6178,6192,6195,6198,6204,6207,6210,6280,6283,6285,6297,6315,6320,6397,6402,6546,6549,6573,6583,6696,6701,6762,6771,7504,7507,7566,7569,7575,7578,7584,7593,7777,7780,7786,7789,7791,7794,7797,7800,7807,7813,7821,7827,7830,7832,7835,7838,7844,7847,7853,7856,7859,7865,7868,7874,7877,7879,7885,7887,7890,7907,7910],[573,6084,6085,6086,6091,6092,2113],{},"In this guide, we will delve into the intricacies of configuring Privileged Identity Management (PIM) Eligible Role Assignments on Azure subscriptions using the ARM API in PowerShell. As seasoned professionals, we recognize that leveraging PIM in Azure is a strategic imperative. However, as DevOps Engineers, we also acknowledge the challenges posed by incorporating Eligible role assignments into deployments. In this post I will expose all the intricacies concerning this piece of automation. Microsoft provides Microsoft Graph cmdlets for ",[682,6087,6090],{"href":6088,"rel":6089},"https://learn.microsoft.com/en-us/powershell/microsoftgraph/tutorial-pim?view=graph-powershell-1.0",[686],"Entra ID PIM",", but for Azure PIM Role Assignments you must use the ",[682,6093,6096],{"href":6094,"rel":6095},"https://learn.microsoft.com/en-us/rest/api/authorization/role-eligibility-schedule-requests?view=rest-authorization-2020-10-01",[686],"Azure Resource Manager (ARM) API",[573,6098,6099,6100,6105,6106,6111],{},"Before we dive into the details, I want to give a shout-out to my colleague ",[682,6101,6104],{"href":6102,"rel":6103},"https://www.linkedin.com/in/bjorn-peters-b48b666a/",[686],"Bjorn Peters",". He did the refactoring of the JSON-body into a PowerShell object so it's easier to manipulate the code. This provides with a cleaner looking script. Also ",[682,6107,6110],{"href":6108,"rel":6109},"https://bjornpeters.com/",[686],"check out his blog"," for interesting articles about Azure, DevOps and automation.",[569,6113,49],{"id":6114},"arm-api",[573,6116,6117,6118,6121],{},"As mentioned, Microsoft provides us with the ",[682,6119,6096],{"href":6094,"rel":6120},[686]," for configuring PIM assignments. While this API offers unparalleled flexibility, deciphering the precise configuration details can sometimes be like unraveling a labyrinth. In this blogpost I will provide code snippets, making automation of PIM eligible roles in your environment a breeze. As mentioned before, we will do everything in PowerShell including the body. Hopefully this will provide you with more insights on how to use the code snippets in your automation.",[569,6123,55],{"id":3128},[1195,6125,6126,6128,6136,6139],{},[1198,6127,3994],{},[1198,6129,4011,6130],{},[1195,6131,6132,6134],{},[1198,6133,4016],{},[1198,6135,4019],{},[1198,6137,6138],{},"Entra ID Premium license for PIM",[1198,6140,6141],{},"optional: Custom role definition",[569,6143,60],{"id":4059},[573,6145,6146],{},"PIM configuration exists of two steps:",[1519,6148,6149],{},[1198,6150,6151,6154],{},[583,6152,6153],{},"Define Role Settings",": These settings determine when role activation occurs. Think of them as your compass.",[573,6156,6157],{},[590,6158],{"alt":6159,"src":6160},"role-settings","/images/blog/azure-privileged-identity-management-as-code/role-settings.png",[1519,6162,6163],{"start":52},[1198,6164,6165,6168],{},[583,6166,6167],{},"Create Eligible Role Assignments",": This step associates roles with users or groups, allowing temporary permission elevation using PIM.",[569,6170,65],{"id":6171},"custom-role-definitions",[573,6173,6174,6177],{},[583,6175,6176],{},"Scope Matters",": When creating custom role definitions, consider the scope. If you intend to target multiple subscriptions, create the custom role definition at the highest possible level. Why? Because each role definition has its own unique GUID. If you create a new GUID for every subscription, your automation complexity increases significantly.",[573,6179,6180,6183,6184,6187,6188,6191],{},[583,6181,6182],{},"Example Scenario",": Imagine an enterprise aiming to restrict developer access. They define a custom role called ",[628,6185,6186],{},"Developer"," with GUID ",[628,6189,6190],{},"3a9c9b30-bb02-43c2-a487-0d5aff050fec",". Now, they want to assign this custom role via PIM to different security groups across various subscriptions. In this case, it's prudent to create the role definition at a higher level, ensuring consistent GUIDs for role assignments across different levels.",[573,6193,6194],{},"Remember these principles as you want to use custom role definitions within PIM. It will streamline your development processes.",[573,6196,6197],{},"Visualization of PIM a custom role definition:",[573,6199,6200],{},[590,6201],{"alt":6202,"src":6203},"pim-role-assignment-levels","/images/blog/azure-privileged-identity-management-as-code/pim-role-assignment-levels.png",[569,6205,70],{"id":6206},"tasks",[573,6208,6209],{},"In this section we are going to execute the following tasks:",[601,6211,6212,6222],{},[604,6213,6214],{},[607,6215,6216,6219],{},[610,6217,6218],{},"#",[610,6220,6221],{},"Task",[620,6223,6224,6232,6240,6248,6256,6264,6272],{},[607,6225,6226,6229],{},[625,6227,6228],{},"1.",[625,6230,6231],{},"Connect to the environment",[607,6233,6234,6237],{},[625,6235,6236],{},"2.",[625,6238,6239],{},"Connect with MG Graph. Used for the creation of groups",[607,6241,6242,6245],{},[625,6243,6244],{},"3.",[625,6246,6247],{},"Create 2 new security groups. 1 for PIM requests, 1 for approval of requests",[607,6249,6250,6253],{},[625,6251,6252],{},"4.",[625,6254,6255],{},"Create a basic function to obtain headers. Used for making API calls",[607,6257,6258,6261],{},[625,6259,6260],{},"5.",[625,6262,6263],{},"Store recurring values in objects",[607,6265,6266,6269],{},[625,6267,6268],{},"6.",[625,6270,6271],{},"Update role policy with a custom role settings",[607,6273,6274,6277],{},[625,6275,6276],{},"7.",[625,6278,6279],{},"Assign the eligible role",[573,6281,6282],{},"In the end we are also going to test our setup.",[569,6284,75],{"id":1190},[1519,6286,6287,6294],{},[1198,6288,6289,6290,6293],{},"Connect with your Azure environment ",[628,6291,6292],{},"Connect-AzAccount"," to get started.",[1198,6295,6296],{},"Connect with MG Graph with at least Group read/write permissions:",[710,6298,6300],{"className":1298,"code":6299,"language":1300,"meta":454,"style":454},"Connect-MgGraph -Scopes \"Group.ReadWrite.All\"\n",[628,6301,6302],{"__ignoreMap":454},[718,6303,6304,6307,6309,6312],{"class":720,"line":46},[718,6305,6306],{"class":756},"Connect-MgGraph",[718,6308,2469],{"class":1002},[718,6310,6311],{"class":733},"Scopes ",[718,6313,6314],{"class":745},"\"Group.ReadWrite.All\"\n",[1519,6316,6317],{"start":88},[1198,6318,6319],{},"Create a new Security Group that will be assigned the PIM Eligible role:",[710,6321,6323],{"className":1298,"code":6322,"language":1300,"meta":454,"style":454},"$pimRequestorGroup = New-MgGroup -DisplayName 'pim-requestor-sg' -MailEnabled:$False  -MailNickName 'pim-test-sg' -SecurityEnabled\n$pimApproverGroup = New-MgGroup -DisplayName 'pim-approver-sg' -MailEnabled:$False  -MailNickName 'pim-test-sg' -SecurityEnabled\n",[628,6324,6325,6365],{"__ignoreMap":454},[718,6326,6327,6330,6332,6335,6337,6340,6343,6345,6348,6351,6354,6357,6360,6362],{"class":720,"line":46},[718,6328,6329],{"class":733},"$pimRequestorGroup ",[718,6331,1003],{"class":1002},[718,6333,6334],{"class":756}," New-MgGroup",[718,6336,2469],{"class":1002},[718,6338,6339],{"class":733},"DisplayName ",[718,6341,6342],{"class":745},"'pim-requestor-sg'",[718,6344,2469],{"class":1002},[718,6346,6347],{"class":733},"MailEnabled:",[718,6349,6350],{"class":756},"$False",[718,6352,6353],{"class":1002},"  -",[718,6355,6356],{"class":733},"MailNickName ",[718,6358,6359],{"class":745},"'pim-test-sg'",[718,6361,2469],{"class":1002},[718,6363,6364],{"class":733},"SecurityEnabled\n",[718,6366,6367,6370,6372,6374,6376,6378,6381,6383,6385,6387,6389,6391,6393,6395],{"class":720,"line":52},[718,6368,6369],{"class":733},"$pimApproverGroup ",[718,6371,1003],{"class":1002},[718,6373,6334],{"class":756},[718,6375,2469],{"class":1002},[718,6377,6339],{"class":733},[718,6379,6380],{"class":745},"'pim-approver-sg'",[718,6382,2469],{"class":1002},[718,6384,6347],{"class":733},[718,6386,6350],{"class":756},[718,6388,6353],{"class":1002},[718,6390,6356],{"class":733},[718,6392,6359],{"class":745},[718,6394,2469],{"class":1002},[718,6396,6364],{"class":733},[1519,6398,6399],{"start":99},[1198,6400,6401],{},"Obtain headers. To be able to execute calls to the ARM API we need to obtain the correct headers. We will wrap this in a function for easier use throughout the codebase:",[710,6403,6405],{"className":1298,"code":6404,"language":1300,"meta":454,"style":454},"Function Get-Headers {\n    Param (\n        [Parameter(Mandatory)]\n        [Array]$Context\n    )\n    $azProfile = [Microsoft.Azure.Commands.Common.Authentication.Abstractions.AzureRmProfileProvider]::Instance.Profile\n    $profileClient = New-Object -TypeName Microsoft.Azure.Commands.ResourceManager.Common.RMProfileClient -ArgumentList ($azProfile)\n    $token = $profileClient.AcquireAccessToken($Context.Subscription.TenantId)\n    $authHeader = @{\n        'Content-Type'  = 'application/json'\n        'Authorization' = 'Bearer ' + $token.AccessToken\n    }\n    return $authHeader\n}\n",[628,6406,6407,6417,6424,6436,6446,6450,6465,6485,6495,6506,6516,6531,6535,6542],{"__ignoreMap":454},[718,6408,6409,6412,6415],{"class":720,"line":46},[718,6410,6411],{"class":1002},"Function",[718,6413,6414],{"class":1225}," Get-Headers",[718,6416,4670],{"class":733},[718,6418,6419,6422],{"class":720,"line":52},[718,6420,6421],{"class":1002},"    Param",[718,6423,4573],{"class":733},[718,6425,6426,6428,6430,6432,6434],{"class":720,"line":88},[718,6427,4682],{"class":733},[718,6429,4581],{"class":756},[718,6431,2802],{"class":733},[718,6433,4587],{"class":4586},[718,6435,4603],{"class":733},[718,6437,6438,6440,6443],{"class":720,"line":99},[718,6439,4682],{"class":733},[718,6441,6442],{"class":1002},"Array",[718,6444,6445],{"class":733},"]$Context\n",[718,6447,6448],{"class":720,"line":760},[718,6449,4751],{"class":733},[718,6451,6452,6455,6457,6459,6462],{"class":720,"line":771},[718,6453,6454],{"class":733},"    $azProfile ",[718,6456,1003],{"class":1002},[718,6458,2561],{"class":733},[718,6460,6461],{"class":1002},"Microsoft.Azure.Commands.Common.Authentication.Abstractions.AzureRmProfileProvider",[718,6463,6464],{"class":733},"]::Instance.Profile\n",[718,6466,6467,6470,6472,6475,6477,6480,6482],{"class":720,"line":782},[718,6468,6469],{"class":733},"    $profileClient ",[718,6471,1003],{"class":1002},[718,6473,6474],{"class":756}," New-Object",[718,6476,2469],{"class":1002},[718,6478,6479],{"class":733},"TypeName Microsoft.Azure.Commands.ResourceManager.Common.RMProfileClient ",[718,6481,2960],{"class":1002},[718,6483,6484],{"class":733},"ArgumentList ($azProfile)\n",[718,6486,6487,6490,6492],{"class":720,"line":793},[718,6488,6489],{"class":733},"    $token ",[718,6491,1003],{"class":1002},[718,6493,6494],{"class":733}," $profileClient.AcquireAccessToken($Context.Subscription.TenantId)\n",[718,6496,6497,6500,6502,6504],{"class":720,"line":800},[718,6498,6499],{"class":733},"    $authHeader ",[718,6501,1003],{"class":1002},[718,6503,2607],{"class":1002},[718,6505,2610],{"class":733},[718,6507,6508,6511,6514],{"class":720,"line":808},[718,6509,6510],{"class":745},"        'Content-Type'",[718,6512,6513],{"class":1002},"  =",[718,6515,4867],{"class":745},[718,6517,6518,6521,6523,6526,6528],{"class":720,"line":822},[718,6519,6520],{"class":745},"        'Authorization'",[718,6522,4597],{"class":1002},[718,6524,6525],{"class":745}," 'Bearer '",[718,6527,5024],{"class":1002},[718,6529,6530],{"class":733}," $token.AccessToken\n",[718,6532,6533],{"class":720,"line":830},[718,6534,2715],{"class":733},[718,6536,6537,6539],{"class":720,"line":841},[718,6538,5281],{"class":1002},[718,6540,6541],{"class":733}," $authHeader\n",[718,6543,6544],{"class":720,"line":852},[718,6545,2721],{"class":733},[573,6547,6548],{},"If we call the function and save the output in an object we can reuse the headers with every API call, like so:",[710,6550,6552],{"className":1298,"code":6551,"language":1300,"meta":454,"style":454},"$headers = Get-Headers -Context (Get-AzContext)\n",[628,6553,6554],{"__ignoreMap":454},[718,6555,6556,6559,6561,6563,6565,6568,6571],{"class":720,"line":46},[718,6557,6558],{"class":733},"$headers ",[718,6560,1003],{"class":1002},[718,6562,6414],{"class":756},[718,6564,2469],{"class":1002},[718,6566,6567],{"class":733},"Context (",[718,6569,6570],{"class":756},"Get-AzContext",[718,6572,2990],{"class":733},[1519,6574,6575],{"start":760},[1198,6576,6577,6578,2113],{},"Store reusable values in objects to use later and switch to the correct context. Find the role definition IDs ",[682,6579,6582],{"href":6580,"rel":6581},"https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles",[686],"here",[710,6584,6586],{"className":1298,"code":6585,"language":1300,"meta":454,"style":454},"$subscription = Get-AzSubscription -SubscriptionId 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' # > replace this with your own subscription ID\n# Switch to the target subscription\nSet-AzContext -Subscription $subscription\n\n$eligibleAssignmentDetails = [PSCustomObject]@{\n    Id               = $pimRequestorGroup.Id # Here we enter the pimRequestorGroup ID set earlier\n    DisplayName      = $pimRequestorGroup.DisplayName # Here we enter the pimGroup DisplayName set earlier\n    EligibleRole     = 'Contributor'\n}\n\n$contributorRoleId = 'b24988ac-6180-42a0-ab88-20f7382dd24c' # This is the targeted role\n",[628,6587,6588,6604,6608,6616,6620,6639,6652,6665,6675,6679,6683],{"__ignoreMap":454},[718,6589,6590,6592,6594,6596,6598,6600,6602],{"class":720,"line":46},[718,6591,2461],{"class":733},[718,6593,1003],{"class":1002},[718,6595,2466],{"class":756},[718,6597,2469],{"class":1002},[718,6599,2472],{"class":733},[718,6601,2475],{"class":745},[718,6603,2478],{"class":723},[718,6605,6606],{"class":720,"line":52},[718,6607,2483],{"class":723},[718,6609,6610,6612,6614],{"class":720,"line":88},[718,6611,2488],{"class":756},[718,6613,2469],{"class":1002},[718,6615,2493],{"class":733},[718,6617,6618],{"class":720,"line":99},[718,6619,797],{"emptyLinePlaceholder":796},[718,6621,6622,6625,6627,6629,6632,6634,6637],{"class":720,"line":760},[718,6623,6624],{"class":733},"$eligibleAssignmentDetails ",[718,6626,1003],{"class":1002},[718,6628,2561],{"class":733},[718,6630,6631],{"class":1002},"PSCustomObject",[718,6633,5348],{"class":733},[718,6635,6636],{"class":1002},"@",[718,6638,2610],{"class":733},[718,6640,6641,6644,6646,6649],{"class":720,"line":771},[718,6642,6643],{"class":733},"    Id               ",[718,6645,1003],{"class":1002},[718,6647,6648],{"class":733}," $pimRequestorGroup.Id ",[718,6650,6651],{"class":723},"# Here we enter the pimRequestorGroup ID set earlier\n",[718,6653,6654,6657,6659,6662],{"class":720,"line":782},[718,6655,6656],{"class":733},"    DisplayName      ",[718,6658,1003],{"class":1002},[718,6660,6661],{"class":733}," $pimRequestorGroup.DisplayName ",[718,6663,6664],{"class":723},"# Here we enter the pimGroup DisplayName set earlier\n",[718,6666,6667,6670,6672],{"class":720,"line":793},[718,6668,6669],{"class":733},"    EligibleRole     ",[718,6671,1003],{"class":1002},[718,6673,6674],{"class":745}," 'Contributor'\n",[718,6676,6677],{"class":720,"line":800},[718,6678,2721],{"class":733},[718,6680,6681],{"class":720,"line":808},[718,6682,797],{"emptyLinePlaceholder":796},[718,6684,6685,6688,6690,6693],{"class":720,"line":822},[718,6686,6687],{"class":733},"$contributorRoleId ",[718,6689,1003],{"class":1002},[718,6691,6692],{"class":745}," 'b24988ac-6180-42a0-ab88-20f7382dd24c'",[718,6694,6695],{"class":723}," # This is the targeted role\n",[1519,6697,6698],{"start":771},[1198,6699,6700],{},"Update the role policy so it requires an approval on activation.",[710,6702,6704],{"className":1298,"code":6703,"language":1300,"meta":454,"style":454},"# First, get the current role policy. We do this because you are only allowed to update role policies:\n$getRolePolicyUri = \"https://management.azure.com/providers/Microsoft.Subscription/subscriptions/{0}/providers/Microsoft.Authorization/roleManagementPolicies?api-version=2020-10-01&`$filter=roleDefinitionId%20eq%20'subscriptions/{0}/providers/Microsoft.Authorization/roleDefinitions/{1}'\" -f $subscription.Id, $contributorRoleId\n$rolePolicy = (Invoke-RestMethod -Uri $getRolePolicyUri -Method Get -Headers $headers).value\n",[628,6705,6706,6711,6736],{"__ignoreMap":454},[718,6707,6708],{"class":720,"line":46},[718,6709,6710],{"class":723},"# First, get the current role policy. We do this because you are only allowed to update role policies:\n",[718,6712,6713,6716,6718,6721,6724,6727,6729,6731,6733],{"class":720,"line":52},[718,6714,6715],{"class":733},"$getRolePolicyUri ",[718,6717,1003],{"class":1002},[718,6719,6720],{"class":745}," \"https://management.azure.com/providers/Microsoft.Subscription/subscriptions/{0}/providers/Microsoft.Authorization/roleManagementPolicies?api-version=2020-10-01&",[718,6722,6723],{"class":756},"`$",[718,6725,6726],{"class":745},"filter=roleDefinitionId%20eq%20'subscriptions/{0}/providers/Microsoft.Authorization/roleDefinitions/{1}'\"",[718,6728,2584],{"class":1002},[718,6730,2587],{"class":733},[718,6732,2590],{"class":1002},[718,6734,6735],{"class":733}," $contributorRoleId\n",[718,6737,6738,6741,6743,6745,6747,6749,6752,6754,6757,6759],{"class":720,"line":88},[718,6739,6740],{"class":733},"$rolePolicy ",[718,6742,1003],{"class":1002},[718,6744,4788],{"class":733},[718,6746,2952],{"class":756},[718,6748,2469],{"class":1002},[718,6750,6751],{"class":733},"Uri $getRolePolicyUri ",[718,6753,2960],{"class":1002},[718,6755,6756],{"class":733},"Method Get ",[718,6758,2960],{"class":1002},[718,6760,6761],{"class":733},"Headers $headers).value\n",[573,6763,6764,6765,6770],{},"Now that we obtained the role policy, we need to create a body to update the policy to our liking. For reference checkout the ",[682,6766,6769],{"href":6767,"rel":6768},"https://learn.microsoft.com/en-us/rest/api/authorization/role-management-policies/get?view=rest-authorization-2020-10-01&tabs=HTTP",[686],"docs",". The body in PowerShell gives us more flexibility if we want to loop through multiple roles/groups/approvers etc. Here goes:",[710,6772,6774],{"className":1298,"code":6773,"language":1300,"meta":454,"style":454},"# Assemble body for this request\n$body = [PSCustomObject]@{\n    properties = [PSCustomObject]@{\n        rules = @(\n            # Enter the basics\n            [PSCustomObject]@{\n                isExpirationRequired = $true\n                maximumDuration      = 'PT8H' #Role can be activated for a maximum of 8 hours\n                id                   = 'Expiration_EndUser_Assignment'\n                ruleType             = 'RoleManagementPolicyExpirationRule'\n                target               = @{\n                    caller     = 'EndUser'\n                    operations = @(\n                        'All'\n                    )\n                }\n                level                = 'Assignment'\n            },\n            [PSCustomObject]@{\n                enabledRules = @(\n                    'Justification', # Requires the user to add a justification in their request\n                    'MultiFactorAuthentication' # Requires MFA authentication for the request\n                )\n                id           = 'Enablement_EndUser_Assignment'\n                ruleType     = 'RoleManagementPolicyEnablementRule'\n                target       = @{\n                    caller     = 'EndUser'\n                    operations = @(\n                        'All'\n                    )\n                    level      = 'Assignment'\n                }\n            },\n            [PSCustomObject]@{\n                isExpirationRequired = $false # Makes the role permanently eligible\n                maximumDuration      = 'P365D' # Maximum duration of eligible role assignment\n                id                   = 'Expiration_Admin_Eligibility'\n                ruleType             = 'RoleManagementPolicyExpirationRule'\n                target               = @{\n                    caller     = 'Admin'\n                    operations = @(\n                        'All'\n                    )\n                    level      = 'Eligibility'\n                }\n            },\n            # The next section adds Approvers to the body object. If you don't want/need approvers you can leave this part out of your script:\n            [PSCustomObject]@{\n                setting  = [PSCustomObject]@{\n                    isApprovalRequired               = $true # Makes approval required for the request on this role\n                    isApprovalRequiredForExtension   = $false\n                    isRequestorJustificationRequired = $true\n                    approvalMode                     = 'SingleStage'\n                    approvalStages                   = @(\n                        @{\n                            approvalStageTimeOutInDays      = 1\n                            isApproverJustificationRequired = $true\n                            escalationTimeInMinutes         = 0\n                            isEscalationEnabled             = $false\n                            primaryApprovers                = @(\n                                [PSCustomObject]@{\n                                    id          = $pimApproverGroup.Id # Reference to the Security Group that was created earlier\n                                    description = $null\n                                    isBackup    = $false\n                                    userType    = \"Group\"\n                                }\n                            )\n                        }\n                    )\n                }\n                id       = 'Approval_EndUser_Assignment'\n                ruleType = 'RoleManagementPolicyApprovalRule'\n                target   = @{\n                    caller     = 'EndUser'\n                    operations = @(\n                        'All'\n                    )\n                    level      = 'Assignment'\n                }\n            }\n        )\n    }\n}\n",[628,6775,6776,6781,6797,6813,6825,6830,6843,6853,6866,6876,6886,6897,6907,6918,6923,6928,6933,6943,6950,6962,6973,6983,6991,6996,7006,7016,7027,7035,7045,7049,7053,7062,7066,7072,7084,7096,7108,7117,7125,7135,7144,7154,7158,7162,7171,7175,7181,7186,7198,7215,7228,7238,7247,7257,7268,7275,7285,7294,7304,7313,7324,7337,7350,7359,7368,7378,7383,7389,7395,7400,7405,7416,7427,7439,7448,7459,7464,7469,7478,7483,7488,7494,7499],{"__ignoreMap":454},[718,6777,6778],{"class":720,"line":46},[718,6779,6780],{"class":723},"# Assemble body for this request\n",[718,6782,6783,6785,6787,6789,6791,6793,6795],{"class":720,"line":52},[718,6784,2602],{"class":733},[718,6786,1003],{"class":1002},[718,6788,2561],{"class":733},[718,6790,6631],{"class":1002},[718,6792,5348],{"class":733},[718,6794,6636],{"class":1002},[718,6796,2610],{"class":733},[718,6798,6799,6801,6803,6805,6807,6809,6811],{"class":720,"line":88},[718,6800,2780],{"class":733},[718,6802,1003],{"class":1002},[718,6804,2561],{"class":733},[718,6806,6631],{"class":1002},[718,6808,5348],{"class":733},[718,6810,6636],{"class":1002},[718,6812,2610],{"class":733},[718,6814,6815,6818,6820,6822],{"class":720,"line":99},[718,6816,6817],{"class":733},"        rules ",[718,6819,1003],{"class":1002},[718,6821,2607],{"class":1002},[718,6823,6824],{"class":733},"(\n",[718,6826,6827],{"class":720,"line":760},[718,6828,6829],{"class":723},"            # Enter the basics\n",[718,6831,6832,6835,6837,6839,6841],{"class":720,"line":771},[718,6833,6834],{"class":733},"            [",[718,6836,6631],{"class":1002},[718,6838,5348],{"class":733},[718,6840,6636],{"class":1002},[718,6842,2610],{"class":733},[718,6844,6845,6848,6850],{"class":720,"line":782},[718,6846,6847],{"class":733},"                isExpirationRequired ",[718,6849,1003],{"class":1002},[718,6851,6852],{"class":756}," $true\n",[718,6854,6855,6858,6860,6863],{"class":720,"line":793},[718,6856,6857],{"class":733},"                maximumDuration      ",[718,6859,1003],{"class":1002},[718,6861,6862],{"class":745}," 'PT8H'",[718,6864,6865],{"class":723}," #Role can be activated for a maximum of 8 hours\n",[718,6867,6868,6871,6873],{"class":720,"line":800},[718,6869,6870],{"class":733},"                id                   ",[718,6872,1003],{"class":1002},[718,6874,6875],{"class":745}," 'Expiration_EndUser_Assignment'\n",[718,6877,6878,6881,6883],{"class":720,"line":808},[718,6879,6880],{"class":733},"                ruleType             ",[718,6882,1003],{"class":1002},[718,6884,6885],{"class":745}," 'RoleManagementPolicyExpirationRule'\n",[718,6887,6888,6891,6893,6895],{"class":720,"line":822},[718,6889,6890],{"class":733},"                target               ",[718,6892,1003],{"class":1002},[718,6894,2607],{"class":1002},[718,6896,2610],{"class":733},[718,6898,6899,6902,6904],{"class":720,"line":830},[718,6900,6901],{"class":733},"                    caller     ",[718,6903,1003],{"class":1002},[718,6905,6906],{"class":745}," 'EndUser'\n",[718,6908,6909,6912,6914,6916],{"class":720,"line":841},[718,6910,6911],{"class":733},"                    operations ",[718,6913,1003],{"class":1002},[718,6915,2607],{"class":1002},[718,6917,6824],{"class":733},[718,6919,6920],{"class":720,"line":852},[718,6921,6922],{"class":745},"                        'All'\n",[718,6924,6925],{"class":720,"line":863},[718,6926,6927],{"class":733},"                    )\n",[718,6929,6930],{"class":720,"line":874},[718,6931,6932],{"class":733},"                }\n",[718,6934,6935,6938,6940],{"class":720,"line":879},[718,6936,6937],{"class":733},"                level                ",[718,6939,1003],{"class":1002},[718,6941,6942],{"class":745}," 'Assignment'\n",[718,6944,6945,6948],{"class":720,"line":891},[718,6946,6947],{"class":733},"            }",[718,6949,4163],{"class":1002},[718,6951,6952,6954,6956,6958,6960],{"class":720,"line":898},[718,6953,6834],{"class":733},[718,6955,6631],{"class":1002},[718,6957,5348],{"class":733},[718,6959,6636],{"class":1002},[718,6961,2610],{"class":733},[718,6963,6964,6967,6969,6971],{"class":720,"line":907},[718,6965,6966],{"class":733},"                enabledRules ",[718,6968,1003],{"class":1002},[718,6970,2607],{"class":1002},[718,6972,6824],{"class":733},[718,6974,6975,6978,6980],{"class":720,"line":917},[718,6976,6977],{"class":745},"                    'Justification'",[718,6979,2590],{"class":1002},[718,6981,6982],{"class":723}," # Requires the user to add a justification in their request\n",[718,6984,6985,6988],{"class":720,"line":927},[718,6986,6987],{"class":745},"                    'MultiFactorAuthentication'",[718,6989,6990],{"class":723}," # Requires MFA authentication for the request\n",[718,6992,6993],{"class":720,"line":937},[718,6994,6995],{"class":733},"                )\n",[718,6997,6998,7001,7003],{"class":720,"line":945},[718,6999,7000],{"class":733},"                id           ",[718,7002,1003],{"class":1002},[718,7004,7005],{"class":745}," 'Enablement_EndUser_Assignment'\n",[718,7007,7008,7011,7013],{"class":720,"line":2677},[718,7009,7010],{"class":733},"                ruleType     ",[718,7012,1003],{"class":1002},[718,7014,7015],{"class":745}," 'RoleManagementPolicyEnablementRule'\n",[718,7017,7018,7021,7023,7025],{"class":720,"line":2689},[718,7019,7020],{"class":733},"                target       ",[718,7022,1003],{"class":1002},[718,7024,2607],{"class":1002},[718,7026,2610],{"class":733},[718,7028,7029,7031,7033],{"class":720,"line":2700},[718,7030,6901],{"class":733},[718,7032,1003],{"class":1002},[718,7034,6906],{"class":745},[718,7036,7037,7039,7041,7043],{"class":720,"line":2706},[718,7038,6911],{"class":733},[718,7040,1003],{"class":1002},[718,7042,2607],{"class":1002},[718,7044,6824],{"class":733},[718,7046,7047],{"class":720,"line":2712},[718,7048,6922],{"class":745},[718,7050,7051],{"class":720,"line":2718},[718,7052,6927],{"class":733},[718,7054,7055,7058,7060],{"class":720,"line":2724},[718,7056,7057],{"class":733},"                    level      ",[718,7059,1003],{"class":1002},[718,7061,6942],{"class":745},[718,7063,7064],{"class":720,"line":2737},[718,7065,6932],{"class":733},[718,7067,7068,7070],{"class":720,"line":2743},[718,7069,6947],{"class":733},[718,7071,4163],{"class":1002},[718,7073,7074,7076,7078,7080,7082],{"class":720,"line":2761},[718,7075,6834],{"class":733},[718,7077,6631],{"class":1002},[718,7079,5348],{"class":733},[718,7081,6636],{"class":1002},[718,7083,2610],{"class":733},[718,7085,7086,7088,7090,7093],{"class":720,"line":2766},[718,7087,6847],{"class":733},[718,7089,1003],{"class":1002},[718,7091,7092],{"class":756}," $false",[718,7094,7095],{"class":723}," # Makes the role permanently eligible\n",[718,7097,7098,7100,7102,7105],{"class":720,"line":2777},[718,7099,6857],{"class":733},[718,7101,1003],{"class":1002},[718,7103,7104],{"class":745}," 'P365D'",[718,7106,7107],{"class":723}," # Maximum duration of eligible role assignment\n",[718,7109,7110,7112,7114],{"class":720,"line":2789},[718,7111,6870],{"class":733},[718,7113,1003],{"class":1002},[718,7115,7116],{"class":745}," 'Expiration_Admin_Eligibility'\n",[718,7118,7119,7121,7123],{"class":720,"line":2818},[718,7120,6880],{"class":733},[718,7122,1003],{"class":1002},[718,7124,6885],{"class":745},[718,7126,7127,7129,7131,7133],{"class":720,"line":2829},[718,7128,6890],{"class":733},[718,7130,1003],{"class":1002},[718,7132,2607],{"class":1002},[718,7134,2610],{"class":733},[718,7136,7137,7139,7141],{"class":720,"line":2839},[718,7138,6901],{"class":733},[718,7140,1003],{"class":1002},[718,7142,7143],{"class":745}," 'Admin'\n",[718,7145,7146,7148,7150,7152],{"class":720,"line":2850},[718,7147,6911],{"class":733},[718,7149,1003],{"class":1002},[718,7151,2607],{"class":1002},[718,7153,6824],{"class":733},[718,7155,7156],{"class":720,"line":2861},[718,7157,6922],{"class":745},[718,7159,7160],{"class":720,"line":2873},[718,7161,6927],{"class":733},[718,7163,7164,7166,7168],{"class":720,"line":2885},[718,7165,7057],{"class":733},[718,7167,1003],{"class":1002},[718,7169,7170],{"class":745}," 'Eligibility'\n",[718,7172,7173],{"class":720,"line":2896},[718,7174,6932],{"class":733},[718,7176,7177,7179],{"class":720,"line":2907},[718,7178,6947],{"class":733},[718,7180,4163],{"class":1002},[718,7182,7183],{"class":720,"line":2918},[718,7184,7185],{"class":723},"            # The next section adds Approvers to the body object. If you don't want/need approvers you can leave this part out of your script:\n",[718,7187,7188,7190,7192,7194,7196],{"class":720,"line":2923},[718,7189,6834],{"class":733},[718,7191,6631],{"class":1002},[718,7193,5348],{"class":733},[718,7195,6636],{"class":1002},[718,7197,2610],{"class":733},[718,7199,7200,7203,7205,7207,7209,7211,7213],{"class":720,"line":2928},[718,7201,7202],{"class":733},"                setting  ",[718,7204,1003],{"class":1002},[718,7206,2561],{"class":733},[718,7208,6631],{"class":1002},[718,7210,5348],{"class":733},[718,7212,6636],{"class":1002},[718,7214,2610],{"class":733},[718,7216,7217,7220,7222,7225],{"class":720,"line":2933},[718,7218,7219],{"class":733},"                    isApprovalRequired               ",[718,7221,1003],{"class":1002},[718,7223,7224],{"class":756}," $true",[718,7226,7227],{"class":723}," # Makes approval required for the request on this role\n",[718,7229,7230,7233,7235],{"class":720,"line":2938},[718,7231,7232],{"class":733},"                    isApprovalRequiredForExtension   ",[718,7234,1003],{"class":1002},[718,7236,7237],{"class":756}," $false\n",[718,7239,7240,7243,7245],{"class":720,"line":2943},[718,7241,7242],{"class":733},"                    isRequestorJustificationRequired ",[718,7244,1003],{"class":1002},[718,7246,6852],{"class":756},[718,7248,7249,7252,7254],{"class":720,"line":2949},[718,7250,7251],{"class":733},"                    approvalMode                     ",[718,7253,1003],{"class":1002},[718,7255,7256],{"class":745}," 'SingleStage'\n",[718,7258,7259,7262,7264,7266],{"class":720,"line":5193},[718,7260,7261],{"class":733},"                    approvalStages                   ",[718,7263,1003],{"class":1002},[718,7265,2607],{"class":1002},[718,7267,6824],{"class":733},[718,7269,7270,7273],{"class":720,"line":5207},[718,7271,7272],{"class":1002},"                        @",[718,7274,2610],{"class":733},[718,7276,7277,7280,7282],{"class":720,"line":5212},[718,7278,7279],{"class":733},"                            approvalStageTimeOutInDays      ",[718,7281,1003],{"class":1002},[718,7283,7284],{"class":756}," 1\n",[718,7286,7287,7290,7292],{"class":720,"line":5222},[718,7288,7289],{"class":733},"                            isApproverJustificationRequired ",[718,7291,1003],{"class":1002},[718,7293,6852],{"class":756},[718,7295,7296,7299,7301],{"class":720,"line":5227},[718,7297,7298],{"class":733},"                            escalationTimeInMinutes         ",[718,7300,1003],{"class":1002},[718,7302,7303],{"class":756}," 0\n",[718,7305,7306,7309,7311],{"class":720,"line":5234},[718,7307,7308],{"class":733},"                            isEscalationEnabled             ",[718,7310,1003],{"class":1002},[718,7312,7237],{"class":756},[718,7314,7315,7318,7320,7322],{"class":720,"line":5249},[718,7316,7317],{"class":733},"                            primaryApprovers                ",[718,7319,1003],{"class":1002},[718,7321,2607],{"class":1002},[718,7323,6824],{"class":733},[718,7325,7326,7329,7331,7333,7335],{"class":720,"line":5254},[718,7327,7328],{"class":733},"                                [",[718,7330,6631],{"class":1002},[718,7332,5348],{"class":733},[718,7334,6636],{"class":1002},[718,7336,2610],{"class":733},[718,7338,7339,7342,7344,7347],{"class":720,"line":5261},[718,7340,7341],{"class":733},"                                    id          ",[718,7343,1003],{"class":1002},[718,7345,7346],{"class":733}," $pimApproverGroup.Id ",[718,7348,7349],{"class":723},"# Reference to the Security Group that was created earlier\n",[718,7351,7352,7355,7357],{"class":720,"line":5268},[718,7353,7354],{"class":733},"                                    description ",[718,7356,1003],{"class":1002},[718,7358,2904],{"class":756},[718,7360,7361,7364,7366],{"class":720,"line":5273},[718,7362,7363],{"class":733},"                                    isBackup    ",[718,7365,1003],{"class":1002},[718,7367,7237],{"class":756},[718,7369,7370,7373,7375],{"class":720,"line":5278},[718,7371,7372],{"class":733},"                                    userType    ",[718,7374,1003],{"class":1002},[718,7376,7377],{"class":745}," \"Group\"\n",[718,7379,7380],{"class":720,"line":5287},[718,7381,7382],{"class":733},"                                }\n",[718,7384,7386],{"class":720,"line":7385},67,[718,7387,7388],{"class":733},"                            )\n",[718,7390,7392],{"class":720,"line":7391},68,[718,7393,7394],{"class":733},"                        }\n",[718,7396,7398],{"class":720,"line":7397},69,[718,7399,6927],{"class":733},[718,7401,7403],{"class":720,"line":7402},70,[718,7404,6932],{"class":733},[718,7406,7408,7411,7413],{"class":720,"line":7407},71,[718,7409,7410],{"class":733},"                id       ",[718,7412,1003],{"class":1002},[718,7414,7415],{"class":745}," 'Approval_EndUser_Assignment'\n",[718,7417,7419,7422,7424],{"class":720,"line":7418},72,[718,7420,7421],{"class":733},"                ruleType ",[718,7423,1003],{"class":1002},[718,7425,7426],{"class":745}," 'RoleManagementPolicyApprovalRule'\n",[718,7428,7430,7433,7435,7437],{"class":720,"line":7429},73,[718,7431,7432],{"class":733},"                target   ",[718,7434,1003],{"class":1002},[718,7436,2607],{"class":1002},[718,7438,2610],{"class":733},[718,7440,7442,7444,7446],{"class":720,"line":7441},74,[718,7443,6901],{"class":733},[718,7445,1003],{"class":1002},[718,7447,6906],{"class":745},[718,7449,7451,7453,7455,7457],{"class":720,"line":7450},75,[718,7452,6911],{"class":733},[718,7454,1003],{"class":1002},[718,7456,2607],{"class":1002},[718,7458,6824],{"class":733},[718,7460,7462],{"class":720,"line":7461},76,[718,7463,6922],{"class":745},[718,7465,7467],{"class":720,"line":7466},77,[718,7468,6927],{"class":733},[718,7470,7472,7474,7476],{"class":720,"line":7471},78,[718,7473,7057],{"class":733},[718,7475,1003],{"class":1002},[718,7477,6942],{"class":745},[718,7479,7481],{"class":720,"line":7480},79,[718,7482,6932],{"class":733},[718,7484,7486],{"class":720,"line":7485},80,[718,7487,2703],{"class":733},[718,7489,7491],{"class":720,"line":7490},81,[718,7492,7493],{"class":733},"        )\n",[718,7495,7497],{"class":720,"line":7496},82,[718,7498,2715],{"class":733},[718,7500,7502],{"class":720,"line":7501},83,[718,7503,2721],{"class":733},[573,7505,7506],{},"We have created the body, now it's time to update the role policy! First we construct the URI by inserting the ID we got from obtaining the role policy. Secondly we update the role policy by calling the API with a PATCH method. If all goes well the role policy should be updated.",[710,7508,7510],{"className":1298,"code":7509,"language":1300,"meta":454,"style":454},"$patchRolePolicyUri = \"https://management.azure.com{0}?api-version=2020-10-01\" -f $rolePolicy.id\n$patchPolicyRequest = Invoke-RestMethod -Uri $patchRolePolicyUri -Method Patch -Headers $headers -Body ($body | ConvertTo-Json -Depth 10)\n",[628,7511,7512,7527],{"__ignoreMap":454},[718,7513,7514,7517,7519,7522,7524],{"class":720,"line":46},[718,7515,7516],{"class":733},"$patchRolePolicyUri ",[718,7518,1003],{"class":1002},[718,7520,7521],{"class":745}," \"https://management.azure.com{0}?api-version=2020-10-01\"",[718,7523,2584],{"class":1002},[718,7525,7526],{"class":733}," $rolePolicy.id\n",[718,7528,7529,7532,7534,7536,7538,7541,7543,7546,7548,7550,7552,7554,7556,7558,7560,7562,7564],{"class":720,"line":52},[718,7530,7531],{"class":733},"$patchPolicyRequest ",[718,7533,1003],{"class":1002},[718,7535,5121],{"class":756},[718,7537,2469],{"class":1002},[718,7539,7540],{"class":733},"Uri $patchRolePolicyUri ",[718,7542,2960],{"class":1002},[718,7544,7545],{"class":733},"Method Patch ",[718,7547,2960],{"class":1002},[718,7549,2968],{"class":733},[718,7551,2960],{"class":1002},[718,7553,2973],{"class":733},[718,7555,2976],{"class":1002},[718,7557,2979],{"class":756},[718,7559,2469],{"class":1002},[718,7561,2984],{"class":733},[718,7563,2987],{"class":756},[718,7565,2990],{"class":733},[573,7567,7568],{},"Before:",[573,7570,7571],{},[590,7572],{"alt":7573,"src":7574},"role-policy-before","/images/blog/azure-privileged-identity-management-as-code/role-policy-before.png",[573,7576,7577],{},"After:",[573,7579,7580],{},[590,7581],{"alt":7582,"src":7583},"role-policy-after","/images/blog/azure-privileged-identity-management-as-code/role-policy-after.png",[1519,7585,7586],{"start":782},[1198,7587,7588,7589,7592],{},"Assign the Eligible role to the ",[628,7590,7591],{},"pimRequestorGroup"," Security Group:",[710,7594,7596],{"className":1298,"code":7595,"language":1300,"meta":454,"style":454},"# Create the Eligible role with a custom GUID\n# Create body\n$body = @{\n    Properties = @{\n        RoleDefinitionID = \"/subscriptions/$Subscription.Id/providers/Microsoft.Authorization/roleDefinitions/$contributorRoleId\"\n        PrincipalId      = $pimRequestorGroup.Id\n        RequestType      = 'AdminAssign'\n        ScheduleInfo     = @{\n            Expiration = @{\n                Type = 'NoExpiration'\n            }\n        }\n    }\n}\n$guid = [guid]::NewGuid()\n# Construct Uri with subscription Id and new GUID\n$createEligibleRoleUri = \"https://management.azure.com/providers/Microsoft.Subscription/subscriptions/{0}/providers/Microsoft.Authorization/roleEligibilityScheduleRequests/{1}?api-version=2020-10-01\" -f $Subscription.Id, $guid\n\n# Call the API with PUT to assign the role to the targeted Security Group\nInvoke-RestMethod -Uri $createEligibleRoleUri -Method Put -Headers $headers -Body ($body | ConvertTo-Json -Depth 10)\n",[628,7597,7598,7603,7608,7618,7628,7644,7652,7660,7670,7680,7688,7692,7696,7700,7704,7716,7720,7736,7740,7745],{"__ignoreMap":454},[718,7599,7600],{"class":720,"line":46},[718,7601,7602],{"class":723},"# Create the Eligible role with a custom GUID\n",[718,7604,7605],{"class":720,"line":52},[718,7606,7607],{"class":723},"# Create body\n",[718,7609,7610,7612,7614,7616],{"class":720,"line":88},[718,7611,2602],{"class":733},[718,7613,1003],{"class":1002},[718,7615,2607],{"class":1002},[718,7617,2610],{"class":733},[718,7619,7620,7622,7624,7626],{"class":720,"line":99},[718,7621,2615],{"class":733},[718,7623,1003],{"class":1002},[718,7625,2607],{"class":1002},[718,7627,2610],{"class":733},[718,7629,7630,7632,7634,7636,7638,7640,7642],{"class":720,"line":760},[718,7631,2626],{"class":733},[718,7633,1003],{"class":1002},[718,7635,2631],{"class":745},[718,7637,2634],{"class":733},[718,7639,2637],{"class":745},[718,7641,2640],{"class":733},[718,7643,2643],{"class":745},[718,7645,7646,7648,7650],{"class":720,"line":771},[718,7647,2648],{"class":733},[718,7649,1003],{"class":1002},[718,7651,2653],{"class":733},[718,7653,7654,7656,7658],{"class":720,"line":782},[718,7655,2658],{"class":733},[718,7657,1003],{"class":1002},[718,7659,2663],{"class":745},[718,7661,7662,7664,7666,7668],{"class":720,"line":793},[718,7663,2668],{"class":733},[718,7665,1003],{"class":1002},[718,7667,2607],{"class":1002},[718,7669,2610],{"class":733},[718,7671,7672,7674,7676,7678],{"class":720,"line":800},[718,7673,2680],{"class":733},[718,7675,1003],{"class":1002},[718,7677,2607],{"class":1002},[718,7679,2610],{"class":733},[718,7681,7682,7684,7686],{"class":720,"line":808},[718,7683,2692],{"class":733},[718,7685,1003],{"class":1002},[718,7687,2697],{"class":745},[718,7689,7690],{"class":720,"line":822},[718,7691,2703],{"class":733},[718,7693,7694],{"class":720,"line":830},[718,7695,2709],{"class":733},[718,7697,7698],{"class":720,"line":841},[718,7699,2715],{"class":733},[718,7701,7702],{"class":720,"line":852},[718,7703,2721],{"class":733},[718,7705,7706,7708,7710,7712,7714],{"class":720,"line":863},[718,7707,2556],{"class":733},[718,7709,1003],{"class":1002},[718,7711,2561],{"class":733},[718,7713,2564],{"class":1002},[718,7715,2567],{"class":733},[718,7717,7718],{"class":720,"line":874},[718,7719,2740],{"class":723},[718,7721,7722,7724,7726,7728,7730,7732,7734],{"class":720,"line":879},[718,7723,2576],{"class":733},[718,7725,1003],{"class":1002},[718,7727,2581],{"class":745},[718,7729,2584],{"class":1002},[718,7731,2754],{"class":733},[718,7733,2590],{"class":1002},[718,7735,2593],{"class":733},[718,7737,7738],{"class":720,"line":891},[718,7739,797],{"emptyLinePlaceholder":796},[718,7741,7742],{"class":720,"line":898},[718,7743,7744],{"class":723},"# Call the API with PUT to assign the role to the targeted Security Group\n",[718,7746,7747,7749,7751,7753,7755,7757,7759,7761,7763,7765,7767,7769,7771,7773,7775],{"class":720,"line":907},[718,7748,2952],{"class":756},[718,7750,2469],{"class":1002},[718,7752,2957],{"class":733},[718,7754,2960],{"class":1002},[718,7756,2963],{"class":733},[718,7758,2960],{"class":1002},[718,7760,2968],{"class":733},[718,7762,2960],{"class":1002},[718,7764,2973],{"class":733},[718,7766,2976],{"class":1002},[718,7768,2979],{"class":756},[718,7770,2469],{"class":1002},[718,7772,2984],{"class":733},[718,7774,2987],{"class":756},[718,7776,2990],{"class":733},[573,7778,7779],{},"Result:",[573,7781,7782],{},[590,7783],{"alt":7784,"src":7785},"role-assignment-after","/images/blog/azure-privileged-identity-management-as-code/role-assignment-after.png",[569,7787,80],{"id":7788},"testing",[573,7790,82],{},[676,7792,85],{"id":7793},"group-membership",[573,7795,7796],{},"Add members to the created groups:",[573,7798,7799],{},"Remember, users cannot approve their own requests.",[1195,7801,7802],{},[1198,7803,7804,7806],{},[628,7805,7591],{},": Users who can request activation of the Contributor role.",[573,7808,7809],{},[590,7810],{"alt":7811,"src":7812},"pim-requestor","/images/blog/azure-privileged-identity-management-as-code/requestor-group.png",[1195,7814,7815],{},[1198,7816,7817,7820],{},[628,7818,7819],{},"pimApprovalGroup",": Approvers responsible for granting or denying requests.",[573,7822,7823],{},[590,7824],{"alt":7825,"src":7826},"pim-approver","/images/blog/azure-privileged-identity-management-as-code/approver-group.png",[676,7828,91],{"id":7829},"test-the-workflow",[573,7831,93],{},[4356,7833,96],{"id":7834},"requestor",[573,7836,7837],{},"Make a PIM request:",[573,7839,7840],{},[590,7841],{"alt":7842,"src":7843},"PIM-request","/images/blog/azure-privileged-identity-management-as-code/pim-request.png",[573,7845,7846],{},"Fill in the justification and hit 'Submit':",[573,7848,7849],{},[590,7850],{"alt":7851,"src":7852},"PIM-request-justification","/images/blog/azure-privileged-identity-management-as-code/pim-request-justification.png",[4356,7854,102],{"id":7855},"approver",[573,7857,7858],{},"As an approver, start the approval of the PIM request by selecting the request and clicking 'Approve':",[573,7860,7861],{},[590,7862],{"alt":7863,"src":7864},"PIM-approve-request","/images/blog/azure-privileged-identity-management-as-code/pim-approve-request.png",[573,7866,7867],{},"Finally, check the request and if it meets requirements approve it with a justification:",[573,7869,7870],{},[590,7871],{"alt":7872,"src":7873},"PIM-approve-request-submit","/images/blog/azure-privileged-identity-management-as-code/pim-approve-request-submit-600px.png",[4356,7875,107],{"id":7876},"validate",[573,7878,109],{},[573,7880,7881],{},[590,7882],{"alt":7883,"src":7884},"validate-PIM-role-assignment","/images/blog/azure-privileged-identity-management-as-code/validation.png",[569,7886,112],{"id":6031},[573,7888,7889],{},"That concludes this blog about configuring PIM via the ARM API with PowerShell. We have successfully:",[1195,7891,7892,7895,7898,7901,7904],{},[1198,7893,7894],{},"✅ Created 2 new security groups. 1 for PIM requests, 1 for approval of requests",[1198,7896,7897],{},"✅ Wrote a basic function to obtain headers that we used for making API calls",[1198,7899,7900],{},"✅ Updated a role policy with custom role settings",[1198,7902,7903],{},"✅ Assigned the eligible role to our Security Group",[1198,7905,7906],{},"✅ Successfully tested the workflow 🔐",[573,7908,7909],{},"With PIM now in place, your organization gains precise control over permissions, enhancing security and compliance. Hopefully this post provided you with some guidance on how to automate PIM in your environment. Good luck and feel free to reach out if you have any questions! 🚀",[1791,7911,7912],{},"html pre.shiki code .suiK_, html code.shiki .suiK_{--shiki-light:#005CC5;--shiki-default:#79B8FF;--shiki-dark:#79B8FF}html pre.shiki code .so5gQ, html code.shiki .so5gQ{--shiki-light:#D73A49;--shiki-default:#F97583;--shiki-dark:#F97583}html pre.shiki code .slsVL, html code.shiki .slsVL{--shiki-light:#24292E;--shiki-default:#E1E4E8;--shiki-dark:#E1E4E8}html pre.shiki code .sfrk1, html code.shiki .sfrk1{--shiki-light:#032F62;--shiki-default:#9ECBFF;--shiki-dark:#9ECBFF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .shcOC, html code.shiki .shcOC{--shiki-light:#6F42C1;--shiki-default:#B392F0;--shiki-dark:#B392F0}html pre.shiki code .sQHwn, html code.shiki .sQHwn{--shiki-light:#E36209;--shiki-default:#FFAB70;--shiki-dark:#FFAB70}html pre.shiki code .sCsY4, html code.shiki .sCsY4{--shiki-light:#6A737D;--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":454,"searchDepth":52,"depth":52,"links":7914},[7915,7916,7917,7918,7919,7920,7921,7925],{"id":6114,"depth":52,"text":49},{"id":3128,"depth":52,"text":55},{"id":4059,"depth":52,"text":60},{"id":6171,"depth":52,"text":65},{"id":6206,"depth":52,"text":70},{"id":1190,"depth":52,"text":75},{"id":7788,"depth":52,"text":80,"children":7922},[7923,7924],{"id":7793,"depth":88,"text":85},{"id":7829,"depth":88,"text":91},{"id":6031,"depth":52,"text":112},"2024-05-01","Configure PIM Eligible Role Assignments on Azure subscriptions using the ARM API in PowerShell, including role policies, approvers, and eligible role creation.",{},{"title":10,"description":7927},[3074,2181,6076,3076,3077],"ZLYk9zvu1dXDMfyjB4yANlJhz75bfqLFrD5j1SDJq0M",{"id":7933,"title":30,"audience":564,"body":7934,"canonical":564,"cover":9306,"cta":564,"date":9307,"description":9308,"extension":1827,"locale":1828,"meta":9309,"navigation":796,"outcome":564,"path":31,"problem":564,"readingTime":760,"seo":9310,"stem":32,"tags":9311,"translationOf":564,"updatedAt":564,"__hash__":9315},"blog/blog/intune-ubuntu-24-04.md",{"type":566,"value":7935,"toc":9290},[7936,7945,7948,7951,8090,8093,8101,8104,8110,8113,8116,8125,8137,8233,8241,8411,8414,8419,8520,8525,8550,8555,8638,8643,8646,8653,8662,8705,8713,8749,8758,8783,8787,8790,8792,8795,8798,8808,8834,8837,8840,8849,8871,8874,8877,8891,8894,8897,8907,9036,9045,9094,9097,9100,9112,9118,9174,9177,9183,9279,9282,9284,9287],[573,7937,7938,7939,7944],{},"In this guide, I'll walk you through setting up Ubuntu 24.04 LTS with Intune. As a Cloud Architect at ",[682,7940,7943],{"href":7941,"rel":7942},"https://rubicon.nl/",[686],"Rubicon B.V.",", I've been testing whether Ubuntu provides the Edge (pun intended) I need to fulfill my work activities. Specifically, I'll cover how to install the Intune Portal as well as the software I used for my Ubuntu 24.04 installation. You will find instructions for every installation below. I hope this helps you if you have any issues enrolling Ubuntu 24.04 with Intune.",[569,7946,343],{"id":7947},"steps",[573,7949,7950],{},"Here's a list of things that we're going through in this post:",[601,7952,7953,7967],{},[604,7954,7955],{},[607,7956,7957,7959,7962,7964],{},[610,7958,6218],{},[610,7960,7961],{},"Software",[610,7963,1137],{},[610,7965,7966],{},"Installation",[620,7968,7969,7982,7993,8006,8019,8031,8041,8053,8065,8078],{},[607,7970,7971,7973,7976,7979],{},[625,7972,6228],{},[625,7974,7975],{},"Microsoft Edge",[625,7977,7978],{},"Company device management",[625,7980,7981],{},"apt",[607,7983,7984,7986,7989,7991],{},[625,7985,6236],{},[625,7987,7988],{},"Intune Portal",[625,7990,7978],{},[625,7992,7981],{},[607,7994,7995,7997,8000,8003],{},[625,7996,6244],{},[625,7998,7999],{},"Microsoft 365, including Teams and Outlook",[625,8001,8002],{},"Office activities",[625,8004,8005],{},"PWA",[607,8007,8008,8010,8013,8016],{},[625,8009,6252],{},[625,8011,8012],{},"Draw.io",[625,8014,8015],{},"Creating designs",[625,8017,8018],{},"Snap",[607,8020,8021,8023,8026,8029],{},[625,8022,6260],{},[625,8024,8025],{},"VS Code",[625,8027,8028],{},"Development",[625,8030,8018],{},[607,8032,8033,8035,8037,8039],{},[625,8034,6268],{},[625,8036,6076],{},[625,8038,8028],{},[625,8040,8018],{},[607,8042,8043,8045,8048,8051],{},[625,8044,6276],{},[625,8046,8047],{},"KeepassXC",[625,8049,8050],{},"Password management",[625,8052,8018],{},[607,8054,8055,8058,8061,8063],{},[625,8056,8057],{},"8.",[625,8059,8060],{},"Azure CLI",[625,8062,8028],{},[625,8064,7981],{},[607,8066,8067,8070,8073,8075],{},[625,8068,8069],{},"9.",[625,8071,8072],{},"Bicep",[625,8074,8028],{},[625,8076,8077],{},"binary",[607,8079,8080,8083,8085,8088],{},[625,8081,8082],{},"10.",[625,8084,388],{},[625,8086,8087],{},"Multi-monitor support",[625,8089,7981],{},[569,8091,348],{"id":8092},"software-on-beta-branch-of-ubuntu-2404-lts",[573,8094,8095,8096,8098,8099,2113],{},"When I started going down this road, Ubuntu 24.04 was still in beta. Installing software on a pre-release version of Ubuntu can be challenging. Typically, I prefer to keep packages as close to the source as possible. This means either installing from the official repository using ",[628,8097,7981],{},", or adding the developer's repository and then installing with ",[628,8100,7981],{},[573,8102,8103],{},"Initially, I hesitated about using Snap packages due to concerns about their larger size and potential performance impact compared to APT packages. However, when dealing with a beta version of Ubuntu 24.04 LTS, options become limited. Lack of up-to-date documentation and repositories often leads to tinkering with apt sources and keyrings. This process involves navigating dependencies and version pinning, which can be error-prone. By opting for Snap, I streamlined the installation process, making it more straightforward and reliable.",[573,8105,8106,8109],{},[583,8107,8108],{},"Update 25-05-2024",": Ubuntu 24.04 LTS was officially released. Still, taking into account software release cycles it is expected many applications have not yet found their way into the 24.04 repositories.",[569,8111,353],{"id":8112},"manual-installation-of-the-intune-portal",[573,8114,8115],{},"The Intune portal is provided (and officially supported) for Ubuntu 22.04. By adding backport repositories it is possible to install it on 24.04 without compatibility issues. Follow the steps below to install the Intune Portal application.",[1519,8117,8118],{},[1198,8119,8120,8121,8124],{},"Edit ",[628,8122,8123],{},"/etc/apt/sources.list.d/ubuntu.sources"," and:",[1195,8126,8127,8130],{},[1198,8128,8129],{},"Make sure you have both noble sources and mantic sources",[1198,8131,8132,8133,8136],{},"Add an entry for ",[628,8134,8135],{},"mantic-security"," as well",[710,8138,8140],{"className":985,"code":8139,"language":987,"meta":454,"style":454},"Types: deb\nURIs: http://nl.archive.ubuntu.com/ubuntu/\nSuites: mantic\nComponents: main restricted universe multiverse\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n\nTypes: deb\nURIs: http://security.ubuntu.com/ubuntu/\nSuites: mantic-security\nComponents: main restricted universe multiverse\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n",[628,8141,8142,8150,8158,8166,8183,8191,8195,8201,8208,8215,8227],{"__ignoreMap":454},[718,8143,8144,8147],{"class":720,"line":46},[718,8145,8146],{"class":1225},"Types:",[718,8148,8149],{"class":745}," deb\n",[718,8151,8152,8155],{"class":720,"line":52},[718,8153,8154],{"class":1225},"URIs:",[718,8156,8157],{"class":745}," http://nl.archive.ubuntu.com/ubuntu/\n",[718,8159,8160,8163],{"class":720,"line":88},[718,8161,8162],{"class":1225},"Suites:",[718,8164,8165],{"class":745}," mantic\n",[718,8167,8168,8171,8174,8177,8180],{"class":720,"line":99},[718,8169,8170],{"class":1225},"Components:",[718,8172,8173],{"class":745}," main",[718,8175,8176],{"class":745}," restricted",[718,8178,8179],{"class":745}," universe",[718,8181,8182],{"class":745}," multiverse\n",[718,8184,8185,8188],{"class":720,"line":760},[718,8186,8187],{"class":1225},"Signed-By:",[718,8189,8190],{"class":745}," /usr/share/keyrings/ubuntu-archive-keyring.gpg\n",[718,8192,8193],{"class":720,"line":771},[718,8194,797],{"emptyLinePlaceholder":796},[718,8196,8197,8199],{"class":720,"line":782},[718,8198,8146],{"class":1225},[718,8200,8149],{"class":745},[718,8202,8203,8205],{"class":720,"line":793},[718,8204,8154],{"class":1225},[718,8206,8207],{"class":745}," http://security.ubuntu.com/ubuntu/\n",[718,8209,8210,8212],{"class":720,"line":800},[718,8211,8162],{"class":1225},[718,8213,8214],{"class":745}," mantic-security\n",[718,8216,8217,8219,8221,8223,8225],{"class":720,"line":808},[718,8218,8170],{"class":1225},[718,8220,8173],{"class":745},[718,8222,8176],{"class":745},[718,8224,8179],{"class":745},[718,8226,8182],{"class":745},[718,8228,8229,8231],{"class":720,"line":822},[718,8230,8187],{"class":1225},[718,8232,8190],{"class":745},[1195,8234,8235],{},[1198,8236,8237,8238,8240],{},"The file ",[628,8239,8123],{}," should look like the code block below:",[710,8242,8244],{"className":985,"code":8243,"language":987,"meta":454,"style":454},"Types: deb\nURIs: http://archive.ubuntu.com/ubuntu\nSuites: noble noble-updates noble-backports\nComponents: main restricted universe multiverse\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n\nTypes: deb\nURIs: http://security.ubuntu.com/ubuntu/\nSuites: noble-security\nComponents: main restricted universe multiverse\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n\nTypes: deb\nURIs: http://nl.archive.ubuntu.com/ubuntu/\nSuites: mantic\nComponents: main restricted universe multiverse\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n\nTypes: deb\nURIs: http://security.ubuntu.com/ubuntu/\nSuites: mantic-security\nComponents: main restricted universe multiverse\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n",[628,8245,8246,8252,8259,8272,8284,8290,8294,8300,8306,8313,8325,8331,8335,8341,8347,8353,8365,8371,8375,8381,8387,8393,8405],{"__ignoreMap":454},[718,8247,8248,8250],{"class":720,"line":46},[718,8249,8146],{"class":1225},[718,8251,8149],{"class":745},[718,8253,8254,8256],{"class":720,"line":52},[718,8255,8154],{"class":1225},[718,8257,8258],{"class":745}," http://archive.ubuntu.com/ubuntu\n",[718,8260,8261,8263,8266,8269],{"class":720,"line":88},[718,8262,8162],{"class":1225},[718,8264,8265],{"class":745}," noble",[718,8267,8268],{"class":745}," noble-updates",[718,8270,8271],{"class":745}," noble-backports\n",[718,8273,8274,8276,8278,8280,8282],{"class":720,"line":99},[718,8275,8170],{"class":1225},[718,8277,8173],{"class":745},[718,8279,8176],{"class":745},[718,8281,8179],{"class":745},[718,8283,8182],{"class":745},[718,8285,8286,8288],{"class":720,"line":760},[718,8287,8187],{"class":1225},[718,8289,8190],{"class":745},[718,8291,8292],{"class":720,"line":771},[718,8293,797],{"emptyLinePlaceholder":796},[718,8295,8296,8298],{"class":720,"line":782},[718,8297,8146],{"class":1225},[718,8299,8149],{"class":745},[718,8301,8302,8304],{"class":720,"line":793},[718,8303,8154],{"class":1225},[718,8305,8207],{"class":745},[718,8307,8308,8310],{"class":720,"line":800},[718,8309,8162],{"class":1225},[718,8311,8312],{"class":745}," noble-security\n",[718,8314,8315,8317,8319,8321,8323],{"class":720,"line":808},[718,8316,8170],{"class":1225},[718,8318,8173],{"class":745},[718,8320,8176],{"class":745},[718,8322,8179],{"class":745},[718,8324,8182],{"class":745},[718,8326,8327,8329],{"class":720,"line":822},[718,8328,8187],{"class":1225},[718,8330,8190],{"class":745},[718,8332,8333],{"class":720,"line":830},[718,8334,797],{"emptyLinePlaceholder":796},[718,8336,8337,8339],{"class":720,"line":841},[718,8338,8146],{"class":1225},[718,8340,8149],{"class":745},[718,8342,8343,8345],{"class":720,"line":852},[718,8344,8154],{"class":1225},[718,8346,8157],{"class":745},[718,8348,8349,8351],{"class":720,"line":863},[718,8350,8162],{"class":1225},[718,8352,8165],{"class":745},[718,8354,8355,8357,8359,8361,8363],{"class":720,"line":874},[718,8356,8170],{"class":1225},[718,8358,8173],{"class":745},[718,8360,8176],{"class":745},[718,8362,8179],{"class":745},[718,8364,8182],{"class":745},[718,8366,8367,8369],{"class":720,"line":879},[718,8368,8187],{"class":1225},[718,8370,8190],{"class":745},[718,8372,8373],{"class":720,"line":891},[718,8374,797],{"emptyLinePlaceholder":796},[718,8376,8377,8379],{"class":720,"line":898},[718,8378,8146],{"class":1225},[718,8380,8149],{"class":745},[718,8382,8383,8385],{"class":720,"line":907},[718,8384,8154],{"class":1225},[718,8386,8207],{"class":745},[718,8388,8389,8391],{"class":720,"line":917},[718,8390,8162],{"class":1225},[718,8392,8214],{"class":745},[718,8394,8395,8397,8399,8401,8403],{"class":720,"line":927},[718,8396,8170],{"class":1225},[718,8398,8173],{"class":745},[718,8400,8176],{"class":745},[718,8402,8179],{"class":745},[718,8404,8182],{"class":745},[718,8406,8407,8409],{"class":720,"line":937},[718,8408,8187],{"class":1225},[718,8410,8190],{"class":745},[573,8412,8413],{},"This will ensure you have access to 22.04 (mantic) packages which we need during the next phase.",[1519,8415,8416],{"start":52},[1198,8417,8418],{},"Install Microsoft Edge for Business. Edge is needed for the Intune Portal as it leverages the built-in authentication mechanisms.",[710,8420,8422],{"className":985,"code":8421,"language":987,"meta":454,"style":454},"curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > microsoft.gpg\nsudo install -o root -g root -m 644 microsoft.gpg /etc/apt/trusted.gpg.d/\nsudo sh -c 'echo \"deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main\" > /etc/apt/sources.list.d/microsoft-edge-dev.list'\nsudo rm microsoft.gpg\nsudo apt update && sudo apt install microsoft-edge-stable\n",[628,8423,8424,8446,8476,8489,8498],{"__ignoreMap":454},[718,8425,8426,8429,8432,8434,8437,8440,8443],{"class":720,"line":46},[718,8427,8428],{"class":1225},"curl",[718,8430,8431],{"class":745}," https://packages.microsoft.com/keys/microsoft.asc",[718,8433,5448],{"class":1002},[718,8435,8436],{"class":1225}," gpg",[718,8438,8439],{"class":756}," --dearmor",[718,8441,8442],{"class":1002}," >",[718,8444,8445],{"class":745}," microsoft.gpg\n",[718,8447,8448,8451,8454,8457,8460,8463,8465,8467,8470,8473],{"class":720,"line":52},[718,8449,8450],{"class":1225},"sudo",[718,8452,8453],{"class":745}," install",[718,8455,8456],{"class":756}," -o",[718,8458,8459],{"class":745}," root",[718,8461,8462],{"class":756}," -g",[718,8464,8459],{"class":745},[718,8466,3836],{"class":756},[718,8468,8469],{"class":756}," 644",[718,8471,8472],{"class":745}," microsoft.gpg",[718,8474,8475],{"class":745}," /etc/apt/trusted.gpg.d/\n",[718,8477,8478,8480,8483,8486],{"class":720,"line":88},[718,8479,8450],{"class":1225},[718,8481,8482],{"class":745}," sh",[718,8484,8485],{"class":756}," -c",[718,8487,8488],{"class":745}," 'echo \"deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main\" > /etc/apt/sources.list.d/microsoft-edge-dev.list'\n",[718,8490,8491,8493,8496],{"class":720,"line":99},[718,8492,8450],{"class":1225},[718,8494,8495],{"class":745}," rm",[718,8497,8445],{"class":745},[718,8499,8500,8502,8505,8508,8511,8513,8515,8517],{"class":720,"line":760},[718,8501,8450],{"class":1225},[718,8503,8504],{"class":745}," apt",[718,8506,8507],{"class":745}," update",[718,8509,8510],{"class":733}," && ",[718,8512,8450],{"class":1225},[718,8514,8504],{"class":745},[718,8516,8453],{"class":745},[718,8518,8519],{"class":745}," microsoft-edge-stable\n",[1519,8521,8522],{"start":88},[1198,8523,8524],{},"Install the prerequisites for the Intune Portal:",[710,8526,8528],{"className":985,"code":8527,"language":987,"meta":454,"style":454},"sudo apt install openjdk-11-jre libicu72 libjavascriptcoregtk-4.0-18 libwebkit2gtk-4.0-37\n",[628,8529,8530],{"__ignoreMap":454},[718,8531,8532,8534,8536,8538,8541,8544,8547],{"class":720,"line":46},[718,8533,8450],{"class":1225},[718,8535,8504],{"class":745},[718,8537,8453],{"class":745},[718,8539,8540],{"class":745}," openjdk-11-jre",[718,8542,8543],{"class":745}," libicu72",[718,8545,8546],{"class":745}," libjavascriptcoregtk-4.0-18",[718,8548,8549],{"class":745}," libwebkit2gtk-4.0-37\n",[1519,8551,8552],{"start":99},[1198,8553,8554],{},"Install intune-portal:",[710,8556,8558],{"className":985,"code":8557,"language":987,"meta":454,"style":454},"curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > microsoft.gpg\nsudo install -o root -g root -m 644 microsoft.gpg /usr/share/keyrings/\nsudo sh -c 'echo \"deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft.gpg] https://packages.microsoft.com/ubuntu/22.04/prod jammy main\" > /etc/apt/sources.list.d/microsoft-ubuntu-jammy-prod.list'\nsudo rm microsoft.gpg\nsudo apt update\nsudo apt install intune-portal\n",[628,8559,8560,8576,8599,8610,8618,8627],{"__ignoreMap":454},[718,8561,8562,8564,8566,8568,8570,8572,8574],{"class":720,"line":46},[718,8563,8428],{"class":1225},[718,8565,8431],{"class":745},[718,8567,5448],{"class":1002},[718,8569,8436],{"class":1225},[718,8571,8439],{"class":756},[718,8573,8442],{"class":1002},[718,8575,8445],{"class":745},[718,8577,8578,8580,8582,8584,8586,8588,8590,8592,8594,8596],{"class":720,"line":52},[718,8579,8450],{"class":1225},[718,8581,8453],{"class":745},[718,8583,8456],{"class":756},[718,8585,8459],{"class":745},[718,8587,8462],{"class":756},[718,8589,8459],{"class":745},[718,8591,3836],{"class":756},[718,8593,8469],{"class":756},[718,8595,8472],{"class":745},[718,8597,8598],{"class":745}," /usr/share/keyrings/\n",[718,8600,8601,8603,8605,8607],{"class":720,"line":88},[718,8602,8450],{"class":1225},[718,8604,8482],{"class":745},[718,8606,8485],{"class":756},[718,8608,8609],{"class":745}," 'echo \"deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft.gpg] https://packages.microsoft.com/ubuntu/22.04/prod jammy main\" > /etc/apt/sources.list.d/microsoft-ubuntu-jammy-prod.list'\n",[718,8611,8612,8614,8616],{"class":720,"line":99},[718,8613,8450],{"class":1225},[718,8615,8495],{"class":745},[718,8617,8445],{"class":745},[718,8619,8620,8622,8624],{"class":720,"line":760},[718,8621,8450],{"class":1225},[718,8623,8504],{"class":745},[718,8625,8626],{"class":745}," update\n",[718,8628,8629,8631,8633,8635],{"class":720,"line":771},[718,8630,8450],{"class":1225},[718,8632,8504],{"class":745},[718,8634,8453],{"class":745},[718,8636,8637],{"class":745}," intune-portal\n",[1519,8639,8640],{"start":760},[1198,8641,8642],{},"Sign in and smile! (note: It can take up to 1 hour for it to sync, please be patient)",[569,8644,358],{"id":8645},"using-an-older-version-of-the-microsoft-identity-broker-package",[8647,8648,8650],"alert",{"type":8649},"info",[573,8651,8652],{},"I tested the latest microsoft-identity-broker package, and it now works with the Intune Portal. Please use the latest version where possible!",[1519,8654,8655],{},[1198,8656,8657,8658,8661],{},"If you need ",[628,8659,8660],{},"microsoft-identity-broker"," v.1.7.0 follow these steps:",[710,8663,8665],{"className":985,"code":8664,"language":987,"meta":454,"style":454},"sudo apt purge microsoft-identity-broker\nsudo apt install microsoft-identity-broker=1.7.0\nsudo apt-mark hold microsoft-identity-broker\n",[628,8666,8667,8679,8693],{"__ignoreMap":454},[718,8668,8669,8671,8673,8676],{"class":720,"line":46},[718,8670,8450],{"class":1225},[718,8672,8504],{"class":745},[718,8674,8675],{"class":745}," purge",[718,8677,8678],{"class":745}," microsoft-identity-broker\n",[718,8680,8681,8683,8685,8687,8690],{"class":720,"line":52},[718,8682,8450],{"class":1225},[718,8684,8504],{"class":745},[718,8686,8453],{"class":745},[718,8688,8689],{"class":745}," microsoft-identity-broker=",[718,8691,8692],{"class":756},"1.7.0\n",[718,8694,8695,8697,8700,8703],{"class":720,"line":88},[718,8696,8450],{"class":1225},[718,8698,8699],{"class":745}," apt-mark",[718,8701,8702],{"class":745}," hold",[718,8704,8678],{"class":745},[1519,8706,8707],{"start":52},[1198,8708,8709,8710,8712],{},"If you use ",[628,8711,8660],{}," v.1.7.0 and want to go to the latest version, follow these steps:",[710,8714,8716],{"className":985,"code":8715,"language":987,"meta":454,"style":454},"sudo apt-mark unhold microsoft-identity-broker\nsudo apt purge microsoft-identity-broker\nsudo apt install microsoft-identity-broker\n",[628,8717,8718,8729,8739],{"__ignoreMap":454},[718,8719,8720,8722,8724,8727],{"class":720,"line":46},[718,8721,8450],{"class":1225},[718,8723,8699],{"class":745},[718,8725,8726],{"class":745}," unhold",[718,8728,8678],{"class":745},[718,8730,8731,8733,8735,8737],{"class":720,"line":52},[718,8732,8450],{"class":1225},[718,8734,8504],{"class":745},[718,8736,8675],{"class":745},[718,8738,8678],{"class":745},[718,8740,8741,8743,8745,8747],{"class":720,"line":88},[718,8742,8450],{"class":1225},[718,8744,8504],{"class":745},[718,8746,8453],{"class":745},[718,8748,8678],{"class":745},[1519,8750,8751],{"start":88},[1198,8752,8753,8754,8757],{},"Purge ",[628,8755,8756],{},"intune-portal"," from apt and install it once again so it uses the latest Microsoft Identity Broker:",[710,8759,8761],{"className":985,"code":8760,"language":987,"meta":454,"style":454},"sudo apt purge intune-portal\nsudo apt install intune-portal\n",[628,8762,8763,8773],{"__ignoreMap":454},[718,8764,8765,8767,8769,8771],{"class":720,"line":46},[718,8766,8450],{"class":1225},[718,8768,8504],{"class":745},[718,8770,8675],{"class":745},[718,8772,8637],{"class":745},[718,8774,8775,8777,8779,8781],{"class":720,"line":52},[718,8776,8450],{"class":1225},[718,8778,8504],{"class":745},[718,8780,8453],{"class":745},[718,8782,8637],{"class":745},[1519,8784,8785],{"start":99},[1198,8786,8642],{},[569,8788,363],{"id":8789},"other-software",[573,8791,365],{},[676,8793,368],{"id":8794},"snap-packages",[573,8796,8797],{},"These snaps work like a charm:",[1195,8799,8800,8802,8804,8806],{},[1198,8801,6076],{},[1198,8803,8025],{},[1198,8805,8047],{},[1198,8807,8012],{},[710,8809,8811],{"className":985,"code":8810,"language":987,"meta":454,"style":454},"sudo snap install powershell vscode keepassxc drawio\n",[628,8812,8813],{"__ignoreMap":454},[718,8814,8815,8817,8820,8822,8825,8828,8831],{"class":720,"line":46},[718,8816,8450],{"class":1225},[718,8818,8819],{"class":745}," snap",[718,8821,8453],{"class":745},[718,8823,8824],{"class":745}," powershell",[718,8826,8827],{"class":745}," vscode",[718,8829,8830],{"class":745}," keepassxc",[718,8832,8833],{"class":745}," drawio\n",[569,8835,373],{"id":8836},"apt-packages",[573,8838,8839],{},"Apt packages that work without issues on Ubuntu 24.04 LTS:",[1195,8841,8842,8844,8846],{},[1198,8843,1226],{},[1198,8845,8428],{},[1198,8847,8848],{},"gnome-tweaks",[710,8850,8852],{"className":985,"code":8851,"language":987,"meta":454,"style":454},"sudo apt install git curl gnome-tweaks\n",[628,8853,8854],{"__ignoreMap":454},[718,8855,8856,8858,8860,8862,8865,8868],{"class":720,"line":46},[718,8857,8450],{"class":1225},[718,8859,8504],{"class":745},[718,8861,8453],{"class":745},[718,8863,8864],{"class":745}," git",[718,8866,8867],{"class":745}," curl",[718,8869,8870],{"class":745}," gnome-tweaks\n",[676,8872,378],{"id":8873},"progressive-web-app-pwa",[573,8875,8876],{},"To be able to leverage Microsoft's Office suite and Teams client you can install them as PWA on the system. I've installed:",[1195,8878,8879,8882,8885,8888],{},[1198,8880,8881],{},"Outlook",[1198,8883,8884],{},"Microsoft 365",[1198,8886,8887],{},"Teams (v2)",[1198,8889,8890],{},"OneNote",[573,8892,8893],{},"Installation can be done via your specific browser. I used Edge and pinned the PWA's to my dock.",[569,8895,383],{"id":8896},"azure-cli-bicep",[1519,8898,8899],{},[1198,8900,8901,8902,4367],{},"Azure CLI has no official candidate for 24.04, but you can use 22.04 just fine ",[682,8903,8906],{"href":8904,"rel":8905},"https://learn.microsoft.com/en-us/cli/azure/install-azure-cli-linux?pivots=apt",[686],"(link)",[710,8908,8910],{"className":985,"code":8909,"language":987,"meta":454,"style":454},"curl -sLS https://packages.microsoft.com/keys/microsoft.asc |\n  sudo gpg --dearmor -o /etc/apt/keyrings/microsoft.gpg\nsudo chmod go+r /etc/apt/keyrings/microsoft.gpg\nAZ_DIST='jammy'\necho \"Types: deb\nURIs: https://packages.microsoft.com/repos/azure-cli/\nSuites: ${AZ_DIST}\nComponents: main\nArchitectures: $(dpkg --print-architecture)\nSigned-by: /etc/apt/keyrings/microsoft.gpg\" | sudo tee /etc/apt/sources.list.d/azure-cli.sources\nsudo apt-get update\nsudo apt-get install azure-cli\n",[628,8911,8912,8924,8938,8950,8960,8968,8973,8982,8987,9000,9016,9025],{"__ignoreMap":454},[718,8913,8914,8916,8919,8921],{"class":720,"line":46},[718,8915,8428],{"class":1225},[718,8917,8918],{"class":756}," -sLS",[718,8920,8431],{"class":745},[718,8922,8923],{"class":1002}," |\n",[718,8925,8926,8929,8931,8933,8935],{"class":720,"line":52},[718,8927,8928],{"class":1225},"  sudo",[718,8930,8436],{"class":745},[718,8932,8439],{"class":756},[718,8934,8456],{"class":756},[718,8936,8937],{"class":745}," /etc/apt/keyrings/microsoft.gpg\n",[718,8939,8940,8942,8945,8948],{"class":720,"line":88},[718,8941,8450],{"class":1225},[718,8943,8944],{"class":745}," chmod",[718,8946,8947],{"class":745}," go+r",[718,8949,8937],{"class":745},[718,8951,8952,8955,8957],{"class":720,"line":99},[718,8953,8954],{"class":733},"AZ_DIST",[718,8956,1003],{"class":1002},[718,8958,8959],{"class":745},"'jammy'\n",[718,8961,8962,8965],{"class":720,"line":760},[718,8963,8964],{"class":756},"echo",[718,8966,8967],{"class":745}," \"Types: deb\n",[718,8969,8970],{"class":720,"line":771},[718,8971,8972],{"class":745},"URIs: https://packages.microsoft.com/repos/azure-cli/\n",[718,8974,8975,8978,8980],{"class":720,"line":782},[718,8976,8977],{"class":745},"Suites: ${",[718,8979,8954],{"class":733},[718,8981,2721],{"class":745},[718,8983,8984],{"class":720,"line":793},[718,8985,8986],{"class":745},"Components: main\n",[718,8988,8989,8992,8995,8998],{"class":720,"line":800},[718,8990,8991],{"class":745},"Architectures: $(",[718,8993,8994],{"class":1225},"dpkg",[718,8996,8997],{"class":756}," --print-architecture",[718,8999,2990],{"class":745},[718,9001,9002,9005,9007,9010,9013],{"class":720,"line":808},[718,9003,9004],{"class":745},"Signed-by: /etc/apt/keyrings/microsoft.gpg\"",[718,9006,5448],{"class":1002},[718,9008,9009],{"class":1225}," sudo",[718,9011,9012],{"class":745}," tee",[718,9014,9015],{"class":745}," /etc/apt/sources.list.d/azure-cli.sources\n",[718,9017,9018,9020,9023],{"class":720,"line":822},[718,9019,8450],{"class":1225},[718,9021,9022],{"class":745}," apt-get",[718,9024,8626],{"class":745},[718,9026,9027,9029,9031,9033],{"class":720,"line":830},[718,9028,8450],{"class":1225},[718,9030,9022],{"class":745},[718,9032,8453],{"class":745},[718,9034,9035],{"class":745}," azure-cli\n",[1519,9037,9038],{"start":52},[1198,9039,9040,9041,4367],{},"For Bicep get the latest binary ",[682,9042,8906],{"href":9043,"rel":9044},"https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/install#install-manually",[686],[710,9046,9048],{"className":985,"code":9047,"language":987,"meta":454,"style":454},"curl -Lo bicep https://github.com/Azure/bicep/releases/latest/download/bicep-linux-x64\nchmod +x ./bicep\nsudo mv ./bicep /usr/local/bin/bicep\nbicep --help\n",[628,9049,9050,9063,9074,9087],{"__ignoreMap":454},[718,9051,9052,9054,9057,9060],{"class":720,"line":46},[718,9053,8428],{"class":1225},[718,9055,9056],{"class":756}," -Lo",[718,9058,9059],{"class":745}," bicep",[718,9061,9062],{"class":745}," https://github.com/Azure/bicep/releases/latest/download/bicep-linux-x64\n",[718,9064,9065,9068,9071],{"class":720,"line":52},[718,9066,9067],{"class":1225},"chmod",[718,9069,9070],{"class":745}," +x",[718,9072,9073],{"class":745}," ./bicep\n",[718,9075,9076,9078,9081,9084],{"class":720,"line":88},[718,9077,8450],{"class":1225},[718,9079,9080],{"class":745}," mv",[718,9082,9083],{"class":745}," ./bicep",[718,9085,9086],{"class":745}," /usr/local/bin/bicep\n",[718,9088,9089,9091],{"class":720,"line":99},[718,9090,3233],{"class":1225},[718,9092,9093],{"class":756}," --help\n",[569,9095,388],{"id":9096},"displaylink",[573,9098,9099],{},"To support multi-monitor setups you need DisplayLink software from Synapse. You can install DisplayLink with these commands:",[9101,9102,9103],"blockquote",{},[573,9104,9105,9106,9111],{},"If you are using secure boot: Follow the steps on ",[682,9107,9110],{"href":9108,"rel":9109},"https://support.displaylink.com/knowledgebase/articles/1181617-how-to-use-displaylink-ubuntu-driver-with-uefi-sec",[686],"this page"," or see the GIF below.",[573,9113,9114],{},[590,9115],{"alt":9116,"src":9117},"secureboot","/images/blog/intune-ubuntu-24-04/secureboot.gif",[710,9119,9121],{"className":985,"code":9120,"language":987,"meta":454,"style":454},"wget -P ~/Downloads https://www.synaptics.com/sites/default/files/Ubuntu/pool/stable/main/all/synaptics-repository-keyring.deb | sudo apt install ~/Downloads/synaptics-repository-keyring.deb\nsudo apt update\nsudo apt install displaylink-driver\nrm ~/Downloads/synaptics-repository-keyring.deb\n",[628,9122,9123,9148,9156,9167],{"__ignoreMap":454},[718,9124,9125,9128,9131,9134,9137,9139,9141,9143,9145],{"class":720,"line":46},[718,9126,9127],{"class":1225},"wget",[718,9129,9130],{"class":756}," -P",[718,9132,9133],{"class":745}," ~/Downloads",[718,9135,9136],{"class":745}," https://www.synaptics.com/sites/default/files/Ubuntu/pool/stable/main/all/synaptics-repository-keyring.deb",[718,9138,5448],{"class":1002},[718,9140,9009],{"class":1225},[718,9142,8504],{"class":745},[718,9144,8453],{"class":745},[718,9146,9147],{"class":745}," ~/Downloads/synaptics-repository-keyring.deb\n",[718,9149,9150,9152,9154],{"class":720,"line":52},[718,9151,8450],{"class":1225},[718,9153,8504],{"class":745},[718,9155,8626],{"class":745},[718,9157,9158,9160,9162,9164],{"class":720,"line":88},[718,9159,8450],{"class":1225},[718,9161,8504],{"class":745},[718,9163,8453],{"class":745},[718,9165,9166],{"class":745}," displaylink-driver\n",[718,9168,9169,9172],{"class":720,"line":99},[718,9170,9171],{"class":1225},"rm",[718,9173,9147],{"class":745},[569,9175,393],{"id":9176},"further-git-configuration",[573,9178,9179,9180,4367],{},"To integrate git secrets with the gnome-keyring you have to compile the ",[628,9181,9182],{},"git-credential-libsecret",[710,9184,9186],{"className":985,"code":9185,"language":987,"meta":454,"style":454},"sudo apt-get install -y libsecret-tools\nsudo apt-get install -y gcc make libsecret-1-0 libsecret-1-dev\ncd /usr/share/doc/git/contrib/credential/libsecret\nsudo make\ngit config --global credential.helper /usr/share/doc/git/contrib/credential/libsecret/git-credential-libsecret\nsudo apt purge libsecret-1-dev -y && sudo apt autoremove -y\n",[628,9187,9188,9202,9224,9231,9238,9254],{"__ignoreMap":454},[718,9189,9190,9192,9194,9196,9199],{"class":720,"line":46},[718,9191,8450],{"class":1225},[718,9193,9022],{"class":745},[718,9195,8453],{"class":745},[718,9197,9198],{"class":756}," -y",[718,9200,9201],{"class":745}," libsecret-tools\n",[718,9203,9204,9206,9208,9210,9212,9215,9218,9221],{"class":720,"line":52},[718,9205,8450],{"class":1225},[718,9207,9022],{"class":745},[718,9209,8453],{"class":745},[718,9211,9198],{"class":756},[718,9213,9214],{"class":745}," gcc",[718,9216,9217],{"class":745}," make",[718,9219,9220],{"class":745}," libsecret-1-0",[718,9222,9223],{"class":745}," libsecret-1-dev\n",[718,9225,9226,9228],{"class":720,"line":88},[718,9227,1237],{"class":756},[718,9229,9230],{"class":745}," /usr/share/doc/git/contrib/credential/libsecret\n",[718,9232,9233,9235],{"class":720,"line":99},[718,9234,8450],{"class":1225},[718,9236,9237],{"class":745}," make\n",[718,9239,9240,9242,9245,9248,9251],{"class":720,"line":760},[718,9241,1226],{"class":1225},[718,9243,9244],{"class":745}," config",[718,9246,9247],{"class":756}," --global",[718,9249,9250],{"class":745}," credential.helper",[718,9252,9253],{"class":745}," /usr/share/doc/git/contrib/credential/libsecret/git-credential-libsecret\n",[718,9255,9256,9258,9260,9262,9265,9267,9269,9271,9273,9276],{"class":720,"line":771},[718,9257,8450],{"class":1225},[718,9259,8504],{"class":745},[718,9261,8675],{"class":745},[718,9263,9264],{"class":745}," libsecret-1-dev",[718,9266,9198],{"class":756},[718,9268,8510],{"class":733},[718,9270,8450],{"class":1225},[718,9272,8504],{"class":745},[718,9274,9275],{"class":745}," autoremove",[718,9277,9278],{"class":756}," -y\n",[573,9280,9281],{},"After the configuration you execute git commands on your repo, fill in the password at the prompt and it will be saved to the Gnome Keyring.",[569,9283,112],{"id":6031},[573,9285,9286],{},"And there you have it: my Ubuntu 24.04 installation, seamlessly integrated with Intune. After a week of working with this setup, I can confidently say it's both robust and lightning-fast! Even on an Intel i7 7700HQ, the performance is impressive, so if you're using newer hardware, expect an even smoother experience. Now, I'm curious—what's your experience been like with Ubuntu and Intune?",[1791,9288,9289],{},"html pre.shiki code .shcOC, html code.shiki .shcOC{--shiki-light:#6F42C1;--shiki-default:#B392F0;--shiki-dark:#B392F0}html pre.shiki code .sfrk1, html code.shiki .sfrk1{--shiki-light:#032F62;--shiki-default:#9ECBFF;--shiki-dark:#9ECBFF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .so5gQ, html code.shiki .so5gQ{--shiki-light:#D73A49;--shiki-default:#F97583;--shiki-dark:#F97583}html pre.shiki code .suiK_, html code.shiki .suiK_{--shiki-light:#005CC5;--shiki-default:#79B8FF;--shiki-dark:#79B8FF}html pre.shiki code .slsVL, html code.shiki .slsVL{--shiki-light:#24292E;--shiki-default:#E1E4E8;--shiki-dark:#E1E4E8}",{"title":454,"searchDepth":52,"depth":52,"links":9291},[9292,9293,9294,9295,9296,9299,9302,9303,9304,9305],{"id":7947,"depth":52,"text":343},{"id":8092,"depth":52,"text":348},{"id":8112,"depth":52,"text":353},{"id":8645,"depth":52,"text":358},{"id":8789,"depth":52,"text":363,"children":9297},[9298],{"id":8794,"depth":88,"text":368},{"id":8836,"depth":52,"text":373,"children":9300},[9301],{"id":8873,"depth":88,"text":378},{"id":8896,"depth":52,"text":383},{"id":9096,"depth":52,"text":388},{"id":9176,"depth":52,"text":393},{"id":6031,"depth":52,"text":112},"/images/blog/intune-ubuntu-24-04/cover.png","2024-04-22","A complete guide to setting up Ubuntu 24.04 LTS with Intune, including the Intune Portal, Microsoft Edge, development tools, and more.",{},{"title":30,"description":9308},[9312,9313,7961,9314],"Linux","Ubuntu","Intune","YcTIsqhn6O4g67JKsOUgy2lLr_5psF3CfsL6oa-jnQc",1775155471859]