├── .gitignore ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── custom.md │ └── bug-report---feature-request.md ├── workflows │ ├── unit-tests.yml │ ├── add-labels.yml │ ├── InvokeL2RepoDispatchEvent.sh │ ├── defaultLabels.yml │ └── intergation-tests.yml └── config │ └── labels.yml ├── tutorial ├── _imgs │ ├── export.png │ ├── results-log.png │ ├── run-workflow.png │ ├── export-central.png │ ├── github-workflow.png │ ├── github_controls.png │ ├── traceability-data.png │ ├── definition-selection.png │ └── github-exported-folders.png ├── README.md └── azure-policy-as-code.md ├── src ├── utils │ ├── hashUtils.ts │ ├── fileHelper.ts │ ├── utilities.ts │ └── httpClient.ts ├── azure │ ├── azCli.ts │ ├── roleAssignmentHelper.ts │ ├── azHttpClient.ts │ └── policyHelper.ts ├── run.ts ├── report │ └── reportGenerator.ts └── inputProcessing │ ├── inputs.ts │ └── pathHelper.ts ├── lib ├── utils │ ├── hashUtils.js │ ├── fileHelper.js │ ├── utilities.js │ └── httpClient.js ├── report │ └── reportGenerator.js ├── azure │ ├── azCli.js │ ├── roleAssignmentHelper.js │ └── azHttpClient.js ├── run.js └── inputProcessing │ ├── inputs.js │ └── pathHelper.js ├── jest.config.js ├── CODE_OF_CONDUCT.md ├── tsconfig.json ├── package.json ├── LICENSE ├── __tests__ ├── report.test.ts ├── inputs.test.ts ├── azure.azCli.test.ts ├── pathHelper.test.ts ├── azure.roleAssignmentHelper.test.ts ├── azure.forceUpdateHelper.test.ts ├── azure.policyHelper.test.ts ├── utils.test.ts └── azure.azHttpClient.test.ts ├── action.yml ├── SECURITY.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | node_modules/ 3 | coverage -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @tauhid621 @kaverma 2 | -------------------------------------------------------------------------------- /tutorial/_imgs/export.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure/manage-azure-policy/HEAD/tutorial/_imgs/export.png -------------------------------------------------------------------------------- /tutorial/_imgs/results-log.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure/manage-azure-policy/HEAD/tutorial/_imgs/results-log.png -------------------------------------------------------------------------------- /tutorial/_imgs/run-workflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure/manage-azure-policy/HEAD/tutorial/_imgs/run-workflow.png -------------------------------------------------------------------------------- /tutorial/_imgs/export-central.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure/manage-azure-policy/HEAD/tutorial/_imgs/export-central.png -------------------------------------------------------------------------------- /tutorial/_imgs/github-workflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure/manage-azure-policy/HEAD/tutorial/_imgs/github-workflow.png -------------------------------------------------------------------------------- /tutorial/_imgs/github_controls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure/manage-azure-policy/HEAD/tutorial/_imgs/github_controls.png -------------------------------------------------------------------------------- /tutorial/_imgs/traceability-data.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure/manage-azure-policy/HEAD/tutorial/_imgs/traceability-data.png -------------------------------------------------------------------------------- /tutorial/_imgs/definition-selection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure/manage-azure-policy/HEAD/tutorial/_imgs/definition-selection.png -------------------------------------------------------------------------------- /tutorial/_imgs/github-exported-folders.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure/manage-azure-policy/HEAD/tutorial/_imgs/github-exported-folders.png -------------------------------------------------------------------------------- /src/utils/hashUtils.ts: -------------------------------------------------------------------------------- 1 | import * as objectHash from 'object-hash'; 2 | 3 | export function getObjectHash(obj: any): string { 4 | return objectHash.sha1(obj); 5 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report---feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report / Feature Request 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: need-to-triage 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /lib/utils/hashUtils.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.getObjectHash = void 0; 4 | const objectHash = require("object-hash"); 5 | function getObjectHash(obj) { 6 | return objectHash.sha1(obj); 7 | } 8 | exports.getObjectHash = getObjectHash; 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | moduleFileExtensions: ['js', 'ts'], 4 | testEnvironment: 'node', 5 | testMatch: ['**/*.test.ts'], 6 | transform: { 7 | '^.+\\.ts$': 'ts-jest' 8 | }, 9 | verbose: true, 10 | coverageThreshold: { 11 | "global": { 12 | "branches": 0, 13 | "functions": 14, 14 | "lines": 27, 15 | "statements": 27 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /src/utils/fileHelper.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | 3 | export function doesFileExist(path: string): boolean { 4 | return fs.existsSync(path); 5 | } 6 | 7 | export function getFileJson(path: string): any { 8 | try { 9 | const rawContent = fs.readFileSync(path, 'utf-8'); 10 | return JSON.parse(rawContent); 11 | } catch (ex) { 12 | throw new Error(`An error occured while parsing the contents of the file: ${path}. Error: ${ex}`); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns 10 | -------------------------------------------------------------------------------- /.github/workflows/unit-tests.yml: -------------------------------------------------------------------------------- 1 | name: "Run unit tests." 2 | on: # rebuild any PRs and main branch changes 3 | pull_request: 4 | branches: 5 | - main 6 | - 'releases/*' 7 | push: 8 | branches: 9 | - main 10 | - 'releases/*' 11 | 12 | jobs: 13 | build: # make sure build/ci works properly 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v1 17 | - name: Run L0 tests. 18 | run: | 19 | npm install 20 | npm test -------------------------------------------------------------------------------- /.github/workflows/add-labels.yml: -------------------------------------------------------------------------------- 1 | name: add-labels 2 | on: 3 | workflow_dispatch: 4 | 5 | jobs: 6 | test_action_job: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Check out source code 10 | uses: actions/checkout@v1 11 | - name: Synchronize labels 12 | uses: julb/action-manage-label@v1 13 | with: 14 | from: .github/config/labels.yml 15 | skip_delete: true 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "outDir": "./lib", 6 | "rootDir": "./src", 7 | "strict": true, 8 | "noImplicitAny": false, 9 | "strictNullChecks": false, 10 | "strictPropertyInitialization": false, 11 | "esModuleInterop": false, 12 | "allowSyntheticDefaultImports": true, 13 | "skipLibCheck": true, 14 | "forceConsistentCasingInFileNames": true 15 | }, 16 | "exclude": [ 17 | "node_modules", 18 | "__tests__" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /tutorial/README.md: -------------------------------------------------------------------------------- 1 | 2 | ## Documentation 3 | 1. [Export Azure policy resources to GitHub](https://docs.microsoft.com/azure/governance/policy/how-to/export-resources) 4 | 2. [Tutorial: Managing Azure Policy as Code with GitHub](https://github.com/Azure/manage-azure-policy/blob/main/tutorial/azure-policy-as-code.md) 5 | 6 | 7 | 8 | ## Quickstart Video Tutorials: 9 | 1. [Export Azure Policy resources to GitHub Repository](https://aka.ms/pac-yvideo-export) 10 | 2. [Deploy Azure Policies with GitHub workflows](https://aka.ms/pac-yvideo-rollout-policy) 11 | -------------------------------------------------------------------------------- /lib/utils/fileHelper.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.getFileJson = exports.doesFileExist = void 0; 4 | const fs = require("fs"); 5 | function doesFileExist(path) { 6 | return fs.existsSync(path); 7 | } 8 | exports.doesFileExist = doesFileExist; 9 | function getFileJson(path) { 10 | try { 11 | const rawContent = fs.readFileSync(path, 'utf-8'); 12 | return JSON.parse(rawContent); 13 | } 14 | catch (ex) { 15 | throw new Error(`An error occured while parsing the contents of the file: ${path}. Error: ${ex}`); 16 | } 17 | } 18 | exports.getFileJson = getFileJson; 19 | -------------------------------------------------------------------------------- /.github/workflows/InvokeL2RepoDispatchEvent.sh: -------------------------------------------------------------------------------- 1 | token=$1 2 | commit=$2 3 | repository=$3 4 | prNumber=$4 5 | frombranch=$5 6 | tobranch=$6 7 | patUser=$7 8 | getPayLoad() { 9 | cat <=8.0.1", 18 | "ts-jest": "^25.5.1", 19 | "typescript": "^3.9.7" 20 | }, 21 | "dependencies": { 22 | "@actions/core": "^1.2.6", 23 | "@actions/exec": "^1.0.1", 24 | "@actions/io": "^1.0.1", 25 | "@types/glob": "^7.1.3", 26 | "@types/minimatch": "3.0.3", 27 | "glob": "^7.1.3", 28 | "minimatch": "3.0.3", 29 | "object-hash": "^2.0.3", 30 | "table": "^6.8.0", 31 | "typed-rest-client": "^1.8.6", 32 | "uuid": "^8.3.1" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.github/config/labels.yml: -------------------------------------------------------------------------------- 1 | - name: need-to-triage 2 | color: "#fbca04" 3 | description: "Requires investigation" 4 | 5 | - name: idle 6 | color: "#9A777A" 7 | description: "Inactive for 14 days" 8 | 9 | - name: stale 10 | color: "#A9A9A9" 11 | description: "90 days old" 12 | 13 | - name: question 14 | color: "#d876e3" 15 | description: "Requiring some clarification" 16 | 17 | - name: bug 18 | color: "#d73a4a" 19 | description: "Something is not working" 20 | 21 | - name: P0 22 | color: "#B60205" 23 | description: "Action not working" 24 | 25 | - name: P1 26 | color: "#EE3D1D" 27 | description: "Some scenario broken but workaround exists" 28 | 29 | - name: enhancement 30 | color: "#a2eeef" 31 | description: "Feature request/improved experience" 32 | 33 | - name: documentation 34 | color: "#0075ca" 35 | description: "Improvements or additions to documentation" 36 | 37 | - name: backlog 38 | color: "#bd7e4b" 39 | description: "Planned for future" 40 | 41 | - name: performance-issue 42 | color: "#0e8a16" 43 | description: "Performance improvement required" 44 | 45 | - name: waiting-for-customer 46 | color: "#0e8a16" 47 | description: "Waiting for inputs from customer" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE 22 | -------------------------------------------------------------------------------- /.github/workflows/defaultLabels.yml: -------------------------------------------------------------------------------- 1 | name: setting-default-labels 2 | 3 | # Controls when the action will run. 4 | on: 5 | schedule: 6 | - cron: "0 0/3 * * *" 7 | 8 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 9 | jobs: 10 | build: 11 | # The type of runner that the job will run on 12 | runs-on: ubuntu-latest 13 | 14 | # Steps represent a sequence of tasks that will be executed as part of the job 15 | steps: 16 | 17 | - uses: actions/stale@v3 18 | name: Setting issue as idle 19 | with: 20 | repo-token: ${{ secrets.GITHUB_TOKEN }} 21 | stale-issue-message: 'This issue is idle because it has been open for 14 days with no activity.' 22 | stale-issue-label: 'idle' 23 | days-before-stale: 14 24 | days-before-close: -1 25 | operations-per-run: 100 26 | exempt-issue-labels: 'backlog' 27 | 28 | - uses: actions/stale@v3 29 | name: Setting PR as idle 30 | with: 31 | repo-token: ${{ secrets.GITHUB_TOKEN }} 32 | stale-pr-message: 'This PR is idle because it has been open for 14 days with no activity.' 33 | stale-pr-label: 'idle' 34 | days-before-stale: 14 35 | days-before-close: -1 36 | operations-per-run: 100 -------------------------------------------------------------------------------- /.github/workflows/intergation-tests.yml: -------------------------------------------------------------------------------- 1 | name: "Trigger Integration tests" 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - 'releases/*' 7 | pull_request: 8 | branches: 9 | - main 10 | - 'releases/*' 11 | jobs: 12 | trigger-integration-tests: 13 | name: Trigger Integration tests 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Check out repository 17 | uses: actions/checkout@v2 18 | with: 19 | path: IntegrationTests 20 | 21 | - name: Extract branch name 22 | id: extract_branch 23 | run: | 24 | echo "##[set-output name=branchname;]$(echo ${GITHUB_REF##*/})" 25 | 26 | - name: Trigger Test run 27 | if: | 28 | github.event.pull_request.base.ref == 'releases/v0' || steps.extract_branch.outputs.branchname == 'releases/v0' || 29 | github.event.pull_request.base.ref == 'main' || steps.extract_branch.outputs.branchname == 'main' 30 | run: | 31 | bash ./IntegrationTests/.github/workflows/InvokeL2RepoDispatchEvent.sh ${{ secrets.L2_REPO_TOKEN }} ${{ github.event.pull_request.head.sha }} ${{ github.repository }} ${{ github.event.pull_request.number }} ${{ github.event.pull_request.head.ref }} ${{ github.event.pull_request.base.ref }} ${{ secrets.L2_REPO_USER }} -------------------------------------------------------------------------------- /__tests__/report.test.ts: -------------------------------------------------------------------------------- 1 | import * as reportGenerator from '../src/report/reportGenerator'; 2 | 3 | describe('Testing all functions in reportGenerator file', () => { 4 | test('getRowSeparator() - generate and return row seperator', () => { 5 | expect(reportGenerator.getRowSeparator([1, 2, 3, 4])).toMatchObject(['-', '--', '---', '----']); 6 | }); 7 | 8 | test('getRowSeparator() - generate and return tableConfig', () => { 9 | const expected = { 10 | columns: { 11 | 0: { 12 | width: 1, 13 | wrapWord: true 14 | }, 15 | 1: { 16 | width: 2, 17 | wrapWord: true 18 | }, 19 | 2: { 20 | width: 3, 21 | wrapWord: true 22 | }, 23 | 3: { 24 | width: 4, 25 | wrapWord: true 26 | } 27 | } 28 | } 29 | expect(reportGenerator.getTableConfig([1, 2, 3, 4])).toMatchObject(expected); 30 | }); 31 | 32 | test('getTableConfig() - generate table config for table', () => { 33 | expect(reportGenerator.getTableConfig([1, 2, 3, 4])).toMatchObject({ 34 | "columns": { 35 | "0": { 36 | "width": 1, 37 | "wrapWord": true 38 | }, 39 | "1": { 40 | "width": 2, 41 | "wrapWord": true 42 | }, 43 | "2": { 44 | "width": 3, 45 | "wrapWord": true 46 | }, 47 | "3": { 48 | "width": 4, 49 | "wrapWord": true 50 | } 51 | } 52 | }); 53 | }); 54 | }); -------------------------------------------------------------------------------- /__tests__/inputs.test.ts: -------------------------------------------------------------------------------- 1 | import * as inputs from '../src/inputProcessing/inputs'; 2 | import * as core from '@actions/core'; 3 | 4 | describe('Testing all functions in reportGenerator file', () => { 5 | test('getInputArray() - convert multiline input to array', () => { 6 | expect(inputs.getInputArray('a\n b\n c \nd')).toMatchObject(['a', 'b', 'c', 'd']); 7 | }); 8 | 9 | test('validateAssignmentLikePatterns() - validates inputs on some rules and throws error if they fail', () => { 10 | expect(() => inputs.validateAssignmentLikePatterns('inputName', ['/'])).toThrow("Input 'inputName' should not contain directory separator '/' in any pattern."); 11 | expect(() => inputs.validateAssignmentLikePatterns('inputName', ['**'])).toThrow("Input 'inputName' should not contain globstar '**' in any pattern."); 12 | expect(inputs.validateAssignmentLikePatterns('inputName', ['a'])).toBeUndefined(); 13 | }); 14 | 15 | test('readInputs() - validates inputs and sets variables to access them', () => { 16 | jest.spyOn(core, 'getInput').mockImplementation((input) => { 17 | if (input == 'paths') return 'path1\npath2'; 18 | if (input == 'ignore-paths') return 'path2/path21\npath2/path22'; 19 | if (input == 'assignments') return 'assign.something.json'; 20 | if (input == 'enforce') return 'enforce\n~doNotEnforce'; 21 | if (input == 'mode') return 'complete'; 22 | if (input == 'force-update') return 'false'; 23 | }); 24 | expect(inputs.readInputs()).toBeUndefined(); 25 | expect(inputs.includePathPatterns).toMatchObject(['path1', 'path2']); 26 | expect(inputs.excludePathPatterns).toMatchObject(['path2/path21', 'path2/path22']); 27 | expect(inputs.assignmentPatterns).toMatchObject(['assign.something.json']); 28 | expect(inputs.enforcePatterns).toMatchObject(['enforce']); 29 | expect(inputs.doNotEnforcePatterns).toMatchObject(['doNotEnforce']); 30 | expect(inputs.mode).toBe('complete'); 31 | }); 32 | }); -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Manage Azure Policy' 2 | description: 'Create or update Azure policies from your GitHub Workflows using Manage Azure Policy action.' 3 | inputs: 4 | paths: 5 | description: 'mandatory. The path(s) to the directory that contains Azure policy files. The files present only in these directories will be considered by this action for updating policies in Azure. You can use wild card characters as mentioned * or ** for specifying sub folders in a path. For more details on the use of the wild cards check [glob wildcard patterns](https://github.com/isaacs/node-glob#glob-primer). Note that a definition file should be named as policy.json and assignment filenames should start with assign keyword.' 6 | required: true 7 | ignore-paths: 8 | description: 'Optional. These are the directory paths that will be ignored by the action. If you have a specific policy folder that is not ready to be applied yet, specify the path here. Note that ignore-paths has a higher precedence compared to paths parameter.' 9 | required: false 10 | assignments: 11 | description: 'Optional. These are policy assignment files that would be considered by the action. This parameter is especially useful if you want to apply only those assignments that correspond to a specific environment for following a safe deployment practice. E.g. _assign.AllowedVMSKUs-dev-rg.json_. You can use wild card character * to match multiple file names. E.g. _assign.\*dev\*.json_. If this parameter is not specified, the action will consider all assignment files that are present in the directories mentioned in paths parameter.' 12 | required: false 13 | mode: 14 | required: false 15 | description: 'Optional. There are 2 modes for this action - _incremental_ and _complete_. If not specified, the action will use incremental mode by default. In incremental mode, the action will compare already exisiting policy in azure with the contents of policy provided in repository file. It will apply the policy only if there is a mismatch. On the contrary, the complete mode will apply all the files present in the specified paths irrespective of whether or not repository policy file has been updated.' 16 | enforce: 17 | required: false 18 | description: 'Optional. To override the property enforcementMode in assignments. Input is similar to assignments input. Add ~ at the beginning if you do not want to enforce the assignment(s)' 19 | runs: 20 | using: 'node16' 21 | main: 'lib/run.js' 22 | -------------------------------------------------------------------------------- /src/azure/azCli.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core'; 2 | import * as exec from '@actions/exec'; 3 | import * as io from "@actions/io"; 4 | 5 | export class AzCli { 6 | 7 | public static async getManagementUrl(): Promise { 8 | if (!this._baseUrl) { 9 | try { 10 | let azCloudDetails = JSON.parse(await this.executeCommand('cloud show')); 11 | const cloudEndpoints = azCloudDetails['endpoints']; 12 | this._baseUrl = this.getResourceManagerUrl(cloudEndpoints); 13 | } 14 | catch (error) { 15 | console.log('Failed to get management URL from azure. Setting it to default url for public cloud.'); 16 | this._baseUrl = this.defaultManagementUrl; 17 | } 18 | } 19 | 20 | return this._baseUrl; 21 | } 22 | 23 | public static async getAccessToken(): Promise { 24 | const resource = await this.getManagementUrl(); 25 | let accessToken = ""; 26 | 27 | try { 28 | let azAccessToken = JSON.parse(await this.executeCommand("account get-access-token --resource=" + resource)); 29 | core.setSecret(azAccessToken); 30 | accessToken = azAccessToken['accessToken']; 31 | } 32 | catch (error) { 33 | console.log('Failed to fetch Azure access token'); 34 | throw error; 35 | } 36 | 37 | return accessToken; 38 | } 39 | 40 | public static async executeCommand(command: string, args?: string[]): Promise { 41 | let azCliPath = await io.which('az', true); 42 | let stdout = ''; 43 | let stderr = ''; 44 | 45 | try { 46 | core.debug(`"${azCliPath}" ${command}`); 47 | await exec.exec(`"${azCliPath}" ${command}`, args, { 48 | silent: true, // this will prevent priniting access token to console output 49 | listeners: { 50 | stdout: (data: Buffer) => { 51 | stdout += data.toString(); 52 | }, 53 | stderr: (data: Buffer) => { 54 | stderr += data.toString(); 55 | } 56 | } 57 | }); 58 | } 59 | catch (error) { 60 | throw new Error(stderr); 61 | } 62 | 63 | return stdout; 64 | } 65 | 66 | private static getResourceManagerUrl(cloudEndpoints: { [key: string]: string }): string { 67 | if (!cloudEndpoints['resourceManager']) { 68 | return this.defaultManagementUrl; 69 | } 70 | 71 | // Remove trailing slash. 72 | return cloudEndpoints['resourceManager'].replace(/\/$/, ""); 73 | } 74 | 75 | private static _baseUrl: string; 76 | private static defaultManagementUrl: string = "https://management.azure.com"; 77 | } -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://docs.microsoft.com/en-us/previous-versions/tn-archive/cc751383(v=technet.10)), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://msrc.microsoft.com/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://www.microsoft.com/en-us/msrc/pgp-key-msrc). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://microsoft.com/msrc/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://www.microsoft.com/en-us/msrc/cvd). 40 | 41 | -------------------------------------------------------------------------------- /src/run.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core'; 2 | import * as Inputs from './inputProcessing/inputs'; 3 | import { POLICY_FILE_NAME, POLICY_INITIATIVE_FILE_NAME, POLICY_RESULT_FAILED, PolicyRequest, PolicyResult, createUpdatePolicies, getAllPolicyRequests } from './azure/policyHelper' 4 | import { printSummary } from './report/reportGenerator'; 5 | import { prettyLog, setUpUserAgent } from './utils/utilities' 6 | 7 | /** 8 | * Entry point for Action 9 | */ 10 | async function run() { 11 | let policyResults: PolicyResult[] = null; 12 | try { 13 | Inputs.readInputs(); 14 | setUpUserAgent(); 15 | 16 | const policyRequests: PolicyRequest[] = await getAllPolicyRequests(); 17 | 18 | //2. Push above polices to Azure policy service 19 | policyResults = await createUpdatePolicies(policyRequests); 20 | 21 | //3. Print summary result to console 22 | printSummary(policyResults); 23 | 24 | } catch (error) { 25 | core.setFailed(error.message); 26 | prettyLog(`Error : ${error}`); 27 | } finally { 28 | //4. Set action outcome 29 | setResult(policyResults); 30 | } 31 | } 32 | 33 | function setResult(policyResults: PolicyResult[]): void { 34 | if (!policyResults) { 35 | core.setFailed(`Error while deploying policies.`); 36 | } else { 37 | const failedCount: number = policyResults.filter(result => result.status === POLICY_RESULT_FAILED).length; 38 | if (failedCount > 0) { 39 | core.setFailed(`Found '${failedCount}' failure(s) while deploying policies.`); 40 | } else if (policyResults.length > 0) { 41 | core.info(`All policies deployed successfully. Created/updated '${policyResults.length}' definitions/assignments.`); 42 | } else { 43 | let warningMessage: string; 44 | if(Inputs.mode == Inputs.MODE_COMPLETE) { 45 | warningMessage = `Did not find any policies to create/update. No policy files match the given patterns. If you have policy definitions or policy initiatives, please ensure that the files are named '${POLICY_FILE_NAME}' and '${POLICY_INITIATIVE_FILE_NAME}' respectively. For more details, please enable debug logs by adding secret 'ACTIONS_STEP_DEBUG' with value 'true'. (https://docs.github.com/en/actions/managing-workflow-runs/enabling-debug-logging#enabling-step-debug-logging)`; 46 | } 47 | else { 48 | warningMessage = `Did not find any policies to create/update. No policy files match the given patterns or no changes were detected. If you have policy definitions or policy initiatives, please ensure that the files are named '${POLICY_FILE_NAME}' and '${POLICY_INITIATIVE_FILE_NAME}' respectively. For more details, please enable debug logs by adding secret 'ACTIONS_STEP_DEBUG' with value 'true'. (https://docs.github.com/en/actions/managing-workflow-runs/enabling-debug-logging#enabling-step-debug-logging)`; 49 | } 50 | 51 | core.warning(warningMessage); 52 | } 53 | } 54 | } 55 | 56 | run(); -------------------------------------------------------------------------------- /src/utils/utilities.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | import * as crypto from "crypto"; 3 | import { doesFileExist, getFileJson } from './fileHelper'; 4 | 5 | const TEXT_PARTITION: string = "----------------------------------------------------------------------------------------------------"; 6 | 7 | export function prettyLog(text: string) { 8 | console.log(`${TEXT_PARTITION}\n${text}\n${TEXT_PARTITION}`); 9 | } 10 | 11 | export function prettyDebugLog(text: string) { 12 | core.debug(`${TEXT_PARTITION}\n${text}\n${TEXT_PARTITION}`); 13 | } 14 | 15 | export function getWorkflowRunUrl(): string { 16 | return `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`; 17 | } 18 | 19 | export function setUpUserAgent() { 20 | let usrAgentRepo = crypto.createHash('sha256').update(`${process.env.GITHUB_REPOSITORY}`).digest('hex'); 21 | let actionName = 'ManageAzurePolicy'; 22 | let userAgentString = `GITHUBACTIONS_${actionName}_${usrAgentRepo}`; 23 | core.exportVariable('AZURE_HTTP_USER_AGENT', userAgentString); 24 | } 25 | 26 | export function splitArray(array: any[], chunkSize: number): any[] { 27 | let results = []; 28 | 29 | while (array.length) { 30 | results.push(array.splice(0, chunkSize)); 31 | } 32 | 33 | return results; 34 | } 35 | 36 | /** 37 | * Group objects of an array based on a property. 38 | * 39 | * @param array Array of objects 40 | * @param property property based on which objects need to be grouped 41 | */ 42 | export function groupBy(array: any[], property: string): any { 43 | let hash = {}; 44 | for (var i = 0; i < array.length; i++) { 45 | if (!hash[array[i][property]]) { 46 | hash[array[i][property]] = []; 47 | } 48 | hash[array[i][property]].push(array[i]); 49 | } 50 | return hash; 51 | } 52 | 53 | export function repeatString(str: string, repeatCount: number): string { 54 | return str.repeat(repeatCount); 55 | } 56 | 57 | /** 58 | * Populates property to the given object from the provided jsonfile. If jsonfile does not contain the property whole json object is populated. 59 | * 60 | * @param object object to which property needs to be populated 61 | * @param jsonFilePath File from which property is to be read 62 | * @param propertyName Name of property which needs to be populated 63 | */ 64 | export function populatePropertyFromJsonFile(object: any, jsonFilePath: string, propertyName: string) { 65 | if (doesFileExist(jsonFilePath)){ 66 | const jsonObj = getFileJson(jsonFilePath); 67 | if (jsonObj) { 68 | // If same property exists in jsonObj then fetch that else use whole json object 69 | if (jsonObj[propertyName]) { 70 | object[propertyName] = jsonObj[propertyName]; 71 | } 72 | else { 73 | object[propertyName] = jsonObj; 74 | } 75 | } 76 | } 77 | } 78 | 79 | /** 80 | * Returns a short random string of 11 characters 81 | * 82 | * */ 83 | export function getRandomShortString(): string { 84 | return Math.random().toString(36).slice(-11); 85 | } -------------------------------------------------------------------------------- /src/report/reportGenerator.ts: -------------------------------------------------------------------------------- 1 | import * as table from 'table'; 2 | import { groupBy, repeatString } from '../utils/utilities'; 3 | import { PolicyResult, POLICY_RESULT_SUCCEEDED } from '../azure/policyHelper'; 4 | 5 | const TITLE_PATH: string = 'PATH'; 6 | const TITLE_TYPE: string = 'TYPE'; 7 | const TITLE_OPERATION: string = 'OPERATION'; 8 | const TITLE_NAME: string = 'NAME'; 9 | const TITLE_STATUS: string = 'STATUS'; 10 | const TITLE_MESSAGE: string = 'MESSAGE'; 11 | 12 | export function printSummary(policyResults: PolicyResult[]) { 13 | let successRows: any[] = []; 14 | let errorRows: any[] = []; 15 | 16 | let titles = [TITLE_NAME, TITLE_TYPE, TITLE_PATH, TITLE_OPERATION, TITLE_STATUS, TITLE_MESSAGE]; 17 | const widths = [ 30, 10, 25, 10, 10, 50 ]; 18 | successRows.push(titles); 19 | errorRows.push(titles); 20 | const rowSeparator = getRowSeparator(widths); 21 | 22 | // Group result based on policy definition id. 23 | const groupedResult = groupBy(policyResults, 'policyDefinitionId'); 24 | populateRows(groupedResult, successRows, errorRows, rowSeparator); 25 | 26 | if (successRows.length > 1) { 27 | console.log(table.table(successRows, getTableConfig(widths))); 28 | } 29 | if (errorRows.length > 1) { 30 | console.log(table.table(errorRows, getTableConfig(widths))); 31 | } 32 | } 33 | 34 | function populateRows(groupedResult: any, successRows: any[], errorRows: any[], rowSeparator: string[]) { 35 | for (const policyDefinitionId in groupedResult) { 36 | let successRowAdded: boolean = false; 37 | let errorRowAdded: boolean = false; 38 | 39 | const policyDefinitionResults: PolicyResult[] = groupedResult[policyDefinitionId]; 40 | policyDefinitionResults.forEach((policyResult: PolicyResult) => { 41 | let row: string[] = []; 42 | row.push(policyResult.displayName); 43 | row.push(policyResult.type); 44 | row.push(policyResult.path); 45 | row.push(policyResult.operation); 46 | row.push(policyResult.status); 47 | row.push(policyResult.message); 48 | 49 | if (policyResult.status == POLICY_RESULT_SUCCEEDED) { 50 | successRows.push(row); 51 | successRowAdded = true; 52 | } 53 | else { 54 | errorRows.push(row); 55 | errorRowAdded = true; 56 | } 57 | }); 58 | 59 | if (successRowAdded) { 60 | successRows.push(rowSeparator); 61 | } 62 | if (errorRowAdded) { 63 | errorRows.push(rowSeparator); 64 | } 65 | } 66 | } 67 | 68 | export function getTableConfig(widths: number[]): any { 69 | let config: any = { 70 | columns: {} 71 | }; 72 | 73 | let index: number = 0; 74 | for (const width of widths) { 75 | config.columns[index] = { 76 | width: width, 77 | wrapWord: true 78 | } 79 | 80 | index++; 81 | } 82 | 83 | return config; 84 | } 85 | 86 | export function getRowSeparator(widths: number[]): string[] { 87 | let row: string[] = []; 88 | 89 | for (const width of widths) { 90 | row.push(repeatString('-', width)); 91 | } 92 | 93 | return row; 94 | } -------------------------------------------------------------------------------- /__tests__/azure.azCli.test.ts: -------------------------------------------------------------------------------- 1 | import * as azCli from '../src/azure/azCli'; 2 | import * as exec from '@actions/exec'; 3 | import * as io from '@actions/io'; 4 | import * as core from '@actions/core'; 5 | 6 | describe('Testing all functions in azCli file', () => { 7 | 8 | test('executeCommand() - execute a az command', async () => { 9 | jest.spyOn(io, 'which').mockResolvedValue('pathToAz'); 10 | jest.spyOn(core, 'debug').mockImplementation(); 11 | const execSpy = jest.spyOn(exec, 'exec').mockImplementation(async (command, args, options) => { 12 | options.listeners.stdout(Buffer.from('successResponse')); 13 | return 0; 14 | }); 15 | 16 | expect(await azCli.AzCli.executeCommand('cloud show')).toBe('successResponse'); 17 | expect(execSpy.mock.calls[0][0]).toBe('"pathToAz" cloud show'); 18 | }); 19 | 20 | test('executeCommand() - throw error if error occurs during command execution', async () => { 21 | jest.spyOn(io, 'which').mockResolvedValue('pathToAz'); 22 | jest.spyOn(core, 'debug').mockImplementation(); 23 | const execSpy = jest.spyOn(exec, 'exec').mockImplementation(async (command, args, options) => { 24 | options.listeners.stderr(Buffer.from('errorResponse')); 25 | throw ''; 26 | }); 27 | 28 | await expect(azCli.AzCli.executeCommand('cloud show')).rejects.toThrow('errorResponse'); 29 | expect(io.which).toBeCalledWith('az', true); 30 | expect(execSpy.mock.calls[0][0]).toBe('"pathToAz" cloud show'); 31 | }); 32 | 33 | test('getManagementUrl() - get and return management endpoint URL', async () => { 34 | jest.spyOn(io, 'which').mockResolvedValue('pathToAz'); 35 | const execSpy = jest.spyOn(exec, 'exec').mockImplementation(async (command, args, options) => { 36 | options.listeners.stdout(Buffer.from(JSON.stringify({ 37 | "endpoints": { 38 | "resourceManager": "https://management.new.azure.com/", 39 | }, 40 | }))); 41 | return 0; 42 | }); 43 | 44 | expect(await azCli.AzCli.getManagementUrl()).toBe('https://management.new.azure.com'); 45 | expect(await azCli.AzCli.getManagementUrl()).toBe('https://management.new.azure.com'); 46 | expect(execSpy.mock.calls.length).toBe(1); 47 | }); 48 | 49 | test('getAccessToken() - get and return access token using management endpoint URL', async () => { 50 | jest.spyOn(io, 'which').mockResolvedValue('pathToAz'); 51 | jest.spyOn(exec, 'exec').mockImplementation(async (command, args, options) => { 52 | options.listeners.stdout(Buffer.from(JSON.stringify({ 53 | "accessToken": "token" 54 | }))); 55 | return 0; 56 | }); 57 | jest.spyOn(core, 'setSecret').mockImplementation(); 58 | 59 | expect(await azCli.AzCli.getAccessToken()).toBe('token'); 60 | expect(core.setSecret).toBeCalledWith({ 61 | "accessToken": "token" 62 | }); 63 | }); 64 | 65 | test('getAccessToken() - throw error if something fails', async () => { 66 | jest.spyOn(io, 'which').mockResolvedValue('pathToAz'); 67 | jest.spyOn(exec, 'exec').mockImplementation(async (command, args, options) => { 68 | options.listeners.stderr(Buffer.from('error')); 69 | throw ''; 70 | }); 71 | jest.spyOn(console, 'log').mockImplementation(); 72 | 73 | await expect(azCli.AzCli.getAccessToken()).rejects.toThrow('error'); 74 | }); 75 | }); -------------------------------------------------------------------------------- /lib/report/reportGenerator.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.getRowSeparator = exports.getTableConfig = exports.printSummary = void 0; 4 | const table = require("table"); 5 | const utilities_1 = require("../utils/utilities"); 6 | const policyHelper_1 = require("../azure/policyHelper"); 7 | const TITLE_PATH = 'PATH'; 8 | const TITLE_TYPE = 'TYPE'; 9 | const TITLE_OPERATION = 'OPERATION'; 10 | const TITLE_NAME = 'NAME'; 11 | const TITLE_STATUS = 'STATUS'; 12 | const TITLE_MESSAGE = 'MESSAGE'; 13 | function printSummary(policyResults) { 14 | let successRows = []; 15 | let errorRows = []; 16 | let titles = [TITLE_NAME, TITLE_TYPE, TITLE_PATH, TITLE_OPERATION, TITLE_STATUS, TITLE_MESSAGE]; 17 | const widths = [30, 10, 25, 10, 10, 50]; 18 | successRows.push(titles); 19 | errorRows.push(titles); 20 | const rowSeparator = getRowSeparator(widths); 21 | // Group result based on policy definition id. 22 | const groupedResult = utilities_1.groupBy(policyResults, 'policyDefinitionId'); 23 | populateRows(groupedResult, successRows, errorRows, rowSeparator); 24 | if (successRows.length > 1) { 25 | console.log(table.table(successRows, getTableConfig(widths))); 26 | } 27 | if (errorRows.length > 1) { 28 | console.log(table.table(errorRows, getTableConfig(widths))); 29 | } 30 | } 31 | exports.printSummary = printSummary; 32 | function populateRows(groupedResult, successRows, errorRows, rowSeparator) { 33 | for (const policyDefinitionId in groupedResult) { 34 | let successRowAdded = false; 35 | let errorRowAdded = false; 36 | const policyDefinitionResults = groupedResult[policyDefinitionId]; 37 | policyDefinitionResults.forEach((policyResult) => { 38 | let row = []; 39 | row.push(policyResult.displayName); 40 | row.push(policyResult.type); 41 | row.push(policyResult.path); 42 | row.push(policyResult.operation); 43 | row.push(policyResult.status); 44 | row.push(policyResult.message); 45 | if (policyResult.status == policyHelper_1.POLICY_RESULT_SUCCEEDED) { 46 | successRows.push(row); 47 | successRowAdded = true; 48 | } 49 | else { 50 | errorRows.push(row); 51 | errorRowAdded = true; 52 | } 53 | }); 54 | if (successRowAdded) { 55 | successRows.push(rowSeparator); 56 | } 57 | if (errorRowAdded) { 58 | errorRows.push(rowSeparator); 59 | } 60 | } 61 | } 62 | function getTableConfig(widths) { 63 | let config = { 64 | columns: {} 65 | }; 66 | let index = 0; 67 | for (const width of widths) { 68 | config.columns[index] = { 69 | width: width, 70 | wrapWord: true 71 | }; 72 | index++; 73 | } 74 | return config; 75 | } 76 | exports.getTableConfig = getTableConfig; 77 | function getRowSeparator(widths) { 78 | let row = []; 79 | for (const width of widths) { 80 | row.push(utilities_1.repeatString('-', width)); 81 | } 82 | return row; 83 | } 84 | exports.getRowSeparator = getRowSeparator; 85 | -------------------------------------------------------------------------------- /lib/utils/utilities.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.getRandomShortString = exports.populatePropertyFromJsonFile = exports.repeatString = exports.groupBy = exports.splitArray = exports.setUpUserAgent = exports.getWorkflowRunUrl = exports.prettyDebugLog = exports.prettyLog = void 0; 4 | const core = require("@actions/core"); 5 | const crypto = require("crypto"); 6 | const fileHelper_1 = require("./fileHelper"); 7 | const TEXT_PARTITION = "----------------------------------------------------------------------------------------------------"; 8 | function prettyLog(text) { 9 | console.log(`${TEXT_PARTITION}\n${text}\n${TEXT_PARTITION}`); 10 | } 11 | exports.prettyLog = prettyLog; 12 | function prettyDebugLog(text) { 13 | core.debug(`${TEXT_PARTITION}\n${text}\n${TEXT_PARTITION}`); 14 | } 15 | exports.prettyDebugLog = prettyDebugLog; 16 | function getWorkflowRunUrl() { 17 | return `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`; 18 | } 19 | exports.getWorkflowRunUrl = getWorkflowRunUrl; 20 | function setUpUserAgent() { 21 | let usrAgentRepo = crypto.createHash('sha256').update(`${process.env.GITHUB_REPOSITORY}`).digest('hex'); 22 | let actionName = 'ManageAzurePolicy'; 23 | let userAgentString = `GITHUBACTIONS_${actionName}_${usrAgentRepo}`; 24 | core.exportVariable('AZURE_HTTP_USER_AGENT', userAgentString); 25 | } 26 | exports.setUpUserAgent = setUpUserAgent; 27 | function splitArray(array, chunkSize) { 28 | let results = []; 29 | while (array.length) { 30 | results.push(array.splice(0, chunkSize)); 31 | } 32 | return results; 33 | } 34 | exports.splitArray = splitArray; 35 | /** 36 | * Group objects of an array based on a property. 37 | * 38 | * @param array Array of objects 39 | * @param property property based on which objects need to be grouped 40 | */ 41 | function groupBy(array, property) { 42 | let hash = {}; 43 | for (var i = 0; i < array.length; i++) { 44 | if (!hash[array[i][property]]) { 45 | hash[array[i][property]] = []; 46 | } 47 | hash[array[i][property]].push(array[i]); 48 | } 49 | return hash; 50 | } 51 | exports.groupBy = groupBy; 52 | function repeatString(str, repeatCount) { 53 | return str.repeat(repeatCount); 54 | } 55 | exports.repeatString = repeatString; 56 | /** 57 | * Populates property to the given object from the provided jsonfile. If jsonfile does not contain the property whole json object is populated. 58 | * 59 | * @param object object to which property needs to be populated 60 | * @param jsonFilePath File from which property is to be read 61 | * @param propertyName Name of property which needs to be populated 62 | */ 63 | function populatePropertyFromJsonFile(object, jsonFilePath, propertyName) { 64 | if (fileHelper_1.doesFileExist(jsonFilePath)) { 65 | const jsonObj = fileHelper_1.getFileJson(jsonFilePath); 66 | if (jsonObj) { 67 | // If same property exists in jsonObj then fetch that else use whole json object 68 | if (jsonObj[propertyName]) { 69 | object[propertyName] = jsonObj[propertyName]; 70 | } 71 | else { 72 | object[propertyName] = jsonObj; 73 | } 74 | } 75 | } 76 | } 77 | exports.populatePropertyFromJsonFile = populatePropertyFromJsonFile; 78 | /** 79 | * Returns a short random string of 11 characters 80 | * 81 | * */ 82 | function getRandomShortString() { 83 | return Math.random().toString(36).slice(-11); 84 | } 85 | exports.getRandomShortString = getRandomShortString; 86 | -------------------------------------------------------------------------------- /lib/azure/azCli.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { 3 | function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } 4 | return new (P || (P = Promise))(function (resolve, reject) { 5 | function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } 6 | function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } 7 | function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } 8 | step((generator = generator.apply(thisArg, _arguments || [])).next()); 9 | }); 10 | }; 11 | Object.defineProperty(exports, "__esModule", { value: true }); 12 | exports.AzCli = void 0; 13 | const core = require("@actions/core"); 14 | const exec = require("@actions/exec"); 15 | const io = require("@actions/io"); 16 | class AzCli { 17 | static getManagementUrl() { 18 | return __awaiter(this, void 0, void 0, function* () { 19 | if (!this._baseUrl) { 20 | try { 21 | let azCloudDetails = JSON.parse(yield this.executeCommand('cloud show')); 22 | const cloudEndpoints = azCloudDetails['endpoints']; 23 | this._baseUrl = this.getResourceManagerUrl(cloudEndpoints); 24 | } 25 | catch (error) { 26 | console.log('Failed to get management URL from azure. Setting it to default url for public cloud.'); 27 | this._baseUrl = this.defaultManagementUrl; 28 | } 29 | } 30 | return this._baseUrl; 31 | }); 32 | } 33 | static getAccessToken() { 34 | return __awaiter(this, void 0, void 0, function* () { 35 | const resource = yield this.getManagementUrl(); 36 | let accessToken = ""; 37 | try { 38 | let azAccessToken = JSON.parse(yield this.executeCommand("account get-access-token --resource=" + resource)); 39 | core.setSecret(azAccessToken); 40 | accessToken = azAccessToken['accessToken']; 41 | } 42 | catch (error) { 43 | console.log('Failed to fetch Azure access token'); 44 | throw error; 45 | } 46 | return accessToken; 47 | }); 48 | } 49 | static executeCommand(command, args) { 50 | return __awaiter(this, void 0, void 0, function* () { 51 | let azCliPath = yield io.which('az', true); 52 | let stdout = ''; 53 | let stderr = ''; 54 | try { 55 | core.debug(`"${azCliPath}" ${command}`); 56 | yield exec.exec(`"${azCliPath}" ${command}`, args, { 57 | silent: true, 58 | listeners: { 59 | stdout: (data) => { 60 | stdout += data.toString(); 61 | }, 62 | stderr: (data) => { 63 | stderr += data.toString(); 64 | } 65 | } 66 | }); 67 | } 68 | catch (error) { 69 | throw new Error(stderr); 70 | } 71 | return stdout; 72 | }); 73 | } 74 | static getResourceManagerUrl(cloudEndpoints) { 75 | if (!cloudEndpoints['resourceManager']) { 76 | return this.defaultManagementUrl; 77 | } 78 | // Remove trailing slash. 79 | return cloudEndpoints['resourceManager'].replace(/\/$/, ""); 80 | } 81 | } 82 | exports.AzCli = AzCli; 83 | AzCli.defaultManagementUrl = "https://management.azure.com"; 84 | -------------------------------------------------------------------------------- /lib/run.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { 3 | function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } 4 | return new (P || (P = Promise))(function (resolve, reject) { 5 | function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } 6 | function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } 7 | function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } 8 | step((generator = generator.apply(thisArg, _arguments || [])).next()); 9 | }); 10 | }; 11 | Object.defineProperty(exports, "__esModule", { value: true }); 12 | const core = require("@actions/core"); 13 | const Inputs = require("./inputProcessing/inputs"); 14 | const policyHelper_1 = require("./azure/policyHelper"); 15 | const reportGenerator_1 = require("./report/reportGenerator"); 16 | const utilities_1 = require("./utils/utilities"); 17 | /** 18 | * Entry point for Action 19 | */ 20 | function run() { 21 | return __awaiter(this, void 0, void 0, function* () { 22 | let policyResults = null; 23 | try { 24 | Inputs.readInputs(); 25 | utilities_1.setUpUserAgent(); 26 | const policyRequests = yield policyHelper_1.getAllPolicyRequests(); 27 | //2. Push above polices to Azure policy service 28 | policyResults = yield policyHelper_1.createUpdatePolicies(policyRequests); 29 | //3. Print summary result to console 30 | reportGenerator_1.printSummary(policyResults); 31 | } 32 | catch (error) { 33 | core.setFailed(error.message); 34 | utilities_1.prettyLog(`Error : ${error}`); 35 | } 36 | finally { 37 | //4. Set action outcome 38 | setResult(policyResults); 39 | } 40 | }); 41 | } 42 | function setResult(policyResults) { 43 | if (!policyResults) { 44 | core.setFailed(`Error while deploying policies.`); 45 | } 46 | else { 47 | const failedCount = policyResults.filter(result => result.status === policyHelper_1.POLICY_RESULT_FAILED).length; 48 | if (failedCount > 0) { 49 | core.setFailed(`Found '${failedCount}' failure(s) while deploying policies.`); 50 | } 51 | else if (policyResults.length > 0) { 52 | core.info(`All policies deployed successfully. Created/updated '${policyResults.length}' definitions/assignments.`); 53 | } 54 | else { 55 | let warningMessage; 56 | if (Inputs.mode == Inputs.MODE_COMPLETE) { 57 | warningMessage = `Did not find any policies to create/update. No policy files match the given patterns. If you have policy definitions or policy initiatives, please ensure that the files are named '${policyHelper_1.POLICY_FILE_NAME}' and '${policyHelper_1.POLICY_INITIATIVE_FILE_NAME}' respectively. For more details, please enable debug logs by adding secret 'ACTIONS_STEP_DEBUG' with value 'true'. (https://docs.github.com/en/actions/managing-workflow-runs/enabling-debug-logging#enabling-step-debug-logging)`; 58 | } 59 | else { 60 | warningMessage = `Did not find any policies to create/update. No policy files match the given patterns or no changes were detected. If you have policy definitions or policy initiatives, please ensure that the files are named '${policyHelper_1.POLICY_FILE_NAME}' and '${policyHelper_1.POLICY_INITIATIVE_FILE_NAME}' respectively. For more details, please enable debug logs by adding secret 'ACTIONS_STEP_DEBUG' with value 'true'. (https://docs.github.com/en/actions/managing-workflow-runs/enabling-debug-logging#enabling-step-debug-logging)`; 61 | } 62 | core.warning(warningMessage); 63 | } 64 | } 65 | } 66 | run(); 67 | -------------------------------------------------------------------------------- /src/inputProcessing/inputs.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core'; 2 | 3 | const INPUT_PATHS_KEY = 'paths'; 4 | const INPUT_IGNORE_PATHS_KEY = 'ignore-paths'; 5 | const INPUT_ASSIGNMENTS_KEY = 'assignments'; 6 | const INPUT_ENFORCEMENT_MODE_KEY = 'enforce'; 7 | const FORCE_UPDATE_KEY = "force-update"; 8 | export const INPUT_MODE = "mode"; 9 | const DO_NOT_ENFORCE_PREFIX = '~'; 10 | const DEFAULT_ASSIGNMENT_PATTERN = 'assign.*.json'; 11 | export const MODE_INCREMENTAL = "incremental"; 12 | export const MODE_COMPLETE = "complete"; 13 | 14 | export let paths: string[]; 15 | export let ignorePaths: string[] | undefined; 16 | export let assignments: string[] | undefined; 17 | export let enforcementMode: string[] | undefined; 18 | export let mode: string = MODE_INCREMENTAL; 19 | export let forceUpdate: boolean = false; 20 | 21 | export let includePathPatterns: string[] = []; 22 | export let excludePathPatterns: string[] = []; 23 | export let assignmentPatterns: string[] = []; 24 | export let enforcePatterns: string[] = []; 25 | export let doNotEnforcePatterns: string[] = []; 26 | 27 | export function readInputs() { 28 | const pathsInput = core.getInput(INPUT_PATHS_KEY, { required: true }); 29 | const ignorePathsInput = core.getInput(INPUT_IGNORE_PATHS_KEY); 30 | const assignmentsInput = core.getInput(INPUT_ASSIGNMENTS_KEY); 31 | const enforcementModeInput = core.getInput(INPUT_ENFORCEMENT_MODE_KEY); 32 | mode = core.getInput(INPUT_MODE) ? core.getInput(INPUT_MODE).toLowerCase() : MODE_INCREMENTAL; 33 | forceUpdate = core.getInput(FORCE_UPDATE_KEY) ? core.getInput(FORCE_UPDATE_KEY).toLowerCase() == "true" : false; 34 | 35 | paths = getInputArray(pathsInput); 36 | ignorePaths = getInputArray(ignorePathsInput); 37 | assignments = getInputArray(assignmentsInput); 38 | enforcementMode = getInputArray(enforcementModeInput); 39 | 40 | validateAssignments(); 41 | validateEnforcementMode(); 42 | 43 | paths.forEach(path => { 44 | includePathPatterns.push(path); 45 | }); 46 | 47 | if (ignorePaths) { 48 | ignorePaths.forEach(ignorePath => { 49 | excludePathPatterns.push(ignorePath); 50 | }) 51 | } 52 | 53 | if (assignments) { 54 | assignments.forEach(assignment => { 55 | assignmentPatterns.push(assignment); 56 | }); 57 | } 58 | 59 | if (assignmentPatterns.length == 0) { 60 | assignmentPatterns.push(DEFAULT_ASSIGNMENT_PATTERN); 61 | } 62 | 63 | if (enforcementMode) { 64 | enforcementMode.forEach(enforcementMode => { 65 | enforcementMode.startsWith(DO_NOT_ENFORCE_PREFIX) 66 | ? doNotEnforcePatterns.push(enforcementMode.substring(1)) 67 | : enforcePatterns.push(enforcementMode); 68 | }); 69 | } 70 | } 71 | 72 | export function getInputArray(input: string): string[] | undefined { 73 | return input ? input.split('\n').map(item => item.trim()) : undefined; 74 | } 75 | 76 | function validateAssignments(): void { 77 | validateAssignmentLikePatterns(INPUT_ASSIGNMENTS_KEY, assignments); 78 | } 79 | 80 | function validateEnforcementMode(): void { 81 | validateAssignmentLikePatterns(INPUT_ENFORCEMENT_MODE_KEY, enforcementMode); 82 | } 83 | 84 | export function validateAssignmentLikePatterns(inputName: string, patterns?: string[]): void { 85 | if (!patterns) { 86 | return; 87 | } 88 | 89 | if (hasSlashInPattern(patterns)) { 90 | throw Error(`Input '${inputName}' should not contain directory separator '/' in any pattern.`); 91 | } 92 | 93 | if (hasGlobStarPattern(patterns)) { 94 | throw Error(`Input '${inputName}' should not contain globstar '**' in any pattern.`); 95 | } 96 | } 97 | 98 | function hasSlashInPattern(patterns: string[]): boolean { 99 | return patterns.some(pattern => { 100 | return pattern.includes('/'); 101 | }); 102 | } 103 | 104 | function hasGlobStarPattern(patterns: string[]): boolean { 105 | return patterns.some(pattern => { 106 | return pattern.includes('**'); 107 | }); 108 | } -------------------------------------------------------------------------------- /__tests__/pathHelper.test.ts: -------------------------------------------------------------------------------- 1 | import * as pathHelper from '../src/inputProcessing/pathHelper'; 2 | import * as path from 'path'; 3 | import * as glob from 'glob'; 4 | import * as core from '@actions/core'; 5 | 6 | jest.mock('../src/inputProcessing/inputs', () => { 7 | return { 8 | includePathPatterns: ['policies1/**', 'policies2/**'], 9 | excludePathPatterns: ['policies2/ignorePolicies/**'], 10 | assignmentPatterns: ['assign.*.json'] 11 | } 12 | }); 13 | 14 | describe('Testing all functions in pathHelper file', () => { 15 | test('getAllPolicyDefinitionPaths() - get all directories in non excluding paths with policy.json', () => { 16 | jest.spyOn(glob, 'sync').mockImplementation((pattern) => { 17 | if (pattern == path.join('policies1', '**', 'policy.json')) return [ 18 | path.join('policies1', 'somePolicies', 'policy.json'), 19 | path.join('policies1', 'policy.json'), 20 | ]; 21 | if (pattern == path.join('policies2', '**', 'policy.json')) return [ 22 | path.join('policies2', 'ignorePolicies', 'policy.json'), 23 | path.join('policies2', 'somePolicies', 'policy.json') 24 | ]; 25 | if (pattern == path.join('policies2', 'ignorePolicies', '**', 'policy.json')) return [ 26 | path.join('policies2', 'ignorePolicies', 'policy.json') 27 | ]; 28 | }); 29 | jest.spyOn(core, 'debug').mockImplementation(); 30 | 31 | expect(pathHelper.getAllPolicyDefinitionPaths()).toMatchObject([ 32 | path.join('policies1', 'somePolicies'), 33 | path.join('policies1'), 34 | path.join('policies2', 'somePolicies') 35 | ]); 36 | }); 37 | 38 | test('getAllInitiativesPaths() - get all directories in non excluding paths with policyset.json', () => { 39 | jest.spyOn(glob, 'sync').mockImplementation((pattern) => { 40 | if (pattern == path.join('policies1', '**', 'policyset.json')) return [ 41 | path.join('policies1', 'somePolicies', 'policyset.json'), 42 | path.join('policies1', 'policyset.json'), 43 | ]; 44 | if (pattern == path.join('policies2', '**', 'policyset.json')) return [ 45 | path.join('policies2', 'ignorePolicies', 'policyset.json'), 46 | path.join('policies2', 'somePolicies', 'policyset.json') 47 | ]; 48 | if (pattern == path.join('policies2', 'ignorePolicies', '**', 'policyset.json')) return [ 49 | path.join('policies2', 'ignorePolicies', 'policyset.json') 50 | ]; 51 | }); 52 | jest.spyOn(core, 'debug').mockImplementation(); 53 | 54 | expect(pathHelper.getAllInitiativesPaths()).toMatchObject([ 55 | path.join('policies1', 'somePolicies'), 56 | path.join('policies1'), 57 | path.join('policies2', 'somePolicies') 58 | ]); 59 | }); 60 | 61 | test('getAllPolicyAssignmentPaths() - get all assignment files in input paths parameter with input pattern', () => { 62 | jest.spyOn(glob, 'sync').mockImplementation((pattern) => { 63 | if (pattern == path.join('policies1', '**', 'assign.*.json')) return [ 64 | path.join('policies1', 'somePolicies', 'assign.one.json'), 65 | path.join('policies1', 'assign.two.json') 66 | ]; 67 | if (pattern == path.join('policies2', '**', 'assign.*.json')) return [ 68 | path.join('policies2', 'ignorePolicies', 'assign.three.json'), 69 | path.join('policies2', 'somePolicies', 'assign.four.json') 70 | ]; 71 | if (pattern == path.join('policies2', 'ignorePolicies', '**', 'assign.*.json')) return [ 72 | path.join('policies2', 'ignorePolicies', 'assign.three.json') 73 | ]; 74 | }); 75 | jest.spyOn(core, 'debug').mockImplementation(); 76 | 77 | expect(pathHelper.getAllPolicyAssignmentPaths()).toMatchObject([ 78 | path.join('policies1', 'somePolicies', 'assign.one.json'), 79 | path.join('policies1', 'assign.two.json'), 80 | path.join('policies2', 'somePolicies', 'assign.four.json'), 81 | ]); 82 | }); 83 | 84 | test('getAllAssignmentInPaths() - get all assignment files in given paths parameter with input pattern', () => { 85 | jest.spyOn(glob, 'sync').mockImplementation((pattern) => { 86 | if (pattern == path.join('policies2', 'ignorePolicies', '**', 'assign.*.json')) return [ 87 | path.join('policies2', 'ignorePolicies', 'assign.one.json') 88 | ]; 89 | }); 90 | jest.spyOn(core, 'debug').mockImplementation(); 91 | 92 | expect(pathHelper.getAllAssignmentInPaths(['policies2/ignorePolicies/**'])).toMatchObject([ 93 | path.join('policies2', 'ignorePolicies', 'assign.one.json') 94 | ]); 95 | }); 96 | }); -------------------------------------------------------------------------------- /lib/inputProcessing/inputs.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.validateAssignmentLikePatterns = exports.getInputArray = exports.readInputs = exports.doNotEnforcePatterns = exports.enforcePatterns = exports.assignmentPatterns = exports.excludePathPatterns = exports.includePathPatterns = exports.forceUpdate = exports.mode = exports.enforcementMode = exports.assignments = exports.ignorePaths = exports.paths = exports.MODE_COMPLETE = exports.MODE_INCREMENTAL = exports.INPUT_MODE = void 0; 4 | const core = require("@actions/core"); 5 | const INPUT_PATHS_KEY = 'paths'; 6 | const INPUT_IGNORE_PATHS_KEY = 'ignore-paths'; 7 | const INPUT_ASSIGNMENTS_KEY = 'assignments'; 8 | const INPUT_ENFORCEMENT_MODE_KEY = 'enforce'; 9 | const FORCE_UPDATE_KEY = "force-update"; 10 | exports.INPUT_MODE = "mode"; 11 | const DO_NOT_ENFORCE_PREFIX = '~'; 12 | const DEFAULT_ASSIGNMENT_PATTERN = 'assign.*.json'; 13 | exports.MODE_INCREMENTAL = "incremental"; 14 | exports.MODE_COMPLETE = "complete"; 15 | exports.mode = exports.MODE_INCREMENTAL; 16 | exports.forceUpdate = false; 17 | exports.includePathPatterns = []; 18 | exports.excludePathPatterns = []; 19 | exports.assignmentPatterns = []; 20 | exports.enforcePatterns = []; 21 | exports.doNotEnforcePatterns = []; 22 | function readInputs() { 23 | const pathsInput = core.getInput(INPUT_PATHS_KEY, { required: true }); 24 | const ignorePathsInput = core.getInput(INPUT_IGNORE_PATHS_KEY); 25 | const assignmentsInput = core.getInput(INPUT_ASSIGNMENTS_KEY); 26 | const enforcementModeInput = core.getInput(INPUT_ENFORCEMENT_MODE_KEY); 27 | exports.mode = core.getInput(exports.INPUT_MODE) ? core.getInput(exports.INPUT_MODE).toLowerCase() : exports.MODE_INCREMENTAL; 28 | exports.forceUpdate = core.getInput(FORCE_UPDATE_KEY) ? core.getInput(FORCE_UPDATE_KEY).toLowerCase() == "true" : false; 29 | exports.paths = getInputArray(pathsInput); 30 | exports.ignorePaths = getInputArray(ignorePathsInput); 31 | exports.assignments = getInputArray(assignmentsInput); 32 | exports.enforcementMode = getInputArray(enforcementModeInput); 33 | validateAssignments(); 34 | validateEnforcementMode(); 35 | exports.paths.forEach(path => { 36 | exports.includePathPatterns.push(path); 37 | }); 38 | if (exports.ignorePaths) { 39 | exports.ignorePaths.forEach(ignorePath => { 40 | exports.excludePathPatterns.push(ignorePath); 41 | }); 42 | } 43 | if (exports.assignments) { 44 | exports.assignments.forEach(assignment => { 45 | exports.assignmentPatterns.push(assignment); 46 | }); 47 | } 48 | if (exports.assignmentPatterns.length == 0) { 49 | exports.assignmentPatterns.push(DEFAULT_ASSIGNMENT_PATTERN); 50 | } 51 | if (exports.enforcementMode) { 52 | exports.enforcementMode.forEach(enforcementMode => { 53 | enforcementMode.startsWith(DO_NOT_ENFORCE_PREFIX) 54 | ? exports.doNotEnforcePatterns.push(enforcementMode.substring(1)) 55 | : exports.enforcePatterns.push(enforcementMode); 56 | }); 57 | } 58 | } 59 | exports.readInputs = readInputs; 60 | function getInputArray(input) { 61 | return input ? input.split('\n').map(item => item.trim()) : undefined; 62 | } 63 | exports.getInputArray = getInputArray; 64 | function validateAssignments() { 65 | validateAssignmentLikePatterns(INPUT_ASSIGNMENTS_KEY, exports.assignments); 66 | } 67 | function validateEnforcementMode() { 68 | validateAssignmentLikePatterns(INPUT_ENFORCEMENT_MODE_KEY, exports.enforcementMode); 69 | } 70 | function validateAssignmentLikePatterns(inputName, patterns) { 71 | if (!patterns) { 72 | return; 73 | } 74 | if (hasSlashInPattern(patterns)) { 75 | throw Error(`Input '${inputName}' should not contain directory separator '/' in any pattern.`); 76 | } 77 | if (hasGlobStarPattern(patterns)) { 78 | throw Error(`Input '${inputName}' should not contain globstar '**' in any pattern.`); 79 | } 80 | } 81 | exports.validateAssignmentLikePatterns = validateAssignmentLikePatterns; 82 | function hasSlashInPattern(patterns) { 83 | return patterns.some(pattern => { 84 | return pattern.includes('/'); 85 | }); 86 | } 87 | function hasGlobStarPattern(patterns) { 88 | return patterns.some(pattern => { 89 | return pattern.includes('**'); 90 | }); 91 | } 92 | -------------------------------------------------------------------------------- /__tests__/azure.roleAssignmentHelper.test.ts: -------------------------------------------------------------------------------- 1 | import * as roleAssignmentHelper from '../src/azure/roleAssignmentHelper'; 2 | import { 3 | PolicyRequest 4 | } from '../src/azure/policyHelper'; 5 | import * as core from '@actions/core'; 6 | 7 | jest.mock('uuid', () => { 8 | return { 9 | v4: () => 'newUUID' 10 | } 11 | }); 12 | 13 | const mockGetPolicyFn = jest.fn().mockImplementation((policyIds) => [{ 14 | error: { 15 | message: 'Some error message' 16 | } 17 | }, { 18 | id: 'createPolicyId1', 19 | properties: { 20 | policyRule: { 21 | then: { 22 | details: { 23 | roleDefinitionIds: ['unwantedPart1/wantedPart1', 'unwantedPart2/wantedPart2'] 24 | } 25 | } 26 | } 27 | } 28 | }]); 29 | 30 | const mockAddRoleAssignments = jest.fn().mockImplementation((roleRequests) => [{ 31 | httpStatusCode: 201, 32 | content: { 33 | id: 'abc' 34 | } 35 | }, { 36 | httpStatusCode: 404, 37 | content: { 38 | error: { 39 | message: 'Cant reach' 40 | } 41 | } 42 | }]); 43 | 44 | jest.mock('../src/azure/azHttpClient', () => { 45 | return { 46 | AzHttpClient: jest.fn().mockImplementation(() => { 47 | return { 48 | initialize: () => {}, 49 | getPolicyDefintions: (policyIds) => mockGetPolicyFn(policyIds), 50 | addRoleAssinments: (roleRequests) => mockAddRoleAssignments(roleRequests) 51 | } 52 | }) 53 | }; 54 | }); 55 | 56 | describe('Testing all function in roleAssignmentHelper file', () => { 57 | test('assignRoles() - assign roles using assignment requests, assignment responses and role assignment results', async () => { 58 | jest.spyOn(console, 'log').mockImplementation(); 59 | jest.spyOn(core, 'debug').mockImplementation(); 60 | const assignmentRequests = [{ 61 | operation: 'CREATE', 62 | path: 'pathToCreateRequest1' 63 | }, { 64 | operation: 'CREATE', 65 | path: 'pathToCreateRequest2' 66 | }, { 67 | operation: 'UPDATE', 68 | path: 'pathToUpdateRequest' 69 | }] as PolicyRequest[]; 70 | const assignmentResponses = [{ 71 | content: { 72 | id: 'policyAssigmentId1', 73 | path: 'pathToPolicyAssignment1', 74 | identity: { 75 | principalId: 'principalId' 76 | }, 77 | properties: { 78 | policyDefinitionId: 'createPolicyId1', 79 | scope: 'subscription' 80 | } 81 | } 82 | }, { 83 | content: { 84 | id: 'policyAssigmentId2', 85 | path: 'pathToPolicyAssignment2', 86 | identity: { 87 | principalId: 'principalId' 88 | }, 89 | properties: { 90 | policyDefinitionId: 'createPolicyId2', 91 | scope: 'subscription' 92 | } 93 | } 94 | }, { 95 | content: { 96 | identity: { 97 | principalId: 'principalId' 98 | }, 99 | properties: { 100 | policyDefinitionId: 'updatePolicyId' 101 | } 102 | } 103 | }]; 104 | const roleAssignmentResults = []; 105 | expect(await roleAssignmentHelper.assignRoles(assignmentRequests, assignmentResponses, roleAssignmentResults)); 106 | expect(mockGetPolicyFn).toBeCalledWith(['createPolicyId1', 'createPolicyId2']); 107 | expect(roleAssignmentResults).toMatchObject([{ 108 | path: 'pathToCreateRequest1', 109 | type: 'Microsoft.Authorization/roleAssignments', 110 | operation: 'CREATE', 111 | displayName: 'Role Assignment for policy policy assignment id : policyAssigmentId1', 112 | status: 'FAILED', 113 | message: 'Some error message', 114 | policyDefinitionId: 'createPolicyId1' 115 | }, 116 | { 117 | path: 'pathToCreateRequest1', 118 | type: 'Microsoft.Authorization/roleAssignments', 119 | operation: 'CREATE', 120 | displayName: 'Role Assignment for policy policy assignment id : policyAssigmentId1', 121 | status: 'SUCCEEDED', 122 | message: 'Role Assignment created with id : abc', 123 | policyDefinitionId: 'createPolicyId1' 124 | }, 125 | { 126 | path: 'pathToCreateRequest1', 127 | type: 'Microsoft.Authorization/roleAssignments', 128 | operation: 'CREATE', 129 | displayName: 'Role Assignment for policy policy assignment id : policyAssigmentId1', 130 | status: 'FAILED', 131 | message: 'Cant reach', 132 | policyDefinitionId: 'createPolicyId1' 133 | } 134 | ]); 135 | expect(mockAddRoleAssignments).toBeCalledWith([{ 136 | scope: 'subscription', 137 | roleAssignmentId: 'newUUID', 138 | roleDefinitionId: 'wantedPart1', 139 | principalId: 'principalId', 140 | policyAssignmentId: 'policyAssigmentId1', 141 | policyDefinitionId: 'createPolicyId1', 142 | path: 'pathToCreateRequest1' 143 | }, 144 | { 145 | scope: 'subscription', 146 | roleAssignmentId: 'newUUID', 147 | roleDefinitionId: 'wantedPart2', 148 | principalId: 'principalId', 149 | policyAssignmentId: 'policyAssigmentId1', 150 | policyDefinitionId: 'createPolicyId1', 151 | path: 'pathToCreateRequest1' 152 | } 153 | ]); 154 | }); 155 | }); -------------------------------------------------------------------------------- /src/utils/httpClient.ts: -------------------------------------------------------------------------------- 1 | import util = require("util"); 2 | import fs = require("fs"); 3 | import httpClient = require("typed-rest-client/HttpClient"); 4 | import * as core from "@actions/core"; 5 | 6 | var httpCallbackClient = new httpClient.HttpClient( 7 | "GITHUB_RUNNER", 8 | undefined, 9 | {} 10 | ); 11 | 12 | export enum StatusCodes { 13 | OK = 200, 14 | CREATED = 201, 15 | ACCEPTED = 202, 16 | BAD_REQUEST = 400, 17 | UNAUTHORIZED = 401, 18 | NOT_FOUND = 404, 19 | UNPROCESSABLE_ENTITY = 422, 20 | INTERNAL_SERVER_ERROR = 500, 21 | SERVICE_UNAVAILABLE = 503, 22 | } 23 | 24 | export class WebRequest { 25 | public method: string; 26 | public uri: string; 27 | // body can be string or ReadableStream 28 | public body: string | NodeJS.ReadableStream; 29 | public headers: any; 30 | } 31 | 32 | export class WebResponse { 33 | public statusCode: number | undefined; 34 | public statusMessage: string | undefined; 35 | public headers: any; 36 | public body: any; 37 | } 38 | 39 | export class WebRequestOptions { 40 | public retriableErrorCodes?: string[]; 41 | public retryCount?: number; 42 | public retryIntervalInSeconds?: number; 43 | public retriableStatusCodes?: number[]; 44 | public retryRequestTimedout?: boolean; 45 | } 46 | 47 | export async function sendRequest( 48 | request: WebRequest, 49 | options?: WebRequestOptions 50 | ): Promise { 51 | let i = 0; 52 | let retryCount = options && options.retryCount ? options.retryCount : 5; 53 | let retryIntervalInSeconds = 54 | options && options.retryIntervalInSeconds 55 | ? options.retryIntervalInSeconds 56 | : 2; 57 | let retriableErrorCodes = 58 | options && options.retriableErrorCodes 59 | ? options.retriableErrorCodes 60 | : [ 61 | "ETIMEDOUT", 62 | "ECONNRESET", 63 | "ENOTFOUND", 64 | "ESOCKETTIMEDOUT", 65 | "ECONNREFUSED", 66 | "EHOSTUNREACH", 67 | "EPIPE", 68 | "EA_AGAIN", 69 | ]; 70 | let retriableStatusCodes = 71 | options && options.retriableStatusCodes 72 | ? options.retriableStatusCodes 73 | : [408, 409, 500, 502, 503, 504]; 74 | let timeToWait: number = retryIntervalInSeconds; 75 | while (true) { 76 | try { 77 | if ( 78 | request.body && 79 | typeof request.body !== "string" && 80 | !request.body["readable"] 81 | ) { 82 | request.body = fs.createReadStream(request.body["path"]); 83 | } 84 | 85 | let response: WebResponse = await sendRequestInternal(request); 86 | if ( 87 | response.statusCode && 88 | retriableStatusCodes.indexOf(response.statusCode) != -1 && 89 | ++i < retryCount 90 | ) { 91 | core.debug( 92 | util.format( 93 | "Encountered a retriable status code: %s. Message: '%s'.", 94 | response.statusCode, 95 | response.statusMessage 96 | ) 97 | ); 98 | await sleepFor(timeToWait); 99 | timeToWait = 100 | timeToWait * retryIntervalInSeconds + retryIntervalInSeconds; 101 | continue; 102 | } 103 | 104 | return response; 105 | } catch (error) { 106 | if (retriableErrorCodes.indexOf(error.code) != -1 && ++i < retryCount) { 107 | core.debug( 108 | util.format( 109 | "Encountered a retriable error:%s. Message: %s.", 110 | error.code, 111 | error.message 112 | ) 113 | ); 114 | await sleepFor(timeToWait); 115 | timeToWait = 116 | timeToWait * retryIntervalInSeconds + retryIntervalInSeconds; 117 | } else { 118 | if (error.code) { 119 | core.debug("error code =" + error.code); 120 | } 121 | 122 | throw error; 123 | } 124 | } 125 | } 126 | } 127 | 128 | export function sleepFor(sleepDurationInSeconds: number): Promise { 129 | return new Promise((resolve, reject) => { 130 | setTimeout(resolve, sleepDurationInSeconds * 1000); 131 | }); 132 | } 133 | 134 | export async function sendRequestInternal(request: WebRequest): Promise { 135 | core.debug(util.format("[%s]%s", request.method, request.uri)); 136 | var response: httpClient.HttpClientResponse = await httpCallbackClient.request( 137 | request.method, 138 | request.uri, 139 | request.body, 140 | request.headers 141 | ); 142 | return await toWebResponse(response); 143 | } 144 | 145 | export async function toWebResponse( 146 | response: httpClient.HttpClientResponse 147 | ): Promise { 148 | var res = new WebResponse(); 149 | if (response) { 150 | res.statusCode = response.message.statusCode; 151 | res.statusMessage = response.message.statusMessage; 152 | res.headers = response.message.headers; 153 | var body = await response.readBody(); 154 | if (body) { 155 | try { 156 | res.body = JSON.parse(body); 157 | } catch (error) { 158 | core.debug("Could not parse response: " + JSON.stringify(error)); 159 | core.debug("Response: " + JSON.stringify(res.body)); 160 | res.body = body; 161 | } 162 | } 163 | } 164 | 165 | return res; 166 | } -------------------------------------------------------------------------------- /src/inputProcessing/pathHelper.ts: -------------------------------------------------------------------------------- 1 | import * as glob from 'glob'; 2 | import minimatch from 'minimatch'; 3 | import * as path from 'path'; 4 | import * as core from '@actions/core'; 5 | import * as Inputs from './inputs'; 6 | import { POLICY_FILE_NAME, POLICY_INITIATIVE_FILE_NAME } from '../azure/policyHelper'; 7 | import { prettyDebugLog } from '../utils/utilities'; 8 | 9 | /** 10 | * @returns All the directories that: 11 | * 1) Match any pattern given in paths input. 12 | * 2) Do not match any pattern given in ignore-paths input. 13 | * 3) Contain policy.json files. 14 | */ 15 | export function getAllPolicyDefinitionPaths(): string[] { 16 | core.debug('Looking for policy definition paths to include...'); 17 | const policyPathsToInclude = getPolicyPathsMatchingPatterns(Inputs.includePathPatterns, POLICY_FILE_NAME); 18 | core.debug('Looking for policy definition paths to ignore...'); 19 | const policyPathsToExclude = getPolicyPathsMatchingPatterns(Inputs.excludePathPatterns, POLICY_FILE_NAME); 20 | const policyPaths = policyPathsToInclude.filter(p => !policyPathsToExclude.includes(p)); 21 | const debugMessage = policyPaths.length > 0 22 | ? `Found the following policy paths that match the given path filters:\n\n${policyPaths.join('\n')}` 23 | : `Found no policies that match the given path filters.`; 24 | prettyDebugLog(debugMessage); 25 | return policyPaths; 26 | } 27 | 28 | /** 29 | * @returns All the directories that: 30 | * 1) Match any pattern given in paths input. 31 | * 2) Do not match any pattern given in ignore-paths input. 32 | * 3) Contain policyset.json files. 33 | */ 34 | export function getAllInitiativesPaths(): string[] { 35 | core.debug('Looking for policy initiative paths to include...'); 36 | const policyInitiativePathsToInclude = getPolicyPathsMatchingPatterns(Inputs.includePathPatterns, POLICY_INITIATIVE_FILE_NAME); 37 | core.debug('Looking for policy initiative paths to ignore...'); 38 | const policyInitiativePathsToExclude = getPolicyPathsMatchingPatterns(Inputs.excludePathPatterns, POLICY_INITIATIVE_FILE_NAME); 39 | const policyInitiativePaths = policyInitiativePathsToInclude.filter(p => !policyInitiativePathsToExclude.includes(p)); 40 | const debugMessage = policyInitiativePaths.length > 0 41 | ? `Found the following policy initiative paths that match the given path filters:\n\n${policyInitiativePaths.join('\n')}` 42 | : `Found no policy initiatives that match the given path filters.`; 43 | prettyDebugLog(debugMessage); 44 | return policyInitiativePaths; 45 | } 46 | 47 | /** 48 | * @returns All the files that: 49 | * 1) Match any pattern given in paths input. 50 | * 2) Do not match pattern given in ignore-paths input. 51 | * 3) Contain policy.json as a sibling. 52 | * 4) File name matches any pattern given in assignments input. 53 | */ 54 | 55 | export function getAllPolicyAssignmentPaths(): string[] { 56 | core.debug('Looking for policy assignment paths to include...'); 57 | const assignmentPathsToInclude = getAssignmentPathsMatchingPatterns(Inputs.includePathPatterns, Inputs.assignmentPatterns); 58 | core.debug('Looking for policy assignment paths to ignore...'); 59 | const assignmentPathsToExclude = getAssignmentPathsMatchingPatterns(Inputs.excludePathPatterns, Inputs.assignmentPatterns); 60 | const assignmentPaths = assignmentPathsToInclude.filter(p => !assignmentPathsToExclude.includes(p)); 61 | const debugMessage = assignmentPaths.length > 0 62 | ? `Found the following policy assignment paths that match the given path filters:\n\n${assignmentPaths.join('\n')}` 63 | : `Found no policy assignments that match the given path filters.`; 64 | prettyDebugLog(debugMessage); 65 | return assignmentPaths; 66 | } 67 | 68 | export function getAllAssignmentInPaths(definitionFolderPaths: string[]): string[] { 69 | return getAssignmentPathsMatchingPatterns(definitionFolderPaths, Inputs.assignmentPatterns); 70 | } 71 | 72 | export function isEnforced(assignmentPath: string): boolean { 73 | core.debug(`Checking if assignment path '${assignmentPath}' is set to enforce`); 74 | return Inputs.enforcePatterns.some(pattern => { 75 | const isMatch = minimatch(assignmentPath, pattern, { dot: true, matchBase: true }); 76 | if (isMatch) { 77 | core.debug(`Assignment path '${assignmentPath}' matches pattern '${pattern}' for enforce`); 78 | } 79 | return isMatch; 80 | }); 81 | } 82 | 83 | export function isNonEnforced(assignmentPath: string): boolean { 84 | core.debug(`Checking if assignment path '${assignmentPath}' is set to do not enforce`); 85 | return Inputs.doNotEnforcePatterns.some(pattern => { 86 | const isMatch = minimatch(assignmentPath, pattern, { dot: true, matchBase: true }); 87 | if (isMatch) { 88 | core.debug(`Assignment path '${assignmentPath}' matches pattern '~${pattern}' for do not enforce`); 89 | } 90 | return isMatch; 91 | }); 92 | } 93 | 94 | function getPolicyPathsMatchingPatterns(patterns: string[], policyFileName: string): string[] { 95 | let matchingPolicyPaths: string[] = []; 96 | patterns.forEach(pattern => { 97 | const policyFilePattern = path.join(pattern, policyFileName); 98 | const policyFiles: string[] = getFilesMatchingPattern(policyFilePattern); 99 | core.debug(`Policy file pattern: ${policyFilePattern}\n Matching policy paths: ${policyFiles}`); 100 | matchingPolicyPaths.push(...policyFiles.map(policyFile => path.dirname(policyFile))); 101 | }); 102 | 103 | return getUniquePaths(matchingPolicyPaths); 104 | } 105 | 106 | function getAssignmentPathsMatchingPatterns(patterns: string[], assignmentPatterns: string[]): string[] { 107 | let matchingAssignmentPaths: string[] = []; 108 | patterns.forEach(policyPath => { 109 | assignmentPatterns.forEach(assignmentPattern => { 110 | const pattern = path.join(policyPath, assignmentPattern); 111 | const assignmentPaths = getFilesMatchingPattern(pattern); 112 | core.debug(`Assignment pattern: ${pattern}\n Matching assignment paths: ${assignmentPaths}`); 113 | matchingAssignmentPaths.push(...assignmentPaths); 114 | }); 115 | }); 116 | 117 | return getUniquePaths(matchingAssignmentPaths); 118 | } 119 | 120 | function getFilesMatchingPattern(pattern: string): string[] { 121 | return glob.sync(pattern, { dot: true }); 122 | } 123 | 124 | function getUniquePaths(paths: string[]): string[] { 125 | return [...new Set(paths)]; 126 | } -------------------------------------------------------------------------------- /lib/utils/httpClient.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { 3 | function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } 4 | return new (P || (P = Promise))(function (resolve, reject) { 5 | function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } 6 | function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } 7 | function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } 8 | step((generator = generator.apply(thisArg, _arguments || [])).next()); 9 | }); 10 | }; 11 | Object.defineProperty(exports, "__esModule", { value: true }); 12 | exports.toWebResponse = exports.sendRequestInternal = exports.sleepFor = exports.sendRequest = exports.WebRequestOptions = exports.WebResponse = exports.WebRequest = exports.StatusCodes = void 0; 13 | const util = require("util"); 14 | const fs = require("fs"); 15 | const httpClient = require("typed-rest-client/HttpClient"); 16 | const core = require("@actions/core"); 17 | var httpCallbackClient = new httpClient.HttpClient("GITHUB_RUNNER", undefined, {}); 18 | var StatusCodes; 19 | (function (StatusCodes) { 20 | StatusCodes[StatusCodes["OK"] = 200] = "OK"; 21 | StatusCodes[StatusCodes["CREATED"] = 201] = "CREATED"; 22 | StatusCodes[StatusCodes["ACCEPTED"] = 202] = "ACCEPTED"; 23 | StatusCodes[StatusCodes["BAD_REQUEST"] = 400] = "BAD_REQUEST"; 24 | StatusCodes[StatusCodes["UNAUTHORIZED"] = 401] = "UNAUTHORIZED"; 25 | StatusCodes[StatusCodes["NOT_FOUND"] = 404] = "NOT_FOUND"; 26 | StatusCodes[StatusCodes["UNPROCESSABLE_ENTITY"] = 422] = "UNPROCESSABLE_ENTITY"; 27 | StatusCodes[StatusCodes["INTERNAL_SERVER_ERROR"] = 500] = "INTERNAL_SERVER_ERROR"; 28 | StatusCodes[StatusCodes["SERVICE_UNAVAILABLE"] = 503] = "SERVICE_UNAVAILABLE"; 29 | })(StatusCodes = exports.StatusCodes || (exports.StatusCodes = {})); 30 | class WebRequest { 31 | } 32 | exports.WebRequest = WebRequest; 33 | class WebResponse { 34 | } 35 | exports.WebResponse = WebResponse; 36 | class WebRequestOptions { 37 | } 38 | exports.WebRequestOptions = WebRequestOptions; 39 | function sendRequest(request, options) { 40 | return __awaiter(this, void 0, void 0, function* () { 41 | let i = 0; 42 | let retryCount = options && options.retryCount ? options.retryCount : 5; 43 | let retryIntervalInSeconds = options && options.retryIntervalInSeconds 44 | ? options.retryIntervalInSeconds 45 | : 2; 46 | let retriableErrorCodes = options && options.retriableErrorCodes 47 | ? options.retriableErrorCodes 48 | : [ 49 | "ETIMEDOUT", 50 | "ECONNRESET", 51 | "ENOTFOUND", 52 | "ESOCKETTIMEDOUT", 53 | "ECONNREFUSED", 54 | "EHOSTUNREACH", 55 | "EPIPE", 56 | "EA_AGAIN", 57 | ]; 58 | let retriableStatusCodes = options && options.retriableStatusCodes 59 | ? options.retriableStatusCodes 60 | : [408, 409, 500, 502, 503, 504]; 61 | let timeToWait = retryIntervalInSeconds; 62 | while (true) { 63 | try { 64 | if (request.body && 65 | typeof request.body !== "string" && 66 | !request.body["readable"]) { 67 | request.body = fs.createReadStream(request.body["path"]); 68 | } 69 | let response = yield sendRequestInternal(request); 70 | if (response.statusCode && 71 | retriableStatusCodes.indexOf(response.statusCode) != -1 && 72 | ++i < retryCount) { 73 | core.debug(util.format("Encountered a retriable status code: %s. Message: '%s'.", response.statusCode, response.statusMessage)); 74 | yield sleepFor(timeToWait); 75 | timeToWait = 76 | timeToWait * retryIntervalInSeconds + retryIntervalInSeconds; 77 | continue; 78 | } 79 | return response; 80 | } 81 | catch (error) { 82 | if (retriableErrorCodes.indexOf(error.code) != -1 && ++i < retryCount) { 83 | core.debug(util.format("Encountered a retriable error:%s. Message: %s.", error.code, error.message)); 84 | yield sleepFor(timeToWait); 85 | timeToWait = 86 | timeToWait * retryIntervalInSeconds + retryIntervalInSeconds; 87 | } 88 | else { 89 | if (error.code) { 90 | core.debug("error code =" + error.code); 91 | } 92 | throw error; 93 | } 94 | } 95 | } 96 | }); 97 | } 98 | exports.sendRequest = sendRequest; 99 | function sleepFor(sleepDurationInSeconds) { 100 | return new Promise((resolve, reject) => { 101 | setTimeout(resolve, sleepDurationInSeconds * 1000); 102 | }); 103 | } 104 | exports.sleepFor = sleepFor; 105 | function sendRequestInternal(request) { 106 | return __awaiter(this, void 0, void 0, function* () { 107 | core.debug(util.format("[%s]%s", request.method, request.uri)); 108 | var response = yield httpCallbackClient.request(request.method, request.uri, request.body, request.headers); 109 | return yield toWebResponse(response); 110 | }); 111 | } 112 | exports.sendRequestInternal = sendRequestInternal; 113 | function toWebResponse(response) { 114 | return __awaiter(this, void 0, void 0, function* () { 115 | var res = new WebResponse(); 116 | if (response) { 117 | res.statusCode = response.message.statusCode; 118 | res.statusMessage = response.message.statusMessage; 119 | res.headers = response.message.headers; 120 | var body = yield response.readBody(); 121 | if (body) { 122 | try { 123 | res.body = JSON.parse(body); 124 | } 125 | catch (error) { 126 | core.debug("Could not parse response: " + JSON.stringify(error)); 127 | core.debug("Response: " + JSON.stringify(res.body)); 128 | res.body = body; 129 | } 130 | } 131 | } 132 | return res; 133 | }); 134 | } 135 | exports.toWebResponse = toWebResponse; 136 | -------------------------------------------------------------------------------- /__tests__/azure.forceUpdateHelper.test.ts: -------------------------------------------------------------------------------- 1 | import * as forceUpdateHelper from '../src/azure/forceUpdateHelper'; 2 | import * as policyHelper from '../src/azure/policyHelper'; 3 | import { 4 | PolicyRequest 5 | } from '../src/azure/policyHelper'; 6 | import * as pathHelper from '../src/inputProcessing/pathHelper'; 7 | import * as utilities from '../src/utils/utilities'; 8 | import * as path from 'path'; 9 | import * as core from '@actions/core'; 10 | 11 | const mockGetAllAssignments = jest.fn().mockImplementation((policyIds) => [{ 12 | httpStatusCode: 200, 13 | content: { 14 | value: [{ 15 | id: 'policyAssignment1', 16 | name: 'policyName1', 17 | properties: { 18 | displayName: 'test policy 1', 19 | } 20 | }] 21 | } 22 | }, { 23 | httpStatusCode: 200, 24 | content: { 25 | value: [{ 26 | id: 'policyAssignment4', 27 | name: 'policyName4', 28 | properties: { 29 | displayName: 'test policy 4', 30 | } 31 | }] 32 | } 33 | }]); 34 | 35 | const mockGetPolicyDefinitions = jest.fn().mockImplementation((policyIds) => [{ 36 | id: 'policyDefinitionId1', 37 | name: 'pol def 1', 38 | type: 'CREATE', 39 | properties: { 40 | displayName: 'test policy definition 1', 41 | policyDefinitionId: 'policyDefinitionId1' 42 | } 43 | }, { 44 | id: 'policyDefinitionId4', 45 | name: 'pol def 2', 46 | type: 'CREATE', 47 | properties: { 48 | displayName: 'test policy definition 4', 49 | policyDefinitionId: 'policyDefinitionId4' 50 | } 51 | }]); 52 | 53 | const mockUpsetPolicyDefinitions = jest.fn().mockImplementation((definitionRequests) => [{ 54 | content: { 55 | id: 'fromUpsert', 56 | name: 'fromUpsert', 57 | type: 'UPDATE' 58 | } 59 | }, { 60 | content: { 61 | id: 'fromUpsert', 62 | name: 'fromUpsert', 63 | type: 'UPDATE' 64 | } 65 | }]); 66 | 67 | const mockUpsertPolicyAssignments = jest.fn().mockImplementation((definitionRequests) => [{ 68 | content: { 69 | id: 'fromUpsert Assignmet', 70 | name: 'fromUpsert Assignmet', 71 | type: 'UPDATE' 72 | } 73 | }, { 74 | content: { 75 | id: 'fromUpsert Assignmet', 76 | name: 'fromUpsert Assignmet', 77 | type: 'UPDATE' 78 | } 79 | }]); 80 | 81 | const mockDeletePolicies = jest.fn().mockImplementation((policyIds) => [{ 82 | httpStatusCode: 200 83 | }, { 84 | httpStatusCode: 200 85 | }]); 86 | 87 | jest.mock('../src/azure/azHttpClient', () => { 88 | return { 89 | AzHttpClient: jest.fn().mockImplementation(() => { 90 | return { 91 | initialize: () => {}, 92 | getAllAssignments: (policyDefinitionIds) => mockGetAllAssignments(policyDefinitionIds), 93 | getPolicyDefintions: (policyDefinitionIds) => mockGetPolicyDefinitions(policyDefinitionIds), 94 | upsertPolicyDefinitions: (definitionRequests) => mockUpsetPolicyDefinitions(definitionRequests), 95 | upsertPolicyAssignments: (assignmentRequests) => mockUpsertPolicyAssignments(assignmentRequests), 96 | deletePolicies: (policyIds) => mockDeletePolicies(policyIds) 97 | } 98 | }) 99 | }; 100 | }); 101 | 102 | describe('Testing all functions in forceUpdateHelper file', () => { 103 | test('handleForceUpdate() - force update the policies', async () => { 104 | const definitionRequests = [{ 105 | path: 'path1', 106 | operation: 'UPDATE', 107 | policy: { 108 | id: 'policyDefinition1', 109 | type: 'Microsoft.Authorization/policyDefinition', 110 | name: 'displayName1', 111 | properties: { 112 | policyDefinitionId: 'policyDefinitionId1' 113 | } 114 | } 115 | }, { 116 | path: 'path2', 117 | operation: 'UPDATE', 118 | policy: { 119 | id: 'policyDefinition2' 120 | } 121 | }, { 122 | path: 'path3', 123 | operation: 'CREATE', 124 | policy: { 125 | id: 'policyDefinition3' 126 | } 127 | }, { 128 | path: 'path4', 129 | operation: 'UPDATE', 130 | policy: { 131 | id: 'policyDefinition4', 132 | type: 'Microsoft.Authorization/policyDefinition', 133 | name: 'displayName1', 134 | properties: { 135 | policyDefinitionId: 'policyDefinitionId4' 136 | } 137 | } 138 | }] as PolicyRequest[]; 139 | const policyResponses = [{ 140 | httpStatusCode: 400, 141 | }, { 142 | httpStatusCode: 200, 143 | }, { 144 | httpStatusCode: 400, 145 | }, { 146 | httpStatusCode: 400, 147 | }]; 148 | jest.spyOn(console, 'log').mockImplementation(); 149 | jest.spyOn(core, 'debug').mockImplementation(); 150 | jest.spyOn(utilities, 'getRandomShortString').mockReturnValueOnce('randomShortString'); 151 | jest.spyOn(pathHelper, 'getAllAssignmentInPaths').mockReturnValue([path.join('path1', 'policyDefinition1'), path.join('path2', 'policyDefinition2')]); 152 | jest.spyOn(policyHelper, 'getPolicyAssignments').mockReturnValueOnce([{ 153 | id: 'policyAssignment1', 154 | }]).mockReturnValueOnce([{ 155 | id: 'policyAssignment4' 156 | }]); 157 | jest.spyOn(policyHelper, 'getPolicyAssignment').mockReturnValueOnce({ 158 | id: 'policyAssignment1', 159 | type: 'Microsoft.Authorization/policyAssignments', 160 | name: 'assignmentDisplayName1' 161 | }).mockReturnValueOnce({ 162 | id: 'policyAssignment4', 163 | type: 'Microsoft.Authorization/policyAssignments', 164 | name: 'assignmentDisplayName4' 165 | }); 166 | const policyResults = [] 167 | await forceUpdateHelper.handleForceUpdate(definitionRequests, policyResponses, [], policyResults); 168 | expect(policyResults).toMatchObject([{ 169 | path: 'path1', 170 | type: 'Microsoft.Authorization/policyDefinition', 171 | operation: 'FORCE_UPDATE', 172 | displayName: 'displayName1', 173 | status: 'SUCCEEDED', 174 | message: 'Policy Microsoft.Authorization/policyDefinitions updated successfully', 175 | policyDefinitionId: 'policyDefinition1' 176 | }, 177 | { 178 | path: 'path4', 179 | type: 'Microsoft.Authorization/policyDefinition', 180 | operation: 'FORCE_UPDATE', 181 | displayName: 'displayName1', 182 | status: 'SUCCEEDED', 183 | message: 'Policy Microsoft.Authorization/policyDefinitions updated successfully', 184 | policyDefinitionId: 'policyDefinition4' 185 | }, 186 | { 187 | path: path.join('path1', 'policyDefinition1'), 188 | type: 'Microsoft.Authorization/policyAssignments', 189 | operation: 'FORCE_CREATE', 190 | displayName: 'assignmentDisplayName1', 191 | status: 'SUCCEEDED', 192 | message: 'Policy Microsoft.Authorization/policyAssignments created successfully', 193 | policyDefinitionId: undefined 194 | }, 195 | { 196 | path: path.join('path2', 'policyDefinition2'), 197 | type: 'Microsoft.Authorization/policyAssignments', 198 | operation: 'FORCE_CREATE', 199 | displayName: 'assignmentDisplayName4', 200 | status: 'SUCCEEDED', 201 | message: 'Policy Microsoft.Authorization/policyAssignments created successfully', 202 | policyDefinitionId: undefined 203 | } 204 | ]); 205 | }); 206 | }); -------------------------------------------------------------------------------- /lib/inputProcessing/pathHelper.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.isNonEnforced = exports.isEnforced = exports.getAllAssignmentInPaths = exports.getAllPolicyAssignmentPaths = exports.getAllInitiativesPaths = exports.getAllPolicyDefinitionPaths = void 0; 4 | const glob = require("glob"); 5 | const minimatch_1 = require("minimatch"); 6 | const path = require("path"); 7 | const core = require("@actions/core"); 8 | const Inputs = require("./inputs"); 9 | const policyHelper_1 = require("../azure/policyHelper"); 10 | const utilities_1 = require("../utils/utilities"); 11 | /** 12 | * @returns All the directories that: 13 | * 1) Match any pattern given in paths input. 14 | * 2) Do not match any pattern given in ignore-paths input. 15 | * 3) Contain policy.json files. 16 | */ 17 | function getAllPolicyDefinitionPaths() { 18 | core.debug('Looking for policy definition paths to include...'); 19 | const policyPathsToInclude = getPolicyPathsMatchingPatterns(Inputs.includePathPatterns, policyHelper_1.POLICY_FILE_NAME); 20 | core.debug('Looking for policy definition paths to ignore...'); 21 | const policyPathsToExclude = getPolicyPathsMatchingPatterns(Inputs.excludePathPatterns, policyHelper_1.POLICY_FILE_NAME); 22 | const policyPaths = policyPathsToInclude.filter(p => !policyPathsToExclude.includes(p)); 23 | const debugMessage = policyPaths.length > 0 24 | ? `Found the following policy paths that match the given path filters:\n\n${policyPaths.join('\n')}` 25 | : `Found no policies that match the given path filters.`; 26 | utilities_1.prettyDebugLog(debugMessage); 27 | return policyPaths; 28 | } 29 | exports.getAllPolicyDefinitionPaths = getAllPolicyDefinitionPaths; 30 | /** 31 | * @returns All the directories that: 32 | * 1) Match any pattern given in paths input. 33 | * 2) Do not match any pattern given in ignore-paths input. 34 | * 3) Contain policyset.json files. 35 | */ 36 | function getAllInitiativesPaths() { 37 | core.debug('Looking for policy initiative paths to include...'); 38 | const policyInitiativePathsToInclude = getPolicyPathsMatchingPatterns(Inputs.includePathPatterns, policyHelper_1.POLICY_INITIATIVE_FILE_NAME); 39 | core.debug('Looking for policy initiative paths to ignore...'); 40 | const policyInitiativePathsToExclude = getPolicyPathsMatchingPatterns(Inputs.excludePathPatterns, policyHelper_1.POLICY_INITIATIVE_FILE_NAME); 41 | const policyInitiativePaths = policyInitiativePathsToInclude.filter(p => !policyInitiativePathsToExclude.includes(p)); 42 | const debugMessage = policyInitiativePaths.length > 0 43 | ? `Found the following policy initiative paths that match the given path filters:\n\n${policyInitiativePaths.join('\n')}` 44 | : `Found no policy initiatives that match the given path filters.`; 45 | utilities_1.prettyDebugLog(debugMessage); 46 | return policyInitiativePaths; 47 | } 48 | exports.getAllInitiativesPaths = getAllInitiativesPaths; 49 | /** 50 | * @returns All the files that: 51 | * 1) Match any pattern given in paths input. 52 | * 2) Do not match pattern given in ignore-paths input. 53 | * 3) Contain policy.json as a sibling. 54 | * 4) File name matches any pattern given in assignments input. 55 | */ 56 | function getAllPolicyAssignmentPaths() { 57 | core.debug('Looking for policy assignment paths to include...'); 58 | const assignmentPathsToInclude = getAssignmentPathsMatchingPatterns(Inputs.includePathPatterns, Inputs.assignmentPatterns); 59 | core.debug('Looking for policy assignment paths to ignore...'); 60 | const assignmentPathsToExclude = getAssignmentPathsMatchingPatterns(Inputs.excludePathPatterns, Inputs.assignmentPatterns); 61 | const assignmentPaths = assignmentPathsToInclude.filter(p => !assignmentPathsToExclude.includes(p)); 62 | const debugMessage = assignmentPaths.length > 0 63 | ? `Found the following policy assignment paths that match the given path filters:\n\n${assignmentPaths.join('\n')}` 64 | : `Found no policy assignments that match the given path filters.`; 65 | utilities_1.prettyDebugLog(debugMessage); 66 | return assignmentPaths; 67 | } 68 | exports.getAllPolicyAssignmentPaths = getAllPolicyAssignmentPaths; 69 | function getAllAssignmentInPaths(definitionFolderPaths) { 70 | return getAssignmentPathsMatchingPatterns(definitionFolderPaths, Inputs.assignmentPatterns); 71 | } 72 | exports.getAllAssignmentInPaths = getAllAssignmentInPaths; 73 | function isEnforced(assignmentPath) { 74 | core.debug(`Checking if assignment path '${assignmentPath}' is set to enforce`); 75 | return Inputs.enforcePatterns.some(pattern => { 76 | const isMatch = minimatch_1.default(assignmentPath, pattern, { dot: true, matchBase: true }); 77 | if (isMatch) { 78 | core.debug(`Assignment path '${assignmentPath}' matches pattern '${pattern}' for enforce`); 79 | } 80 | return isMatch; 81 | }); 82 | } 83 | exports.isEnforced = isEnforced; 84 | function isNonEnforced(assignmentPath) { 85 | core.debug(`Checking if assignment path '${assignmentPath}' is set to do not enforce`); 86 | return Inputs.doNotEnforcePatterns.some(pattern => { 87 | const isMatch = minimatch_1.default(assignmentPath, pattern, { dot: true, matchBase: true }); 88 | if (isMatch) { 89 | core.debug(`Assignment path '${assignmentPath}' matches pattern '~${pattern}' for do not enforce`); 90 | } 91 | return isMatch; 92 | }); 93 | } 94 | exports.isNonEnforced = isNonEnforced; 95 | function getPolicyPathsMatchingPatterns(patterns, policyFileName) { 96 | let matchingPolicyPaths = []; 97 | patterns.forEach(pattern => { 98 | const policyFilePattern = path.join(pattern, policyFileName); 99 | const policyFiles = getFilesMatchingPattern(policyFilePattern); 100 | core.debug(`Policy file pattern: ${policyFilePattern}\n Matching policy paths: ${policyFiles}`); 101 | matchingPolicyPaths.push(...policyFiles.map(policyFile => path.dirname(policyFile))); 102 | }); 103 | return getUniquePaths(matchingPolicyPaths); 104 | } 105 | function getAssignmentPathsMatchingPatterns(patterns, assignmentPatterns) { 106 | let matchingAssignmentPaths = []; 107 | patterns.forEach(policyPath => { 108 | assignmentPatterns.forEach(assignmentPattern => { 109 | const pattern = path.join(policyPath, assignmentPattern); 110 | const assignmentPaths = getFilesMatchingPattern(pattern); 111 | core.debug(`Assignment pattern: ${pattern}\n Matching assignment paths: ${assignmentPaths}`); 112 | matchingAssignmentPaths.push(...assignmentPaths); 113 | }); 114 | }); 115 | return getUniquePaths(matchingAssignmentPaths); 116 | } 117 | function getFilesMatchingPattern(pattern) { 118 | return glob.sync(pattern, { dot: true }); 119 | } 120 | function getUniquePaths(paths) { 121 | return [...new Set(paths)]; 122 | } 123 | -------------------------------------------------------------------------------- /src/azure/roleAssignmentHelper.ts: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from "../utils/httpClient"; 2 | import { POLICY_OPERATION_CREATE, POLICY_RESULT_FAILED, POLICY_RESULT_SUCCEEDED, ROLE_ASSIGNMNET_TYPE, PolicyRequest, PolicyResult, isCreateOperation } from './policyHelper' 3 | import { prettyDebugLog, prettyLog } from '../utils/utilities' 4 | import { AzHttpClient } from './azHttpClient'; 5 | import { v4 as uuidv4 } from 'uuid'; 6 | 7 | export const POLICY_OPERATION_FORCE_UPDATE = "FORCE_UPDATE"; 8 | export const POLICY_OPERATION_FORCE_CREATE = "FORCE_CREATE"; 9 | 10 | export interface RoleRequest { 11 | scope: string; 12 | roleAssignmentId: string; 13 | roleDefinitionId: string; 14 | principalId: string; 15 | policyAssignmentId: string; 16 | policyDefinitionId: string; 17 | path: string; 18 | } 19 | 20 | export async function assignRoles(assignmentRequests: PolicyRequest[], assignmentResponses: any[], roleAssignmentResults: PolicyResult[]) { 21 | let filteredAssignments = filterIdentityAssignments(assignmentRequests, assignmentResponses); 22 | let allRoleDefinitions = await paresRoleDefinitions(filteredAssignments, roleAssignmentResults); 23 | let roleRequests: RoleRequest[] = getRoleRequests(filteredAssignments, allRoleDefinitions); 24 | 25 | await createRoles(roleRequests, roleAssignmentResults); 26 | } 27 | 28 | function filterIdentityAssignments(assignmentRequests: PolicyRequest[], assignmentResponses: any[]): any[] { 29 | let filteredAssignments: any[] = []; 30 | 31 | assignmentRequests.forEach((assignmentRequest, index) => { 32 | let assignmentResponse = assignmentResponses[index].content; 33 | // We will assign roles only when assignmnet was created and has identity field has principalId in it. 34 | if (isCreateOperation(assignmentRequest) && assignmentResponse.identity && assignmentResponse.identity.principalId) { 35 | // We will add path in assignment as it is required later. 36 | assignmentResponse.path = assignmentRequest.path; 37 | filteredAssignments.push(assignmentResponse); 38 | } 39 | }); 40 | 41 | return filteredAssignments; 42 | } 43 | 44 | async function paresRoleDefinitions(policyAssignments: any[], roleAssignmentResults: PolicyResult[]): Promise { 45 | let roleDefinitions = {}; 46 | const policyDefinitionIds: string[] = policyAssignments.map(assignment => assignment.properties.policyDefinitionId); 47 | 48 | try { 49 | const azHttpClient = new AzHttpClient(); 50 | await azHttpClient.initialize(); 51 | let policyDefinitions = await azHttpClient.getPolicyDefintions(policyDefinitionIds); 52 | policyDefinitions.forEach((definition, index) => { 53 | if (definition.error) { 54 | let policyAssignment = policyAssignments[index]; 55 | let message = definition.error.message ? definition.error.message : "Could not get policy definition from Azure"; 56 | roleAssignmentResults.push(getRoleAssignmentResult(policyAssignment.path, policyAssignment.id, policyAssignment.properties.policyDefinitionId, POLICY_RESULT_FAILED, message)); 57 | } 58 | else { 59 | let roleDefinitionIds: string[] = getRoleDefinitionIds(definition); 60 | if (roleDefinitionIds && roleDefinitionIds.length > 0) { 61 | // We need last part of role definition id 62 | roleDefinitions[definition.id] = roleDefinitionIds.map(roleDefinitionId => roleDefinitionId.split("/").pop()); 63 | } 64 | else { 65 | prettyLog(`Could not find role definition ids for adding role assignments to the managed identity. Definition Id : ${definition.id}`); 66 | } 67 | } 68 | }); 69 | } 70 | catch (error) { 71 | prettyDebugLog(`An error occurred while getting role requests for missing policy definitions. Error : ${error}`); 72 | throw new Error(`An error occurred while getting role requests for missing policy definitions. Error: ${error}`); 73 | } 74 | 75 | return roleDefinitions; 76 | } 77 | 78 | async function createRoles(roleRequests: RoleRequest[], roleAssignmentResults: PolicyResult[]) { 79 | if (roleRequests.length == 0) { 80 | prettyDebugLog(`No role assignments needs to be created`); 81 | return; 82 | } 83 | 84 | try { 85 | const azHttpClient = new AzHttpClient(); 86 | await azHttpClient.initialize(); 87 | let responses = await azHttpClient.addRoleAssinments(roleRequests); 88 | 89 | responses.forEach((response, index) => { 90 | let roleRequest = roleRequests[index]; 91 | let message = `Role Assignment created with id : ${response.content.id}`; 92 | let status = POLICY_RESULT_SUCCEEDED; 93 | 94 | if (response.httpStatusCode == StatusCodes.CREATED) { 95 | prettyDebugLog(`Role assignment created with id ${response.content.id} for assignmentId : ${roleRequest.policyAssignmentId}`); 96 | } 97 | else { 98 | prettyLog(`Role assignment could not be created related to assignment id ${roleRequest.policyAssignmentId}. Status : ${response.httpStatusCode}`); 99 | 100 | message = response.content.error ? response.content.error.message : `Role Assignment could not be created. Status : ${response.httpStatusCode}`; 101 | status = POLICY_RESULT_FAILED; 102 | } 103 | roleAssignmentResults.push(getRoleAssignmentResult(roleRequest.path, roleRequest.policyAssignmentId, roleRequest.policyDefinitionId, status, message)); 104 | }); 105 | } 106 | catch (error) { 107 | prettyLog(`An error occurred while creating role assignments. Error: ${error}`); 108 | throw new Error(`An error occurred while creating role assignments. Error: ${error}`); 109 | } 110 | } 111 | 112 | function getRoleRequests(policyAssignments: any[], allRoleDefinitions: any): RoleRequest[] { 113 | let roleRequests: RoleRequest[] = []; 114 | policyAssignments.forEach(policyAssignment => { 115 | let roleDefinitions = allRoleDefinitions[policyAssignment.properties.policyDefinitionId]; 116 | if (roleDefinitions) { 117 | roleDefinitions.forEach(roleId => { 118 | roleRequests.push({ 119 | scope: policyAssignment.properties.scope, 120 | roleAssignmentId: uuidv4(), 121 | roleDefinitionId: roleId, 122 | principalId: policyAssignment.identity.principalId, 123 | policyAssignmentId: policyAssignment.id, 124 | policyDefinitionId: policyAssignment.properties.policyDefinitionId, 125 | path: policyAssignment.path 126 | }); 127 | }); 128 | } 129 | }); 130 | 131 | return roleRequests; 132 | } 133 | 134 | function getRoleAssignmentResult(path: string, assignmentId: string, definitionId: string, status: string, message: string): PolicyResult { 135 | return { 136 | path: path, 137 | type: ROLE_ASSIGNMNET_TYPE, 138 | operation: POLICY_OPERATION_CREATE, 139 | displayName: `Role Assignment for policy policy assignment id : ${assignmentId}`, 140 | status: status, 141 | message: message, 142 | policyDefinitionId: definitionId 143 | } 144 | } 145 | 146 | function getRoleDefinitionIds(policyDefinition: any): string[] { 147 | if (policyDefinition.properties 148 | && policyDefinition.properties.policyRule 149 | && policyDefinition.properties.policyRule.then 150 | && policyDefinition.properties.policyRule.then.details 151 | && policyDefinition.properties.policyRule.then.details.roleDefinitionIds) { 152 | return policyDefinition.properties.policyRule.then.details.roleDefinitionIds; 153 | } 154 | 155 | return undefined; 156 | } -------------------------------------------------------------------------------- /tutorial/azure-policy-as-code.md: -------------------------------------------------------------------------------- 1 | # Managing Azure Policy as Code with GitHub 2 | 3 | As you progress on the journey of Cloud Governance, there is an increasing need to shift from manually managing each policy in Azure portal to something more manageable, collaborative and repeatable at enterprise scale. We are excited to announce that we are rolling out experience that will help you manage Azure policy from GitHub and provide you all the benfits of a version control. 4 | 5 | With these new features: 6 | - You can easily export existing policies from Azure portal and store them as files in GitHub repository 7 | - You can collaborate in GitHub with other stakeholders to create or modify policy files, track all the changes done and push all updates back to Azure using GitHub actions 8 | - You can now employ safe deployment practices by rolling out policies in a stagewise and orchestrated manner using GitHub workflows 9 | - You can also trigger compliance scans at one or more multiple scopes at a pre-defined convenient time and get report in form of a csv file which can be used for further analysis or archival 10 | 11 | 12 | ### Export Policies from Azure Portal 13 | 14 | To export azure policy from Azure portal, follow these steps: 15 | 16 | 1. Launch the Azure Policy service in the Azure portal by clicking **All services**, then searching 17 | for and selecting **Policy**. 18 | 19 | 1. Select **Definitions** on the left side of the Azure Policy page. 20 | 21 | 1. Use the **Export definitions** button or select the ellipsis on the row of a policy definition 22 | and then select **Export definition**. 23 | 24 | ![Primary Export Button](./_imgs/export-central.png) 25 | 26 | 1. Select the **Sign in with GitHub** button. If you are not already logged into your github account in the browser, a window will prompt you to sign in. Provide your GitHub account details and click Sign in. The window self-closes once signed in. 27 | 28 | 1. On the **Basics** tab, set the following options, then select the **Policies** tab or **Next : 29 | Policies** button at the bottom of the page. 30 | 31 | ![GitHub Inputs image](./_imgs/github_controls.png) 32 | 33 | - **Repository filter**: Set to _My repositories_ to see only repositories you own or _All 34 | repositories_ to see all you granted the GitHub Action access to. 35 | - **Repository**: Set to the repository that you want to export the Azure Policy resources to. 36 | - **Branch**: Set the branch in the repository. Using a branch other than the default is a good 37 | way to validate your updates before merging further into your source code. 38 | - **Directory**: The _root level folder_ to export the Azure Policy resources to. Subfolders 39 | under this directory are created based on what resources are exported. 40 | 41 | 42 | 1. On the **Policies** tab, set the scope to search by selecting the ellipsis and picking a 43 | combination of management groups, subscriptions, or resource groups. 44 | 45 | 1. Use the **Add policy definition(s)** button to search the scope for which policy to export. In 46 | the side window that opens, select each policy to export. Filter the selection by the search box 47 | or the type. Once you've selected all objects to export, use the **Add** button at the bottom of 48 | the page. 49 | 50 | 1. For each selected policy, select the desired export options such as _Only Definition_ or 51 | _Definition and Assignment(s)_ for a policy definition. Then select the **Review + Export** tab 52 | or **Next : Review + Export** button at the bottom of the page. 53 | 54 | > **NOTE:** 55 | > If option _Definition and Assignment(s)_ is chosen, only policy assignments within the scope 56 | > set by the filter when the policy definition is added are exported. 57 | 58 | ![policy definition selection image](./_imgs/definition-selection.png) 59 | 60 | 1. On the **Review + Export** tab, check the details match and then use the **Export** button at the 61 | bottom of the page. 62 | 63 | 1. Check your GitHub repo, branch, and _root level folder_ to see that the selected resources are 64 | now exported to your source control. 65 | 66 | ![GitHub Exported folders](./_imgs/github-exported-folders.png) 67 | 68 | The Azure Policy resources are exported into the following structure within the selected GitHub 69 | repository and _root level folder_: 70 | 71 | ```text 72 | | 73 | |- / ________________ # Root level folder set by Directory property 74 | | |- policies/ ________________________ # Subfolder for policy objects 75 | | |- _____________ # Subfolder based on policy displayName and name properties 76 | | |- policy.json _________________ # Policy definition 77 | | |- assign.___ # Each assignment (if selected) based on displayName and name properties 78 | | 79 | ``` 80 | 81 | 82 | 83 | ### Push updates in GitHub Repo policy files to Azure 84 | 85 | 1. When you export policies, along with the policy resources, a [GitHub workflow](https://docs.github.com/en/actions/configuring-and-managing-workflows/configuring-a-workflow#about-workflows) file .github/workflows/manage-azure-policy-\.yml is also created to get you started easily 86 | 87 | ![GitHub workflow](./_imgs/github-workflow.png) 88 | 89 | 90 | 2. This workflow file uses [Manage Azure Policy](https://github.com/marketplace/actions/manage-azure-policy) action to push any changes made to the exported policy resources in GitHub repository to Azure policy. By default, the action will consider only those files that are different from the ones existing in Azure and sync changes for these files only. You can also use the `assignments` parameter in the action to only sync changes done to specific assignment files only. This can be used to apply policies only for a specific environment. For more details check out the [repository](https://github.com/Azure/manage-azure-policy). 91 | 92 | 3. By default the workflow is configured to be triggered manually at the push of a button. You can do so by visiting the Actions section in Github and clicking on the workflow. 93 | 94 | ![GitHub workflow run button](./_imgs/run-workflow.png) 95 | 96 | 4. The workflow will sync the changes done to policy files with Azure and give you the status in the logs. 97 | ![GitHub workflow logs](./_imgs/results-log.png) 98 | 99 | 5. The workflow will also add details in the policy for you to track back changes to a github run. 100 | ![GitHub traceability](./_imgs/traceability-data.png) 101 | 102 | 103 | ### Trigger compliance scans using GitHub action 104 | 105 | Using the [Azure Policy Compliance Scan action](https://github.com/marketplace/actions/azure-policy-compliance-scan) you can trigger an on-demand compliance evaluation scan from your [GitHub workflow](https://docs.github.com/en/actions/configuring-and-managing-workflows/configuring-a-workflow#about-workflows) on one or multiple resources, resource groups or subscriptions, and continue/fail the workflow based on the compliance state of resources. You can also configure the workflow to run at a scheduled time so that you get the latest compliance status at a convenient time. Optionally, this Github action can also generate a report on the compliance state of scanned resources for further analysis or for archiving. 106 | 107 | The following example will run compliance scan for a subscription. 108 | 109 | ```yaml 110 | 111 | on: 112 | schedule: 113 | - cron: '0 8 * * *' # runs every morning 8am 114 | jobs: 115 | assess-policy-compliance: 116 | runs-on: ubuntu-latest 117 | steps: 118 | - name: Login to Azure 119 | uses: azure/login@v1 120 | with: 121 | creds: ${{secrets.AZURE_CREDENTIALS}} 122 | 123 | 124 | - name: Check for resource compliance 125 | uses: azure/policy-compliance-scan@v0 126 | with: 127 | scopes: | 128 | /subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 129 | 130 | ``` 131 | -------------------------------------------------------------------------------- /lib/azure/roleAssignmentHelper.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { 3 | function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } 4 | return new (P || (P = Promise))(function (resolve, reject) { 5 | function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } 6 | function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } 7 | function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } 8 | step((generator = generator.apply(thisArg, _arguments || [])).next()); 9 | }); 10 | }; 11 | Object.defineProperty(exports, "__esModule", { value: true }); 12 | exports.assignRoles = exports.POLICY_OPERATION_FORCE_CREATE = exports.POLICY_OPERATION_FORCE_UPDATE = void 0; 13 | const httpClient_1 = require("../utils/httpClient"); 14 | const policyHelper_1 = require("./policyHelper"); 15 | const utilities_1 = require("../utils/utilities"); 16 | const azHttpClient_1 = require("./azHttpClient"); 17 | const uuid_1 = require("uuid"); 18 | exports.POLICY_OPERATION_FORCE_UPDATE = "FORCE_UPDATE"; 19 | exports.POLICY_OPERATION_FORCE_CREATE = "FORCE_CREATE"; 20 | function assignRoles(assignmentRequests, assignmentResponses, roleAssignmentResults) { 21 | return __awaiter(this, void 0, void 0, function* () { 22 | let filteredAssignments = filterIdentityAssignments(assignmentRequests, assignmentResponses); 23 | let allRoleDefinitions = yield paresRoleDefinitions(filteredAssignments, roleAssignmentResults); 24 | let roleRequests = getRoleRequests(filteredAssignments, allRoleDefinitions); 25 | yield createRoles(roleRequests, roleAssignmentResults); 26 | }); 27 | } 28 | exports.assignRoles = assignRoles; 29 | function filterIdentityAssignments(assignmentRequests, assignmentResponses) { 30 | let filteredAssignments = []; 31 | assignmentRequests.forEach((assignmentRequest, index) => { 32 | let assignmentResponse = assignmentResponses[index].content; 33 | // We will assign roles only when assignmnet was created and has identity field has principalId in it. 34 | if (policyHelper_1.isCreateOperation(assignmentRequest) && assignmentResponse.identity && assignmentResponse.identity.principalId) { 35 | // We will add path in assignment as it is required later. 36 | assignmentResponse.path = assignmentRequest.path; 37 | filteredAssignments.push(assignmentResponse); 38 | } 39 | }); 40 | return filteredAssignments; 41 | } 42 | function paresRoleDefinitions(policyAssignments, roleAssignmentResults) { 43 | return __awaiter(this, void 0, void 0, function* () { 44 | let roleDefinitions = {}; 45 | const policyDefinitionIds = policyAssignments.map(assignment => assignment.properties.policyDefinitionId); 46 | try { 47 | const azHttpClient = new azHttpClient_1.AzHttpClient(); 48 | yield azHttpClient.initialize(); 49 | let policyDefinitions = yield azHttpClient.getPolicyDefintions(policyDefinitionIds); 50 | policyDefinitions.forEach((definition, index) => { 51 | if (definition.error) { 52 | let policyAssignment = policyAssignments[index]; 53 | let message = definition.error.message ? definition.error.message : "Could not get policy definition from Azure"; 54 | roleAssignmentResults.push(getRoleAssignmentResult(policyAssignment.path, policyAssignment.id, policyAssignment.properties.policyDefinitionId, policyHelper_1.POLICY_RESULT_FAILED, message)); 55 | } 56 | else { 57 | let roleDefinitionIds = getRoleDefinitionIds(definition); 58 | if (roleDefinitionIds && roleDefinitionIds.length > 0) { 59 | // We need last part of role definition id 60 | roleDefinitions[definition.id] = roleDefinitionIds.map(roleDefinitionId => roleDefinitionId.split("/").pop()); 61 | } 62 | else { 63 | utilities_1.prettyLog(`Could not find role definition ids for adding role assignments to the managed identity. Definition Id : ${definition.id}`); 64 | } 65 | } 66 | }); 67 | } 68 | catch (error) { 69 | utilities_1.prettyDebugLog(`An error occurred while getting role requests for missing policy definitions. Error : ${error}`); 70 | throw new Error(`An error occurred while getting role requests for missing policy definitions. Error: ${error}`); 71 | } 72 | return roleDefinitions; 73 | }); 74 | } 75 | function createRoles(roleRequests, roleAssignmentResults) { 76 | return __awaiter(this, void 0, void 0, function* () { 77 | if (roleRequests.length == 0) { 78 | utilities_1.prettyDebugLog(`No role assignments needs to be created`); 79 | return; 80 | } 81 | try { 82 | const azHttpClient = new azHttpClient_1.AzHttpClient(); 83 | yield azHttpClient.initialize(); 84 | let responses = yield azHttpClient.addRoleAssinments(roleRequests); 85 | responses.forEach((response, index) => { 86 | let roleRequest = roleRequests[index]; 87 | let message = `Role Assignment created with id : ${response.content.id}`; 88 | let status = policyHelper_1.POLICY_RESULT_SUCCEEDED; 89 | if (response.httpStatusCode == httpClient_1.StatusCodes.CREATED) { 90 | utilities_1.prettyDebugLog(`Role assignment created with id ${response.content.id} for assignmentId : ${roleRequest.policyAssignmentId}`); 91 | } 92 | else { 93 | utilities_1.prettyLog(`Role assignment could not be created related to assignment id ${roleRequest.policyAssignmentId}. Status : ${response.httpStatusCode}`); 94 | message = response.content.error ? response.content.error.message : `Role Assignment could not be created. Status : ${response.httpStatusCode}`; 95 | status = policyHelper_1.POLICY_RESULT_FAILED; 96 | } 97 | roleAssignmentResults.push(getRoleAssignmentResult(roleRequest.path, roleRequest.policyAssignmentId, roleRequest.policyDefinitionId, status, message)); 98 | }); 99 | } 100 | catch (error) { 101 | utilities_1.prettyLog(`An error occurred while creating role assignments. Error: ${error}`); 102 | throw new Error(`An error occurred while creating role assignments. Error: ${error}`); 103 | } 104 | }); 105 | } 106 | function getRoleRequests(policyAssignments, allRoleDefinitions) { 107 | let roleRequests = []; 108 | policyAssignments.forEach(policyAssignment => { 109 | let roleDefinitions = allRoleDefinitions[policyAssignment.properties.policyDefinitionId]; 110 | if (roleDefinitions) { 111 | roleDefinitions.forEach(roleId => { 112 | roleRequests.push({ 113 | scope: policyAssignment.properties.scope, 114 | roleAssignmentId: uuid_1.v4(), 115 | roleDefinitionId: roleId, 116 | principalId: policyAssignment.identity.principalId, 117 | policyAssignmentId: policyAssignment.id, 118 | policyDefinitionId: policyAssignment.properties.policyDefinitionId, 119 | path: policyAssignment.path 120 | }); 121 | }); 122 | } 123 | }); 124 | return roleRequests; 125 | } 126 | function getRoleAssignmentResult(path, assignmentId, definitionId, status, message) { 127 | return { 128 | path: path, 129 | type: policyHelper_1.ROLE_ASSIGNMNET_TYPE, 130 | operation: policyHelper_1.POLICY_OPERATION_CREATE, 131 | displayName: `Role Assignment for policy policy assignment id : ${assignmentId}`, 132 | status: status, 133 | message: message, 134 | policyDefinitionId: definitionId 135 | }; 136 | } 137 | function getRoleDefinitionIds(policyDefinition) { 138 | if (policyDefinition.properties 139 | && policyDefinition.properties.policyRule 140 | && policyDefinition.properties.policyRule.then 141 | && policyDefinition.properties.policyRule.then.details 142 | && policyDefinition.properties.policyRule.then.details.roleDefinitionIds) { 143 | return policyDefinition.properties.policyRule.then.details.roleDefinitionIds; 144 | } 145 | return undefined; 146 | } 147 | -------------------------------------------------------------------------------- /__tests__/azure.policyHelper.test.ts: -------------------------------------------------------------------------------- 1 | import * as policyHelper from '../src/azure/policyHelper'; 2 | import { 3 | PolicyDetails 4 | } from '../src/azure/policyHelper'; 5 | import * as pathHelper from '../src/inputProcessing/pathHelper'; 6 | import * as fs from 'fs'; 7 | import * as path from 'path'; 8 | import * as core from '@actions/core'; 9 | 10 | const mockPopulateFn = jest.fn().mockImplementation((policies) => { 11 | policies.forEach(policy => { 12 | policy.policyInService = 'populated'; 13 | }); 14 | }); 15 | jest.mock('../src/azure/azHttpClient', () => { 16 | return { 17 | AzHttpClient: jest.fn().mockImplementation(() => { 18 | return { 19 | initialize: () => {}, 20 | populateServicePolicies: (policies) => mockPopulateFn(policies) 21 | } 22 | }) 23 | }; 24 | }); 25 | 26 | describe('Testing all functions in policyHelper file', () => { 27 | test('getAllPolicyDetails() - get all policy details', async () => { 28 | jest.spyOn(pathHelper, 'getAllPolicyDefinitionPaths').mockReturnValue(['definitionPath']); 29 | jest.spyOn(pathHelper, 'getAllInitiativesPaths').mockReturnValue(['initiativePath']); 30 | jest.spyOn(pathHelper, 'getAllPolicyAssignmentPaths').mockReturnValue([path.join('definitionPath', 'assign.dev.json')]); 31 | const policyJson = JSON.stringify({ 32 | "id": 'policyId', 33 | "name": 'policyName', 34 | "type": 'Microsoft.Authorization/policyDefinitions', 35 | "properties": { 36 | "displayName": "Allowed locations", 37 | } 38 | }); 39 | const policyRulesJson = JSON.stringify({ 40 | "if": { 41 | "not": { 42 | "field": "location", 43 | "in": "[parameters('allowedLocations')]" 44 | } 45 | } 46 | }); 47 | const policyParametersJson = JSON.stringify({ 48 | "allowedLocations": { 49 | "defaultValue": ["westus2"] 50 | } 51 | }); 52 | const policysetJson = JSON.stringify({ 53 | "properties": { 54 | "displayName": "Billing Tags Policy", 55 | "description": "Specify cost Center tag and product name tag", 56 | "metadata": { 57 | "version": "1.0.0", 58 | "category": "Tags" 59 | } 60 | } 61 | }); 62 | const policySetDefinitionJson = JSON.stringify([{ 63 | "policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/1e30110a-5ceb-460c-a204-c1c3969c6d62", 64 | "parameters": { 65 | "tagName": { 66 | "value": "costCenter" 67 | }, 68 | } 69 | }]); 70 | const policySetParametersJson = JSON.stringify({ 71 | "costCenterValue": { 72 | "type": "String", 73 | "metadata": { 74 | "description": "required value for Cost Center tag" 75 | }, 76 | "defaultValue": "DefaultCostCenter" 77 | }, 78 | }); 79 | const policyAssignJson = JSON.stringify({ 80 | "name": "assignName", 81 | "type": "Microsoft.Authorization/policyAssignments", 82 | "apiVersion": "2020-03-01", 83 | "scope": "subscription", 84 | "properties": { 85 | "displayName": "something", 86 | }, 87 | "location": "westus", 88 | "identity": { 89 | "type": "new" 90 | } 91 | }); 92 | jest.spyOn(pathHelper, 'isNonEnforced').mockReturnValue(false); 93 | jest.spyOn(pathHelper, 'isEnforced').mockReturnValue(true); 94 | jest.spyOn(fs, 'existsSync').mockReturnValue(true); 95 | jest.spyOn(console, 'log').mockImplementation(); 96 | jest.spyOn(core, 'debug').mockImplementation(); 97 | jest.spyOn(fs, 'readFileSync').mockImplementation((file) => { 98 | if (path.join('definitionPath', 'policy.json')) return policyJson; 99 | if (path.join('definitionPath', 'policy.rules.json')) return policyRulesJson; 100 | if (path.join('definitionPath', 'policy.parameters.json')) return policyParametersJson; 101 | if (path.join('initiativePath', 'policyset.json')) return policysetJson; 102 | if (path.join('initiativePath', 'policyset.definitions.json')) return policySetDefinitionJson; 103 | if (path.join('initiativePath', 'policyset.parameters.json')) return policySetParametersJson; 104 | if (path.join('definitionPath', 'assign.dev.json')) return policyAssignJson; 105 | }); 106 | 107 | expect(await policyHelper.getAllPolicyDetails()).toMatchObject([{ 108 | "path": "definitionPath", 109 | "policyInCode": { 110 | "id": "policyId", 111 | "name": "policyName", 112 | "type": "Microsoft.Authorization/policyDefinitions", 113 | "properties": { 114 | "displayName": "Allowed locations", 115 | "policyRule": { 116 | "id": "policyId", 117 | "name": "policyName", 118 | "type": "Microsoft.Authorization/policyDefinitions", 119 | "properties": { 120 | "displayName": "Allowed locations" 121 | } 122 | }, 123 | "parameters": { 124 | "id": "policyId", 125 | "name": "policyName", 126 | "type": "Microsoft.Authorization/policyDefinitions", 127 | "properties": { 128 | "displayName": "Allowed locations" 129 | } 130 | } 131 | } 132 | }, 133 | "policyInService": "populated" 134 | }, 135 | { 136 | "path": "initiativePath", 137 | "policyInCode": { 138 | "id": "policyId", 139 | "name": "policyName", 140 | "type": "Microsoft.Authorization/policyDefinitions", 141 | "properties": { 142 | "displayName": "Allowed locations", 143 | "policyDefinitions": { 144 | "id": "policyId", 145 | "name": "policyName", 146 | "type": "Microsoft.Authorization/policyDefinitions", 147 | "properties": { 148 | "displayName": "Allowed locations" 149 | } 150 | }, 151 | "parameters": { 152 | "id": "policyId", 153 | "name": "policyName", 154 | "type": "Microsoft.Authorization/policyDefinitions", 155 | "properties": { 156 | "displayName": "Allowed locations" 157 | } 158 | } 159 | } 160 | }, 161 | "policyInService": "populated" 162 | }, 163 | { 164 | "path": path.join('definitionPath', 'assign.dev.json'), 165 | "policyInCode": { 166 | "id": "policyId", 167 | "name": "policyName", 168 | "type": "Microsoft.Authorization/policyDefinitions", 169 | "properties": { 170 | "displayName": "Allowed locations", 171 | "enforcementMode": "Default" 172 | } 173 | }, 174 | "policyInService": "populated" 175 | } 176 | ]); 177 | }); 178 | 179 | test('getPolicyOperationType() - return NONE if policy is not newly created', () => { 180 | const policyDetails = { 181 | policyInCode: { 182 | 183 | }, 184 | policyInService: { 185 | properties: { 186 | metadata: { 187 | gitHubPolicy: { 188 | digest: 'abc' 189 | } 190 | } 191 | } 192 | } 193 | } as PolicyDetails; 194 | 195 | expect(policyHelper.getPolicyOperationType(policyDetails, 'abc')).toBe('NONE'); 196 | }); 197 | 198 | test('getPolicyOperationType() - return UPDATE if hash is not available in policy', () => { 199 | const policyDetails = { 200 | policyInCode: { 201 | 202 | }, 203 | policyInService: {} 204 | } as PolicyDetails; 205 | 206 | expect(policyHelper.getPolicyOperationType(policyDetails, 'def')).toBe('UPDATE'); 207 | }); 208 | 209 | test('getPolicyOperationType() - return CREATE if policy needs to be created', () => { 210 | const policyDetails = { 211 | policyInCode: { 212 | 213 | }, 214 | policyInService: { 215 | error: {} 216 | } 217 | } as PolicyDetails; 218 | 219 | expect(policyHelper.getPolicyOperationType(policyDetails, 'def')).toBe('CREATE'); 220 | }); 221 | 222 | test('getPolicyOperationType() - return UPDATE if policy needs to be updated', () => { 223 | const policyDetails = { 224 | policyInCode: { 225 | 226 | }, 227 | policyInService: { 228 | properties: { 229 | metadata: { 230 | gitHubPolicy: { 231 | digest: 'abc' 232 | } 233 | } 234 | } 235 | } 236 | } as PolicyDetails; 237 | 238 | expect(policyHelper.getPolicyOperationType(policyDetails, 'def')).toBe('UPDATE'); 239 | }); 240 | 241 | test('getPolicyRequest() - create and return policy request using parameters', () => { 242 | const processEnv = process.env; 243 | process.env.GITHUB_REPOSITORY = 'githubRepo'; 244 | process.env.GITHUB_SHA = 'sampleSha'; 245 | process.env.GITHUB_RUN_ID = '552'; 246 | const expected = { 247 | "policy": { 248 | "properties": { 249 | "metadata": { 250 | "gitHubPolicy": { 251 | "digest": "abc", 252 | "repoName": "githubRepo", 253 | "commitSha": "sampleSha", 254 | "runUrl": "https://github.com/githubRepo/actions/runs/552", 255 | "filepath": "pathToPolicy" 256 | } 257 | } 258 | } 259 | }, 260 | "path": "pathToPolicy", 261 | "operation": "UPDATE" 262 | }; 263 | 264 | expect(policyHelper.getPolicyRequest({}, 'pathToPolicy', 'abc', 'UPDATE')).toMatchObject(expected); 265 | process.env = processEnv; 266 | }); 267 | }); -------------------------------------------------------------------------------- /__tests__/utils.test.ts: -------------------------------------------------------------------------------- 1 | import * as fileHelper from '../src/utils/fileHelper'; 2 | import * as utilities from '../src/utils/utilities'; 3 | import * as fs from 'fs'; 4 | import * as client from '../src/utils/httpClient'; 5 | import { 6 | WebRequestOptions 7 | } from '../src/utils/httpClient'; 8 | import * as core from '@actions/core'; 9 | import httpClient = require("typed-rest-client/HttpClient"); 10 | import * as crypto from "crypto"; 11 | 12 | describe('Testing functions in fileHelper.', () => { 13 | test('getFileJson() - reads a file, parses and returns json', () => { 14 | jest.spyOn(fs, 'readFileSync').mockReturnValue('{}'); 15 | expect(fileHelper.getFileJson('pathToFile')).toMatchObject({}); 16 | }); 17 | 18 | test('getFileJson() - reads a file, parses and throw error if invalid json', () => { 19 | jest.spyOn(fs, 'readFileSync').mockReturnValue(''); 20 | expect(() => fileHelper.getFileJson('pathToFile')).toThrow('An error occured while parsing the contents of the file: pathToFile. Error: SyntaxError: Unexpected end of JSON input'); 21 | }); 22 | }); 23 | 24 | describe('Testing functions in utilities', () => { 25 | test('getWorkflowRunUrl() - form and return current workflow run url', () => { 26 | const processEnv = process.env; 27 | process.env.GITHUB_REPOSITORY = 'sampleRepo' 28 | process.env.GITHUB_RUN_ID = '55' 29 | expect(utilities.getWorkflowRunUrl()).toBe('https://github.com/sampleRepo/actions/runs/55'); 30 | process.env = processEnv; 31 | }); 32 | 33 | test('setUpUserAgent() - set user agent variable', () => { 34 | jest.spyOn(core, 'exportVariable').mockImplementation(); 35 | const processEnv = process.env; 36 | process.env.GITHUB_REPOSITORY = 'sampleRepo'; 37 | expect(utilities.setUpUserAgent()).toBeUndefined(); 38 | expect(core.exportVariable).toBeCalledWith('AZURE_HTTP_USER_AGENT', `GITHUBACTIONS_ManageAzurePolicy_${crypto.createHash('sha256').update(`${process.env.GITHUB_REPOSITORY}`).digest('hex')}`); 39 | process.env = processEnv; 40 | }); 41 | 42 | test('groupBy() - groups all elements of an array with same property', () => { 43 | const someProperty = 'property'; 44 | const inputArray = [{ 45 | id: 1, 46 | 'property': 'a' 47 | }, 48 | { 49 | id: 2, 50 | 'property': 'b' 51 | }, 52 | { 53 | id: 3, 54 | 'property': 'c' 55 | }, 56 | { 57 | id: 4, 58 | 'property': 'a' 59 | }, 60 | { 61 | id: 5, 62 | 'property': 'a' 63 | }, 64 | { 65 | id: 6, 66 | 'property': 'b' 67 | }, 68 | { 69 | id: 7, 70 | 'property': 'c' 71 | }, 72 | ]; 73 | 74 | const outputObject = { 75 | 'a': [{ 76 | id: 1, 77 | 'property': 'a' 78 | }, 79 | { 80 | id: 4, 81 | 'property': 'a' 82 | }, 83 | { 84 | id: 5, 85 | 'property': 'a' 86 | } 87 | ], 88 | 'b': [{ 89 | id: 2, 90 | 'property': 'b' 91 | }, 92 | { 93 | id: 6, 94 | 'property': 'b' 95 | } 96 | ], 97 | 'c': [{ 98 | id: 3, 99 | 'property': 'c' 100 | }, 101 | { 102 | id: 7, 103 | 'property': 'c' 104 | } 105 | ] 106 | } 107 | expect(utilities.groupBy(inputArray, someProperty)).toMatchObject(outputObject); 108 | }); 109 | 110 | test('repeatString() - returns the input string repeated specified number of times', () => { 111 | expect(utilities.repeatString('abc', 5)).toBe('abcabcabcabcabc'); 112 | }); 113 | 114 | test('populatePropertyFromJsonFile() - populates property to the given object from the provided jsonfile', () => { 115 | jest.spyOn(fs, 'existsSync').mockReturnValue(true); 116 | const jsonFile = JSON.stringify({ 117 | property: 'someValue' 118 | }); 119 | jest.spyOn(fs, 'readFileSync').mockReturnValue(jsonFile); 120 | const inputObject = {}; 121 | 122 | expect(utilities.populatePropertyFromJsonFile(inputObject, 'jsonFilePath', 'property')).toBeUndefined(); 123 | expect(inputObject).toMatchObject({ 124 | property: 'someValue' 125 | }); 126 | }); 127 | 128 | test('populatePropertyFromJsonFile() - jsonfile does not contain the property whole json object is populated', () => { 129 | jest.spyOn(fs, 'existsSync').mockReturnValue(true); 130 | jest.spyOn(fs, 'readFileSync').mockReturnValue('{}'); 131 | const inputObject = {}; 132 | 133 | expect(utilities.populatePropertyFromJsonFile(inputObject, 'jsonFilePath', 'property')).toBeUndefined(); 134 | expect(inputObject).toMatchObject({ 135 | property: {} 136 | }); 137 | }); 138 | }); 139 | 140 | var mockResponses: any[], mockRequestNumber: number; 141 | const mockRequestFuncion = jest.fn().mockImplementation(async () => { 142 | if (mockRequestNumber < mockResponses.length) { 143 | if (mockResponses[mockRequestNumber].message && mockResponses[mockRequestNumber].message.statusCode) 144 | return Promise.resolve(mockResponses[mockRequestNumber++]); 145 | else 146 | return Promise.reject(mockResponses[mockRequestNumber++]) 147 | }; 148 | }); 149 | jest.mock('typed-rest-client/HttpClient', () => { 150 | return { 151 | HttpClient: jest.fn().mockImplementation(() => { 152 | return { 153 | request: async (verb, requestUrl, data) => mockRequestFuncion(verb, requestUrl, data) 154 | } 155 | }) 156 | } 157 | }); 158 | 159 | describe('Testing all functions in httpClient file.', () => { 160 | test('toWebResponse() - construct WebResponse object from response and return', async () => { 161 | const sampleResponse = { 162 | message: { 163 | statusCode: 200, 164 | statusMessage: 'success', 165 | headers: {} 166 | }, 167 | readBody: () => Promise.resolve('{}') 168 | } as httpClient.HttpClientResponse; 169 | const sampleReturn = { 170 | statusCode: 200, 171 | statusMessage: 'success', 172 | headers: {}, 173 | body: {} 174 | }; 175 | 176 | expect(await client.toWebResponse(sampleResponse)).toEqual(sampleReturn); 177 | }); 178 | 179 | test('toWebResponse() - return response body as is if not parseable', async () => { 180 | jest.spyOn(core, 'debug').mockImplementation(); 181 | const sampleResponse = { 182 | message: { 183 | statusCode: 200, 184 | statusMessage: 'success', 185 | headers: {} 186 | }, 187 | readBody: () => Promise.resolve('invalid') 188 | } as httpClient.HttpClientResponse; 189 | const sampleReturn = { 190 | statusCode: 200, 191 | statusMessage: 'success', 192 | headers: {}, 193 | body: 'invalid' 194 | }; 195 | 196 | expect(await client.toWebResponse(sampleResponse)).toEqual(sampleReturn); 197 | }); 198 | 199 | test('sendRequestInternal() - make request and return response as WebResponse', async () => { 200 | const sampleResponse = { 201 | message: { 202 | statusCode: 200, 203 | statusMessage: 'success', 204 | headers: {} 205 | }, 206 | readBody: () => Promise.resolve('{}') 207 | } as httpClient.HttpClientResponse; 208 | const sampleReturn = { 209 | statusCode: 200, 210 | statusMessage: 'success', 211 | headers: {}, 212 | body: {} 213 | }; 214 | const sampleRequest = { 215 | method: 'get', 216 | uri: 'https://github.com', 217 | body: {} 218 | } as client.WebRequest; 219 | jest.spyOn(core, 'debug').mockImplementation(); 220 | mockRequestNumber = 0 221 | mockResponses = [sampleResponse]; 222 | 223 | expect(await client.sendRequestInternal(sampleRequest)).toEqual(sampleReturn); 224 | expect(mockRequestFuncion).toBeCalledWith('get', 'https://github.com', {}); 225 | }); 226 | 227 | test('sendRequest() - make requests with specified options and return WebResponse promise', async () => { 228 | const sampleOptions = { 229 | retryCount: 3, 230 | retryIntervalInSeconds: 0.1 231 | } as WebRequestOptions; 232 | const sampleResponse = { 233 | message: { 234 | statusCode: 200, 235 | statusMessage: 'success', 236 | headers: {} 237 | }, 238 | readBody: () => Promise.resolve('{}') 239 | } as httpClient.HttpClientResponse; 240 | const sampleRetryResponse = { 241 | message: { 242 | statusCode: 408, 243 | statusMessage: 'timeout', 244 | headers: {} 245 | }, 246 | readBody: () => Promise.resolve('{}') 247 | } as httpClient.HttpClientResponse; 248 | const sampleErrorResponse = { 249 | code: 'ECONNRESET' 250 | }; 251 | const sampleReturn = { 252 | statusCode: 200, 253 | statusMessage: 'success', 254 | headers: {}, 255 | body: {} 256 | }; 257 | const sampleRequest = { 258 | method: 'get', 259 | uri: 'https://github.com', 260 | } as client.WebRequest; 261 | jest.spyOn(core, 'debug').mockImplementation(); 262 | mockRequestNumber = 0 263 | mockResponses = [sampleRetryResponse, sampleErrorResponse, sampleResponse]; 264 | 265 | jest.setTimeout(10000); 266 | const sendRequestPromise = client.sendRequest(sampleRequest, sampleOptions) 267 | await sendRequestPromise.then(response => { 268 | expect(response).toEqual(sampleReturn); 269 | expect(mockRequestFuncion).toBeCalledTimes(3); 270 | expect(mockRequestFuncion).toBeCalledWith('get', 'https://github.com', undefined); 271 | }); 272 | }); 273 | }); -------------------------------------------------------------------------------- /src/azure/azHttpClient.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core'; 2 | import { AzCli } from "./azCli"; 3 | import { StatusCodes, WebRequest, WebResponse, sendRequest } from "../utils/httpClient"; 4 | import { PolicyDetails, PolicyRequest, PolicyResult, createPoliciesUsingIds } from './policyHelper' 5 | import { prettyDebugLog, splitArray } from '../utils/utilities' 6 | import { RoleRequest, assignRoles } from './roleAssignmentHelper' 7 | 8 | const SYNC_BATCH_CALL_SIZE = 20; 9 | const DEFINITION_SCOPE_SEPARATOR = "/providers/Microsoft.Authorization/policyDefinitions"; 10 | 11 | interface BatchRequest { 12 | name: string, 13 | httpMethod: string, 14 | url: string, 15 | content: any 16 | } 17 | 18 | interface BatchResponse { 19 | name: string, 20 | httpStatusCode: number, 21 | headers: any, 22 | content: any, 23 | contentLength: number 24 | } 25 | 26 | export class AzHttpClient { 27 | 28 | async initialize() { 29 | this.token = await AzCli.getAccessToken(); 30 | this.managementUrl = await AzCli.getManagementUrl(); 31 | this.batchCallUrl = `${this.managementUrl}/batch?api-version=${this.batchApiVersion}`; 32 | } 33 | 34 | /** 35 | * Gets all assignments of the provided policydefinition ids. 36 | * 37 | * @param policyDefinitionIds : PolicyDefinition Ids 38 | */ 39 | async getAllAssignments(policyDefinitionIds: string[]): Promise { 40 | let batchRequests: BatchRequest[] = []; 41 | 42 | policyDefinitionIds.forEach((policyDefinitionId, index) => { 43 | const policyBatchCallName = this.getPolicyBatchCallName(index); 44 | batchRequests.push({ 45 | url: this.getAllAssignmentsUrl(policyDefinitionId), 46 | name: policyBatchCallName, 47 | httpMethod: 'GET', 48 | content: undefined 49 | }); 50 | }); 51 | 52 | let batchResponses = await this.processBatchRequestSync(batchRequests); 53 | 54 | // We need to return response in the order of request. 55 | batchResponses.sort(this.compareBatchResponse); 56 | return batchResponses; 57 | } 58 | 59 | /** 60 | * For all policies, fetches policy from azure service and populates in the policy details. 61 | * 62 | * @param allPolicyDetails : All Policy Details 63 | */ 64 | async populateServicePolicies(allPolicyDetails: PolicyDetails[]) { 65 | const policies = allPolicyDetails.map(policyDetails => policyDetails.policyInCode); 66 | const batchResponses = await this.getBatchResponse(policies, 'GET'); 67 | 68 | if (allPolicyDetails.length != batchResponses.length) { 69 | throw Error(`Azure batch response count does not match batch request count`); 70 | } 71 | 72 | allPolicyDetails.forEach((policyDetails, index) => { 73 | policyDetails.policyInService = batchResponses[index].content; 74 | }); 75 | } 76 | 77 | async getPolicyDefintions(policyIds: string[]):Promise { 78 | const policies = createPoliciesUsingIds(policyIds); 79 | 80 | const batchResponses = await this.getBatchResponse(policies, 'GET'); 81 | if (policyIds.length != batchResponses.length) { 82 | throw Error(`Azure batch response count does not match batch request count`); 83 | } 84 | 85 | return batchResponses.map(response => response.content); 86 | } 87 | 88 | async addRoleAssinments(roleRequests: RoleRequest[]):Promise { 89 | let batchRequests: BatchRequest[] = []; 90 | 91 | roleRequests.forEach((roleRequest, index) => { 92 | const policyBatchCallName = this.getPolicyBatchCallName(index); 93 | batchRequests.push({ 94 | url: this.getRoleAssignmentUrl(roleRequest.scope, roleRequest.roleAssignmentId), 95 | name: policyBatchCallName, 96 | httpMethod: 'PUT', 97 | content: this.getRoleAssignmentBody(roleRequest) 98 | }); 99 | }); 100 | 101 | 102 | let batchResponses = await this.processBatchRequestSync(batchRequests); 103 | 104 | // We need to return response in the order of request. 105 | batchResponses.sort(this.compareBatchResponse); 106 | return batchResponses; 107 | } 108 | 109 | async upsertPolicyDefinitions(policyRequests: PolicyRequest[]): Promise { 110 | return this.upsertPolicies(policyRequests); 111 | } 112 | 113 | async upsertPolicyInitiatives(policyRequests: PolicyRequest[]): Promise { 114 | return this.upsertPolicies(policyRequests); 115 | } 116 | 117 | async upsertPolicyAssignments(policyRequests: PolicyRequest[], roleAssignmentResults: PolicyResult[]): Promise { 118 | const assignmentResponses = await this.upsertPolicies(policyRequests); 119 | 120 | // Now we need to add roles to managed identity for policy remediation. 121 | await assignRoles(policyRequests, assignmentResponses, roleAssignmentResults); 122 | 123 | return assignmentResponses; 124 | } 125 | 126 | async deletePolicies(policyIds: string[]): Promise { 127 | const policies = createPoliciesUsingIds(policyIds); 128 | 129 | const batchResponses = await this.getBatchResponse(policies, 'DELETE'); 130 | if (policyIds.length != batchResponses.length) { 131 | throw Error(`Azure batch response count does not match batch request count`); 132 | } 133 | 134 | return batchResponses; 135 | } 136 | 137 | /** 138 | * For given policy requests, create/update policy. Response of request is in order of request 139 | * So response at index i will be for policy request at index i. 140 | * 141 | * @param policyRequests : policy requests. 142 | */ 143 | private async upsertPolicies(policyRequests: PolicyRequest[]): Promise { 144 | const policies = policyRequests.map(policyRequest => policyRequest.policy); 145 | const batchResponses = await this.getBatchResponse(policies, 'PUT'); 146 | 147 | if (policyRequests.length != batchResponses.length) { 148 | throw Error(`Azure batch response count does not match batch request count`); 149 | } 150 | 151 | return batchResponses; 152 | } 153 | 154 | /** 155 | * For given policies, perfom the given method operation and return response in the order of request. 156 | * So response at index i will be for policy at index i. 157 | * 158 | * @param policies : All policies 159 | * @param method : method to be used for batch call 160 | */ 161 | private async getBatchResponse(policies: any[], method: string): Promise { 162 | let batchRequests: BatchRequest[] = []; 163 | 164 | policies.forEach((policy, index) => { 165 | const policyBatchCallName = this.getPolicyBatchCallName(index); 166 | 167 | batchRequests.push({ 168 | url: this.getResourceUrl(policy.id), 169 | name: policyBatchCallName, 170 | httpMethod: method, 171 | content: method == 'PUT' ? policy : undefined 172 | }); 173 | }); 174 | 175 | let batchResponses = await this.processBatchRequestSync(batchRequests); 176 | 177 | // We need to return response in the order of request. 178 | batchResponses.sort(this.compareBatchResponse); 179 | return batchResponses; 180 | } 181 | 182 | async processBatchRequestSync(batchRequests: BatchRequest[]): Promise { 183 | let batchResponses: BatchResponse[] = []; 184 | 185 | if (batchRequests.length == 0) { 186 | return Promise.resolve([]); 187 | } 188 | 189 | // For sync implementation we will divide into chunks of 20 requests. 190 | const batchRequestsChunks: BatchRequest[][] = splitArray(batchRequests, SYNC_BATCH_CALL_SIZE); 191 | 192 | for (const batchRequests of batchRequestsChunks) { 193 | const payload: any = { requests: batchRequests }; 194 | 195 | try { 196 | let response = await this.sendRequest(this.batchCallUrl, 'POST', payload); 197 | 198 | if (response.statusCode == StatusCodes.OK) { 199 | batchResponses.push(...response.body.responses); 200 | } 201 | else { 202 | return Promise.reject( 203 | `An error occured while fetching the batch result. StatusCode: ${response.statusCode}, Body: ${JSON.stringify(response.body)}` 204 | ); 205 | } 206 | } 207 | catch (error) { 208 | return Promise.reject(error); 209 | } 210 | } 211 | 212 | prettyDebugLog(`Status of batch calls:`); 213 | batchResponses.forEach(response => { 214 | core.debug(`Name : ${response.name}. Status : ${response.httpStatusCode}`); 215 | }); 216 | prettyDebugLog(`End`); 217 | 218 | return batchResponses; 219 | } 220 | 221 | private async sendRequest(url: string, method: string, payload: any): Promise { 222 | 223 | let webRequest = new WebRequest(); 224 | webRequest.method = method; 225 | webRequest.uri = url; 226 | webRequest.headers = { 227 | "Authorization": `Bearer ${this.token}`, 228 | "Content-Type": "application/json; charset=utf-8", 229 | "User-Agent": `${process.env.AZURE_HTTP_USER_AGENT}` 230 | }; 231 | 232 | if(payload) { 233 | webRequest.body = JSON.stringify(payload); 234 | } 235 | 236 | return sendRequest(webRequest); 237 | } 238 | 239 | private getResourceUrl(resourceId: string): string { 240 | return `${this.managementUrl}${resourceId}?api-version=${this.apiVersion}`; 241 | } 242 | 243 | private getRoleAssignmentUrl(scope: string, roleAssignmentId: string): string { 244 | return `${this.managementUrl}${scope}/providers/Microsoft.Authorization/roleAssignments/${roleAssignmentId}?api-version=${this.roleApiVersion}` 245 | } 246 | 247 | private getRoleAssignmentBody(roleRequest: RoleRequest): any { 248 | return { 249 | properties : { 250 | roleDefinitionId: `${roleRequest.scope}/providers/Microsoft.Authorization/roleDefinitions/${roleRequest.roleDefinitionId}`, 251 | principalType: "ServicePrincipal", 252 | principalId: roleRequest.principalId 253 | } 254 | } 255 | } 256 | 257 | private getAllAssignmentsUrl(policyDefinitionId: string): string { 258 | const definitionScope = policyDefinitionId.split(DEFINITION_SCOPE_SEPARATOR)[0]; 259 | return `${this.managementUrl}${definitionScope}/providers/Microsoft.Authorization/policyAssignments?api-version=${this.apiVersion}&$filter=policyDefinitionId eq '${policyDefinitionId}'`; 260 | } 261 | 262 | private compareBatchResponse(response1: BatchResponse, response2: BatchResponse): number { 263 | return parseInt(response1.name) - parseInt(response2.name); 264 | } 265 | 266 | private getPolicyBatchCallName(index: number) { 267 | return `${index}`; 268 | } 269 | 270 | private token: string; 271 | private managementUrl: string; 272 | private apiVersion: string = '2021-06-01'; 273 | private batchApiVersion: string = '2020-06-01'; 274 | private roleApiVersion: string = '2019-04-01-preview'; 275 | private batchCallUrl: string; 276 | } -------------------------------------------------------------------------------- /lib/azure/azHttpClient.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { 3 | function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } 4 | return new (P || (P = Promise))(function (resolve, reject) { 5 | function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } 6 | function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } 7 | function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } 8 | step((generator = generator.apply(thisArg, _arguments || [])).next()); 9 | }); 10 | }; 11 | Object.defineProperty(exports, "__esModule", { value: true }); 12 | exports.AzHttpClient = void 0; 13 | const core = require("@actions/core"); 14 | const azCli_1 = require("./azCli"); 15 | const httpClient_1 = require("../utils/httpClient"); 16 | const policyHelper_1 = require("./policyHelper"); 17 | const utilities_1 = require("../utils/utilities"); 18 | const roleAssignmentHelper_1 = require("./roleAssignmentHelper"); 19 | const SYNC_BATCH_CALL_SIZE = 20; 20 | const DEFINITION_SCOPE_SEPARATOR = "/providers/Microsoft.Authorization/policyDefinitions"; 21 | class AzHttpClient { 22 | constructor() { 23 | this.apiVersion = '2021-06-01'; 24 | this.batchApiVersion = '2020-06-01'; 25 | this.roleApiVersion = '2019-04-01-preview'; 26 | } 27 | initialize() { 28 | return __awaiter(this, void 0, void 0, function* () { 29 | this.token = yield azCli_1.AzCli.getAccessToken(); 30 | this.managementUrl = yield azCli_1.AzCli.getManagementUrl(); 31 | this.batchCallUrl = `${this.managementUrl}/batch?api-version=${this.batchApiVersion}`; 32 | }); 33 | } 34 | /** 35 | * Gets all assignments of the provided policydefinition ids. 36 | * 37 | * @param policyDefinitionIds : PolicyDefinition Ids 38 | */ 39 | getAllAssignments(policyDefinitionIds) { 40 | return __awaiter(this, void 0, void 0, function* () { 41 | let batchRequests = []; 42 | policyDefinitionIds.forEach((policyDefinitionId, index) => { 43 | const policyBatchCallName = this.getPolicyBatchCallName(index); 44 | batchRequests.push({ 45 | url: this.getAllAssignmentsUrl(policyDefinitionId), 46 | name: policyBatchCallName, 47 | httpMethod: 'GET', 48 | content: undefined 49 | }); 50 | }); 51 | let batchResponses = yield this.processBatchRequestSync(batchRequests); 52 | // We need to return response in the order of request. 53 | batchResponses.sort(this.compareBatchResponse); 54 | return batchResponses; 55 | }); 56 | } 57 | /** 58 | * For all policies, fetches policy from azure service and populates in the policy details. 59 | * 60 | * @param allPolicyDetails : All Policy Details 61 | */ 62 | populateServicePolicies(allPolicyDetails) { 63 | return __awaiter(this, void 0, void 0, function* () { 64 | const policies = allPolicyDetails.map(policyDetails => policyDetails.policyInCode); 65 | const batchResponses = yield this.getBatchResponse(policies, 'GET'); 66 | if (allPolicyDetails.length != batchResponses.length) { 67 | throw Error(`Azure batch response count does not match batch request count`); 68 | } 69 | allPolicyDetails.forEach((policyDetails, index) => { 70 | policyDetails.policyInService = batchResponses[index].content; 71 | }); 72 | }); 73 | } 74 | getPolicyDefintions(policyIds) { 75 | return __awaiter(this, void 0, void 0, function* () { 76 | const policies = policyHelper_1.createPoliciesUsingIds(policyIds); 77 | const batchResponses = yield this.getBatchResponse(policies, 'GET'); 78 | if (policyIds.length != batchResponses.length) { 79 | throw Error(`Azure batch response count does not match batch request count`); 80 | } 81 | return batchResponses.map(response => response.content); 82 | }); 83 | } 84 | addRoleAssinments(roleRequests) { 85 | return __awaiter(this, void 0, void 0, function* () { 86 | let batchRequests = []; 87 | roleRequests.forEach((roleRequest, index) => { 88 | const policyBatchCallName = this.getPolicyBatchCallName(index); 89 | batchRequests.push({ 90 | url: this.getRoleAssignmentUrl(roleRequest.scope, roleRequest.roleAssignmentId), 91 | name: policyBatchCallName, 92 | httpMethod: 'PUT', 93 | content: this.getRoleAssignmentBody(roleRequest) 94 | }); 95 | }); 96 | let batchResponses = yield this.processBatchRequestSync(batchRequests); 97 | // We need to return response in the order of request. 98 | batchResponses.sort(this.compareBatchResponse); 99 | return batchResponses; 100 | }); 101 | } 102 | upsertPolicyDefinitions(policyRequests) { 103 | return __awaiter(this, void 0, void 0, function* () { 104 | return this.upsertPolicies(policyRequests); 105 | }); 106 | } 107 | upsertPolicyInitiatives(policyRequests) { 108 | return __awaiter(this, void 0, void 0, function* () { 109 | return this.upsertPolicies(policyRequests); 110 | }); 111 | } 112 | upsertPolicyAssignments(policyRequests, roleAssignmentResults) { 113 | return __awaiter(this, void 0, void 0, function* () { 114 | const assignmentResponses = yield this.upsertPolicies(policyRequests); 115 | // Now we need to add roles to managed identity for policy remediation. 116 | yield roleAssignmentHelper_1.assignRoles(policyRequests, assignmentResponses, roleAssignmentResults); 117 | return assignmentResponses; 118 | }); 119 | } 120 | deletePolicies(policyIds) { 121 | return __awaiter(this, void 0, void 0, function* () { 122 | const policies = policyHelper_1.createPoliciesUsingIds(policyIds); 123 | const batchResponses = yield this.getBatchResponse(policies, 'DELETE'); 124 | if (policyIds.length != batchResponses.length) { 125 | throw Error(`Azure batch response count does not match batch request count`); 126 | } 127 | return batchResponses; 128 | }); 129 | } 130 | /** 131 | * For given policy requests, create/update policy. Response of request is in order of request 132 | * So response at index i will be for policy request at index i. 133 | * 134 | * @param policyRequests : policy requests. 135 | */ 136 | upsertPolicies(policyRequests) { 137 | return __awaiter(this, void 0, void 0, function* () { 138 | const policies = policyRequests.map(policyRequest => policyRequest.policy); 139 | const batchResponses = yield this.getBatchResponse(policies, 'PUT'); 140 | if (policyRequests.length != batchResponses.length) { 141 | throw Error(`Azure batch response count does not match batch request count`); 142 | } 143 | return batchResponses; 144 | }); 145 | } 146 | /** 147 | * For given policies, perfom the given method operation and return response in the order of request. 148 | * So response at index i will be for policy at index i. 149 | * 150 | * @param policies : All policies 151 | * @param method : method to be used for batch call 152 | */ 153 | getBatchResponse(policies, method) { 154 | return __awaiter(this, void 0, void 0, function* () { 155 | let batchRequests = []; 156 | policies.forEach((policy, index) => { 157 | const policyBatchCallName = this.getPolicyBatchCallName(index); 158 | batchRequests.push({ 159 | url: this.getResourceUrl(policy.id), 160 | name: policyBatchCallName, 161 | httpMethod: method, 162 | content: method == 'PUT' ? policy : undefined 163 | }); 164 | }); 165 | let batchResponses = yield this.processBatchRequestSync(batchRequests); 166 | // We need to return response in the order of request. 167 | batchResponses.sort(this.compareBatchResponse); 168 | return batchResponses; 169 | }); 170 | } 171 | processBatchRequestSync(batchRequests) { 172 | return __awaiter(this, void 0, void 0, function* () { 173 | let batchResponses = []; 174 | if (batchRequests.length == 0) { 175 | return Promise.resolve([]); 176 | } 177 | // For sync implementation we will divide into chunks of 20 requests. 178 | const batchRequestsChunks = utilities_1.splitArray(batchRequests, SYNC_BATCH_CALL_SIZE); 179 | for (const batchRequests of batchRequestsChunks) { 180 | const payload = { requests: batchRequests }; 181 | try { 182 | let response = yield this.sendRequest(this.batchCallUrl, 'POST', payload); 183 | if (response.statusCode == httpClient_1.StatusCodes.OK) { 184 | batchResponses.push(...response.body.responses); 185 | } 186 | else { 187 | return Promise.reject(`An error occured while fetching the batch result. StatusCode: ${response.statusCode}, Body: ${JSON.stringify(response.body)}`); 188 | } 189 | } 190 | catch (error) { 191 | return Promise.reject(error); 192 | } 193 | } 194 | utilities_1.prettyDebugLog(`Status of batch calls:`); 195 | batchResponses.forEach(response => { 196 | core.debug(`Name : ${response.name}. Status : ${response.httpStatusCode}`); 197 | }); 198 | utilities_1.prettyDebugLog(`End`); 199 | return batchResponses; 200 | }); 201 | } 202 | sendRequest(url, method, payload) { 203 | return __awaiter(this, void 0, void 0, function* () { 204 | let webRequest = new httpClient_1.WebRequest(); 205 | webRequest.method = method; 206 | webRequest.uri = url; 207 | webRequest.headers = { 208 | "Authorization": `Bearer ${this.token}`, 209 | "Content-Type": "application/json; charset=utf-8", 210 | "User-Agent": `${process.env.AZURE_HTTP_USER_AGENT}` 211 | }; 212 | if (payload) { 213 | webRequest.body = JSON.stringify(payload); 214 | } 215 | return httpClient_1.sendRequest(webRequest); 216 | }); 217 | } 218 | getResourceUrl(resourceId) { 219 | return `${this.managementUrl}${resourceId}?api-version=${this.apiVersion}`; 220 | } 221 | getRoleAssignmentUrl(scope, roleAssignmentId) { 222 | return `${this.managementUrl}${scope}/providers/Microsoft.Authorization/roleAssignments/${roleAssignmentId}?api-version=${this.roleApiVersion}`; 223 | } 224 | getRoleAssignmentBody(roleRequest) { 225 | return { 226 | properties: { 227 | roleDefinitionId: `${roleRequest.scope}/providers/Microsoft.Authorization/roleDefinitions/${roleRequest.roleDefinitionId}`, 228 | principalType: "ServicePrincipal", 229 | principalId: roleRequest.principalId 230 | } 231 | }; 232 | } 233 | getAllAssignmentsUrl(policyDefinitionId) { 234 | const definitionScope = policyDefinitionId.split(DEFINITION_SCOPE_SEPARATOR)[0]; 235 | return `${this.managementUrl}${definitionScope}/providers/Microsoft.Authorization/policyAssignments?api-version=${this.apiVersion}&$filter=policyDefinitionId eq '${policyDefinitionId}'`; 236 | } 237 | compareBatchResponse(response1, response2) { 238 | return parseInt(response1.name) - parseInt(response2.name); 239 | } 240 | getPolicyBatchCallName(index) { 241 | return `${index}`; 242 | } 243 | } 244 | exports.AzHttpClient = AzHttpClient; 245 | -------------------------------------------------------------------------------- /__tests__/azure.azHttpClient.test.ts: -------------------------------------------------------------------------------- 1 | import * as exec from '@actions/exec'; 2 | import * as core from '@actions/core'; 3 | import { 4 | AzHttpClient 5 | } from '../src/azure/azHttpClient'; 6 | import { 7 | PolicyDetails, 8 | PolicyRequest 9 | } from '../src/azure/policyHelper'; 10 | import { 11 | RoleRequest 12 | } from '../src/azure/roleAssignmentHelper'; 13 | import * as httpClient from '../src/utils/httpClient'; 14 | 15 | describe('Testing all functions in azHttpClient file', () => { 16 | var testClient; 17 | beforeAll(async () => { 18 | jest.spyOn(exec, 'exec').mockImplementation(async (command, args, options) => { 19 | options.listeners.stdout(Buffer.from(JSON.stringify({ 20 | "accessToken": "token" 21 | }))); 22 | return 0; 23 | }); 24 | jest.spyOn(console, 'log').mockImplementation(); 25 | jest.spyOn(core, 'debug').mockImplementation(); 26 | 27 | testClient = new AzHttpClient(); 28 | await testClient.initialize(); 29 | }); 30 | 31 | test('getAllAssignments() - return empty array if no ids provided', async () => { 32 | expect(await testClient.getAllAssignments([])).toMatchObject([]); 33 | }); 34 | 35 | test('getAllAssignments() - return all assignments of the provided policydefinition id', async () => { 36 | jest.spyOn(httpClient, 'sendRequest').mockResolvedValue({ 37 | statusCode: 200, 38 | statusMessage: 'success', 39 | headers: {}, 40 | body: { 41 | responses: [{}, {}, {}, {}] 42 | } 43 | }); 44 | jest.spyOn(console, 'log').mockImplementation(); 45 | jest.spyOn(core, 'debug').mockImplementation(); 46 | const policyIds = [ 47 | 'providers/Microsoft.Management/managementgroups/abcdef/providers/Microsoft.Authorization/policySetDefinitions/12', 48 | 'providers/Microsoft.Management/managementgroups/abcdef/providers/Microsoft.Authorization/policySetDefinitions/34', 49 | 'providers/Microsoft.Management/managementgroups/abcdef/providers/Microsoft.Authorization/policySetDefinitions/56', 50 | 'providers/Microsoft.Management/managementgroups/abcdef/providers/Microsoft.Authorization/policySetDefinitions/78', 51 | ] 52 | 53 | expect(await testClient.getAllAssignments(policyIds)).toMatchObject([{}, {}, {}, {}]); 54 | }); 55 | 56 | test('getAllAssignments() - reject if response status code is not ok', async () => { 57 | jest.spyOn(httpClient, 'sendRequest').mockResolvedValue({ 58 | statusCode: 404, 59 | statusMessage: 'failed', 60 | headers: {}, 61 | body: { 62 | message: 'Failed to reach server.' 63 | } 64 | }); 65 | jest.spyOn(console, 'log').mockImplementation(); 66 | jest.spyOn(core, 'debug').mockImplementation(); 67 | const policyIds = [ 68 | 'providers/Microsoft.Management/managementgroups/abcdef/providers/Microsoft.Authorization/policySetDefinitions/12', 69 | ] 70 | 71 | await expect(testClient.getAllAssignments(policyIds)).rejects.toBe(`An error occured while fetching the batch result. StatusCode: 404, Body: ${JSON.stringify({message: 'Failed to reach server.'})}`); 72 | }); 73 | 74 | test('populateServicePolicies() - fetch policy from azure service and populate in the policy details', async () => { 75 | jest.spyOn(httpClient, 'sendRequest').mockResolvedValue({ 76 | statusCode: 200, 77 | statusMessage: 'success', 78 | headers: {}, 79 | body: { 80 | responses: [{ 81 | content: 'policyDetails' 82 | }, { 83 | content: 'policyDetails' 84 | }, { 85 | content: 'policyDetails' 86 | }, { 87 | content: 'policyDetails' 88 | }] 89 | } 90 | }); 91 | jest.spyOn(console, 'log').mockImplementation(); 92 | jest.spyOn(core, 'debug').mockImplementation(); 93 | const policies = [{ 94 | policyInCode: { 95 | id: 'providers/Microsoft.Management/managementgroups/abcdef/providers/Microsoft.Authorization/policySetDefinitions/12' 96 | }, 97 | }, { 98 | policyInCode: { 99 | id: 'providers/Microsoft.Management/managementgroups/abcdef/providers/Microsoft.Authorization/policySetDefinitions/34' 100 | }, 101 | }, { 102 | policyInCode: { 103 | id: 'providers/Microsoft.Management/managementgroups/abcdef/providers/Microsoft.Authorization/policySetDefinitions/56' 104 | }, 105 | }, { 106 | policyInCode: { 107 | id: 'providers/Microsoft.Management/managementgroups/abcdef/providers/Microsoft.Authorization/policySetDefinitions/78' 108 | }, 109 | }] as PolicyDetails[]; 110 | 111 | expect(await testClient.populateServicePolicies(policies)).toBeUndefined(); 112 | policies.forEach(policy => expect(policy.policyInService).toBe('policyDetails')); 113 | }); 114 | 115 | test('populateServicePolicies() - throw error if response doesn\'t have same length', async () => { 116 | jest.spyOn(httpClient, 'sendRequest').mockResolvedValue({ 117 | statusCode: 200, 118 | statusMessage: 'success', 119 | headers: {}, 120 | body: { 121 | responses: [{ 122 | content: 'policyDetails' 123 | }, { 124 | content: 'policyDetails' 125 | }, { 126 | content: 'policyDetails' 127 | }] 128 | } 129 | }); 130 | jest.spyOn(console, 'log').mockImplementation(); 131 | jest.spyOn(core, 'debug').mockImplementation(); 132 | const policies = [{ 133 | policyInCode: { 134 | id: 'providers/Microsoft.Management/managementgroups/abcdef/providers/Microsoft.Authorization/policySetDefinitions/12' 135 | }, 136 | }, { 137 | policyInCode: { 138 | id: 'providers/Microsoft.Management/managementgroups/abcdef/providers/Microsoft.Authorization/policySetDefinitions/34' 139 | }, 140 | }] as PolicyDetails[]; 141 | 142 | await expect(testClient.populateServicePolicies(policies)).rejects.toThrow('Azure batch response count does not match batch request count'); 143 | }); 144 | 145 | test('getAllAssignments() - return all assignments of the provided policydefinition id', async () => { 146 | jest.spyOn(httpClient, 'sendRequest').mockResolvedValue({ 147 | statusCode: 200, 148 | statusMessage: 'success', 149 | headers: {}, 150 | body: { 151 | responses: [{ 152 | content: 'policyDetails' 153 | }, { 154 | content: 'policyDetails' 155 | }, { 156 | content: 'policyDetails' 157 | }, { 158 | content: 'policyDetails' 159 | }] 160 | } 161 | }); 162 | jest.spyOn(console, 'log').mockImplementation(); 163 | jest.spyOn(core, 'debug').mockImplementation(); 164 | const policyIds = [ 165 | 'providers/Microsoft.Management/managementgroups/abcdef/providers/Microsoft.Authorization/policySetDefinitions/12', 166 | 'providers/Microsoft.Management/managementgroups/abcdef/providers/Microsoft.Authorization/policySetDefinitions/34', 167 | 'providers/Microsoft.Management/managementgroups/abcdef/providers/Microsoft.Authorization/policySetDefinitions/56', 168 | 'providers/Microsoft.Management/managementgroups/abcdef/providers/Microsoft.Authorization/policySetDefinitions/78', 169 | ] 170 | 171 | expect(await testClient.getPolicyDefintions(policyIds)).toMatchObject(['policyDetails', 'policyDetails', 'policyDetails', 'policyDetails']); 172 | }); 173 | 174 | test('getAllAssignments() - throw error if response doesn\'t have same length', async () => { 175 | jest.spyOn(httpClient, 'sendRequest').mockResolvedValue({ 176 | statusCode: 200, 177 | statusMessage: 'success', 178 | headers: {}, 179 | body: { 180 | responses: [{ 181 | content: 'policyDetails' 182 | }] 183 | } 184 | }); 185 | jest.spyOn(console, 'log').mockImplementation(); 186 | jest.spyOn(core, 'debug').mockImplementation(); 187 | const policyIds = [ 188 | 'providers/Microsoft.Management/managementgroups/abcdef/providers/Microsoft.Authorization/policySetDefinitions/12', 189 | 'providers/Microsoft.Management/managementgroups/abcdef/providers/Microsoft.Authorization/policySetDefinitions/78', 190 | ] 191 | 192 | await expect(testClient.getPolicyDefintions(policyIds)).rejects.toThrow(''); 193 | }); 194 | 195 | test('addRoleAssinments() - add role assignments', async () => { 196 | jest.spyOn(httpClient, 'sendRequest').mockResolvedValue({ 197 | statusCode: 200, 198 | statusMessage: 'success', 199 | headers: {}, 200 | body: { 201 | responses: [{}, {}, {}, {}] 202 | } 203 | }); 204 | jest.spyOn(console, 'log').mockImplementation(); 205 | jest.spyOn(core, 'debug').mockImplementation(); 206 | const roleRequests = [{ 207 | scope: 'subscriptions/12/resourcegroups/my-rg/', 208 | roleAssignmentId: 'providers/Microsoft.Authorization/roleAssignments/ab', 209 | principalId: 'abcd' 210 | }, { 211 | scope: 'subscriptions/12/resourcegroups/my-rg/', 212 | roleAssignmentId: 'providers/Microsoft.Authorization/roleAssignments/ab', 213 | principalId: 'efgh' 214 | }, { 215 | scope: 'subscriptions/12/resourcegroups/my-rg/', 216 | roleAssignmentId: 'providers/Microsoft.Authorization/roleAssignments/ab', 217 | principalId: 'hijk' 218 | }, { 219 | scope: 'subscriptions/12/resourcegroups/my-rg/', 220 | roleAssignmentId: 'providers/Microsoft.Authorization/roleAssignments/ab', 221 | principalId: 'lmno' 222 | }] as RoleRequest[]; 223 | 224 | expect(await testClient.addRoleAssinments(roleRequests)).toMatchObject([{}, {}, {}, {}]); 225 | }); 226 | 227 | test('upsertPolicyDefinitions() - insert or update policy definition', async () => { 228 | jest.spyOn(httpClient, 'sendRequest').mockResolvedValue({ 229 | statusCode: 200, 230 | statusMessage: 'success', 231 | headers: {}, 232 | body: { 233 | responses: [{}, {}] 234 | } 235 | }); 236 | jest.spyOn(console, 'log').mockImplementation(); 237 | jest.spyOn(core, 'debug').mockImplementation(); 238 | const policyIds = [{ 239 | policy: 'providers/Microsoft.Management/managementgroups/abcdef/providers/Microsoft.Authorization/policySetDefinitions/12', 240 | 241 | }, { 242 | policy: 'providers/Microsoft.Management/managementgroups/abcdef/providers/Microsoft.Authorization/policySetDefinitions/78', 243 | 244 | }] as PolicyRequest[]; 245 | 246 | expect(await testClient.upsertPolicyDefinitions(policyIds)).toMatchObject([{}, {}]); 247 | }); 248 | 249 | test('upsertPolicyDefinitions() - throw if response doesn\'t have same length', async () => { 250 | jest.spyOn(httpClient, 'sendRequest').mockResolvedValue({ 251 | statusCode: 200, 252 | statusMessage: 'success', 253 | headers: {}, 254 | body: { 255 | responses: [{}, {}] 256 | } 257 | }); 258 | jest.spyOn(console, 'log').mockImplementation(); 259 | jest.spyOn(core, 'debug').mockImplementation(); 260 | const policyIds = [{ 261 | policy: 'providers/Microsoft.Management/managementgroups/abcdef/providers/Microsoft.Authorization/policySetDefinitions/12', 262 | }] as PolicyRequest[]; 263 | 264 | await expect(testClient.upsertPolicyDefinitions(policyIds)).rejects.toThrow('Azure batch response count does not match batch request count'); 265 | }); 266 | 267 | test('upsertPolicyInitiatives() - insert or update policy definition', async () => { 268 | jest.spyOn(httpClient, 'sendRequest').mockResolvedValue({ 269 | statusCode: 200, 270 | statusMessage: 'success', 271 | headers: {}, 272 | body: { 273 | responses: [{}, {}] 274 | } 275 | }); 276 | const policyIds = [{ 277 | policy: 'providers/Microsoft.Management/managementgroups/abcdef/providers/Microsoft.Authorization/policySetDefinitions/12', 278 | 279 | }, { 280 | policy: 'providers/Microsoft.Management/managementgroups/abcdef/providers/Microsoft.Authorization/policySetDefinitions/78', 281 | 282 | }] as PolicyRequest[]; 283 | 284 | expect(await testClient.upsertPolicyDefinitions(policyIds)).toMatchObject([{}, {}]); 285 | }); 286 | 287 | test('deletePolicies() - delete a policy using id', async () => { 288 | jest.spyOn(httpClient, 'sendRequest').mockResolvedValue({ 289 | statusCode: 200, 290 | statusMessage: 'success', 291 | headers: {}, 292 | body: { 293 | responses: [{}, {}] 294 | } 295 | }); 296 | const policyIds = [ 297 | 'providers/Microsoft.Management/managementgroups/abcdef/providers/Microsoft.Authorization/policySetDefinitions/12', 298 | 'providers/Microsoft.Management/managementgroups/abcdef/providers/Microsoft.Authorization/policySetDefinitions/78', 299 | ]; 300 | 301 | expect(await testClient.deletePolicies(policyIds)).toMatchObject([{}, {}]); 302 | }); 303 | 304 | test('deletePolicies() - throw if number of response objects is not equal to number of request objects', async () => { 305 | jest.spyOn(httpClient, 'sendRequest').mockResolvedValue({ 306 | statusCode: 200, 307 | statusMessage: 'success', 308 | headers: {}, 309 | body: { 310 | responses: [{}, {}, {}, {}] 311 | } 312 | }); 313 | const policyIds = [ 314 | 'providers/Microsoft.Management/managementgroups/abcdef/providers/Microsoft.Authorization/policySetDefinitions/12', 315 | 'providers/Microsoft.Management/managementgroups/abcdef/providers/Microsoft.Authorization/policySetDefinitions/78', 316 | ]; 317 | 318 | await expect(testClient.deletePolicies(policyIds)).rejects.toThrow('Azure batch response count does not match batch request count'); 319 | }); 320 | }); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Deprecation notice 2 | 3 | This Action is deprecated. Instead, one can use [azure/cli@v1 action](https://github.com/Azure/cli) and pass a custom script to it to access [azure policy](https://learn.microsoft.com/en-in/azure/developer/github/manage-azure-policy). 4 | 5 | # Manage Azure Policy Action 6 | 7 | With Manage Azure Policy Action you can now create or update Azure policies from your GitHub Workflows. Since workflows are totally customizable, you can have a complete control over the sequence in which Azure policies are rolled out. Its now even easier to follow safe deployment practices and catch regressions or bugs well before policies are applied on critical resources. 8 | 9 | New to Azure Policy? Its an Azure service that lets you enforce organizational standards and asses compliance at scale. To know more check out: [Azure Policies - Overview](https://docs.microsoft.com/en-us/azure/governance/policy/overview) 10 | 11 | The definition of this Github Action is in [action.yml](https://github.com/Azure/manage-azure-policy/blob/v0/action.yml) 12 | 13 | 14 | # Pre-requisites: 15 | * Azure Login Action: Authenticate using [Azure Login](https://github.com/Azure/login) action. The Manage Azure Policy action assumes that Azure Login is done using an Azure service principal that has [sufficient permissions](https://docs.microsoft.com/en-us/azure/governance/policy/overview#rbac-permissions-in-azure-policy) to write policy on selected scopes. Once login is done, the next set of actions in the workflow can perform tasks such as creating policies or updating them. For more details on permissions, checkout 'Configure credentials for Azure login action' section in this page or alternatively you can refer the full [documentation](https://github.com/Azure/login) of Azure Login Action. 16 | * Azure Checkout Action: All policies files should be downloaded from the GitHub repository to the GitHub runner. You can use [checkout action](https://github.com/actions/checkout) for doing so. Refer the 'End-to-End Sample Workflows' section in this page for examples. 17 | * Azure Policy files should be present in the following directory structure. You can also export policies from Azure portal. (Go to _Definitions_ section in Azure Policy and Click on _Export definitions_ button) 18 | 19 | 20 | 21 | ```yaml 22 | . 23 | | 24 | |- policies/ ____________________________ # Root folder for policies 25 | | |- / ___________________ # Subfolder for a policy 26 | | |- policy.json _____________________ # Policy definition 27 | | |- assign..json _____________ # Assignment1 for the policy definition in this folder 28 | | |- assign..json _____________ # Assignment2 for the policy definition in this folder 29 | | |- assign..json _____________ # Assignment3 for the policy definition in this folder 30 | | 31 | | |- / ___________________ # Subfolder for another policy 32 | | |- policy.json _____________________ # Policy definition 33 | | |- assign..json _____________ # Assignment1 for the policy definition in this folder 34 | | |- assign..json _____________ # Assignment2 for the policy definition in this folder 35 | | |- assign..json _____________ # Assignment3 for the policy definition in this folder 36 | | |- assign..json _____________ # Assignment4 for the policy definition in this folder 37 | | |- assign..json _____________ # Assignment5 for the policy definition in this folder 38 | 39 | 40 | ``` 41 | 42 | 43 | 44 | # Inputs for the Action 45 | 46 | * `paths`: mandatory. The path(s) to the directory that contains Azure policy files. The files present only in these directories will be considered by this action for updating policies in Azure. You can use wild card characters as mentioned * or ** for specifying sub folders in a path. For more details on the use of the wild cards check [glob wildcard patterns](https://github.com/isaacs/node-glob#glob-primer). Note that a definition file should be named as _'policy.json'_ and assignment filenames should start with _'assign'_ keyword. 47 | * `ignore-paths`: Optional. These are the directory paths that will be ignored by the action. If you have a specific policy folder that is not ready to be applied yet, specify the path here. Note that ignore-paths has a higher precedence compared to `paths` parameter. 48 | * `assignments`: Optional. These are policy assignment files that would be considered by the action. This parameter is especially useful if you want to apply only those assignments that correspond to a specific environment for following a safe deployment practice. E.g. _assign.AllowedVMSKUs-dev-rg.json_. You can use wild card character '*' to match multiple file names. E.g. _assign.\*dev\*.json_. If this parameter is not specified, the action will consider all assignment files that are present in the directories mentioned in `paths` parameter. 49 | * `mode`: Optional. There are 2 modes for this action - _incremental_ and _complete_. If not specified, the action will use incremental mode by default. In incremental mode, the action will compare already exisiting policy in azure with the contents of policy provided in repository file. It will apply the policy only if there is a mismatch. On the contrary, the complete mode will apply all the files present in the specified paths irrespective of whether or not repository policy file has been updated. 50 | 51 | 52 | # End-to-End Sample Workflows 53 | 54 | 55 | ### Sample workflow to apply all policy file changes in a given directory to Azure Policy 56 | 57 | 58 | ```yaml 59 | # File: .github/workflows/workflow.yml 60 | 61 | on: push 62 | 63 | jobs: 64 | apply-azure-policy: 65 | runs-on: ubuntu-latest 66 | steps: 67 | # Azure Login 68 | - name: Login to Azure 69 | uses: azure/login@v1 70 | with: 71 | creds: ${{secrets.AZURE_CREDENTIALS}} 72 | 73 | - name: Checkout 74 | uses: actions/checkout@v2 75 | 76 | - name: Create or Update Azure Policies 77 | uses: azure/manage-azure-policy@v0 78 | with: 79 | paths: | 80 | policies/** 81 | 82 | ``` 83 | The above workflow will apply policy files changees in policies/** ([see pattern syntax](https://github.com/isaacs/node-glob#glob-primer)) directory to Azure Policy. 84 | 85 | 86 | ### Sample workflow to apply only a subset of assignments from a given directory to Azure Policy 87 | 88 | 89 | ```yaml 90 | # File: .github/workflows/workflow.yml 91 | 92 | on: push 93 | 94 | jobs: 95 | apply-azure-policy: 96 | runs-on: ubuntu-latest 97 | steps: 98 | # Azure Login 99 | - name: Login to Azure 100 | uses: azure/login@v1 101 | with: 102 | creds: ${{secrets.AZURE_CREDENTIALS}} 103 | 104 | - name: Checkout 105 | uses: actions/checkout@v2 106 | 107 | - name: Create or Update Azure Policies 108 | uses: azure/manage-azure-policy@v0 109 | with: 110 | paths: | 111 | policies/** 112 | assignments: | 113 | assign.*_testRG_*.json 114 | 115 | ``` 116 | The above workflow will apply policy files changes only in policies/** directory. For each directory, the action will first apply the definition and then assignments that have 'testRG' in their filename. This assignment field is especially useful for risk mitigation scenarios, where you first want to apply assignments corresponding to a specific environment like 'test'. 117 | 118 | 119 | ### Sample workflow to apply policies at Management Group scope 120 | 121 | ```yaml 122 | # File: .github/workflows/workflow.yml 123 | 124 | on: push 125 | 126 | jobs: 127 | apply-azure-policy: 128 | runs-on: ubuntu-latest 129 | steps: 130 | # Azure Login at management group scope requires allow-no-subscriptions to be set to true 131 | - name: Login to Azure 132 | uses: azure/login@v1 133 | with: 134 | creds: ${{secrets.AZURE_CREDENTIALS}} 135 | allow-no-subscriptions: true 136 | 137 | - name: Checkout 138 | uses: actions/checkout@v2 139 | 140 | - name: Create or Update Azure Policies 141 | uses: azure/manage-azure-policy@v0 142 | with: 143 | paths: | 144 | policies/** 145 | 146 | ``` 147 | For deploying policies or initiatives at a management group level, the azure login action should have input `allow-no-subscriptions` set to true. 148 | 149 | Manage Azure Policy Action is supported for the Azure public cloud as well as Azure government clouds ('AzureUSGovernment' or 'AzureChinaCloud') and Azure Stack ('AzureStack') Hub. Before running this action, login to the respective Azure Cloud using [Azure Login](https://github.com/Azure/login) by setting appropriate value for the `environment` parameter. 150 | 151 | 152 | # Quickstart Video Tutorials: 153 | 1. [Export Azure Policy resources to GitHub Repository](https://aka.ms/pac-yvideo-export) 154 | 2. [Deploy Azure Policies with GitHub workflows](https://aka.ms/pac-yvideo-rollout-policy) 155 | 156 | 157 | 158 | # Configure credentials for Azure login action: 159 | 160 | With the Azure login Action, you can perform an Azure login using [Azure service principal](https://docs.microsoft.com/en-us/azure/active-directory/develop/app-objects-and-service-principals). The credentials of Azure Service Principal can be added as [secrets](https://help.github.com/en/articles/virtual-environments-for-github-actions#creating-and-using-secrets-encrypted-variables) in the GitHub repository and then used in the workflow. Follow the below steps to generate credentials and store in github. 161 | 162 | 163 | * Prerequisite: You should have installed Azure cli on your local machine to run the command or use the cloudshell in the Azure portal. To install Azure cli, follow [Install Azure Cli](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli?view=azure-cli-latest). To use cloudshell, follow [CloudShell Quickstart](https://docs.microsoft.com/en-us/azure/cloud-shell/quickstart). After you have one of the above ready, follow these steps: 164 | 165 | 166 | * To create service principal that has access over subscription scope, run the below Azure CLI command and copy the output JSON object to your clipboard. 167 | 168 | ```bash 169 | 170 | az ad sp create-for-rbac --name "myApp" --role "Resource Policy Contributor" \ 171 | --scopes /subscriptions/{subscription-id} \ 172 | --sdk-auth 173 | 174 | # Replace {subscription-id} with the subscription identifiers 175 | 176 | # The command should output a JSON object similar to this: 177 | 178 | { 179 | "clientId": "", 180 | "clientSecret": "", 181 | "subscriptionId": "", 182 | "tenantId": "", 183 | (...) 184 | } 185 | 186 | ``` 187 | * Alternatively, to create service principal that has access over atleast one management group scope, run the below Azure CLI command. 188 | 189 | ```bash 190 | 191 | az ad sp create-for-rbac --name "myApp" --role "Resource Policy Contributor" \ 192 | --scopes /providers/Microsoft.Management/managementGroups/{management-group-id} \ 193 | 194 | 195 | # Replace {management-group-name} with the management group identifier 196 | 197 | # The command should output a JSON object similar to this: 198 | 199 | { 200 | "appId": "", 201 | "displayName": "", 202 | "name": "", 203 | "password": "", 204 | "tenant": "" 205 | } 206 | 207 | # copy the GUID values for appId, password and tenant from above JSON and replace them in the following JSON. Once replaced, copy the JSON to clipboard 208 | 209 | { 210 | "clientId": "", 211 | "clientSecret": "", 212 | "tenantId": "" 213 | } 214 | 215 | 216 | 217 | ``` 218 | 219 | * Define a 'New secret' under your GitHub repository settings -> 'Secrets' menu. Lets name it 'AZURE_CREDENTIALS'. 220 | * Paste the contents of the clipboard as the value of the above secret variable. 221 | * Use the secret variable in the Azure Login Action(Refer the End-to-End Sample Workflows section ) 222 | 223 | 224 | 225 | 226 | If needed, you can modify the Azure CLI command to further reduce the scope for which permissions are provided. Here is the command that gives contributor access to only a resource group. 227 | 228 | ```bash 229 | 230 | az ad sp create-for-rbac --name "myApp" --role "Resource Policy Contributor" \ 231 | --scopes /subscriptions/{subscription-id}/resourceGroups/{resource-group} \ 232 | --sdk-auth 233 | 234 | # Replace {subscription-id}, {resource-group} with the subscription and resource group identifiers. 235 | 236 | ``` 237 | 238 | You can also provide permissions to multiple scopes using the Azure CLI command: 239 | 240 | ```bash 241 | 242 | az ad sp create-for-rbac --name "myApp" --role "Resource Policy Contributor" \ 243 | --scopes /subscriptions/{subscription-id}/resourceGroups/{resource-group1} \ 244 | /subscriptions/{subscription-id}/resourceGroups/{resource-group2} \ 245 | --sdk-auth 246 | 247 | # Replace {subscription-id}, {resource-group1}, {resource-group2} with the subscription and resource group identifiers. 248 | 249 | ``` 250 | # Feedback 251 | 252 | If you have any changes you’d like to see or suggestions for this action, we’d love your feedback ❤️ . Please feel free to raise a GitHub issue in this repository describing your suggestion. This would enable us to label and track it properly. You can do the same if you encounter a problem with the feature as well. 253 | 254 | # Contributing 255 | 256 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 257 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 258 | the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. 259 | 260 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide 261 | a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions 262 | provided by the bot. You will only need to do this once across all repos using our CLA. 263 | 264 | 265 | 266 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 267 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 268 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 269 | -------------------------------------------------------------------------------- /src/azure/policyHelper.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as core from '@actions/core'; 3 | import { AzHttpClient } from './azHttpClient'; 4 | import { handleForceUpdate } from './forceUpdateHelper'; 5 | import { getFileJson } from '../utils/fileHelper'; 6 | import { getObjectHash } from '../utils/hashUtils'; 7 | import { getWorkflowRunUrl, prettyLog, prettyDebugLog, populatePropertyFromJsonFile } from '../utils/utilities'; 8 | import { isEnforced, isNonEnforced, getAllPolicyAssignmentPaths, getAllPolicyDefinitionPaths, getAllInitiativesPaths } from '../inputProcessing/pathHelper'; 9 | import * as Inputs from '../inputProcessing/inputs'; 10 | 11 | export const DEFINITION_TYPE = "Microsoft.Authorization/policyDefinitions"; 12 | export const INITIATIVE_TYPE = "Microsoft.Authorization/policySetDefinitions"; 13 | export const ASSIGNMENT_TYPE = "Microsoft.Authorization/policyAssignments"; 14 | export const ROLE_ASSIGNMNET_TYPE = "Microsoft.Authorization/roleAssignments"; 15 | export const POLICY_OPERATION_CREATE = "CREATE"; 16 | export const POLICY_OPERATION_UPDATE = "UPDATE"; 17 | export const POLICY_OPERATION_NONE = "NONE"; 18 | export const POLICY_RESULT_FAILED = "FAILED"; 19 | export const POLICY_RESULT_SUCCEEDED = "SUCCEEDED"; 20 | export const POLICY_FILE_NAME = "policy.json"; 21 | export const POLICY_INITIATIVE_FILE_NAME = "policyset.json"; 22 | export const FRIENDLY_DEFINITION_TYPE = "definition"; 23 | export const FRIENDLY_INITIATIVE_TYPE = "initiative"; 24 | export const FRIENDLY_ASSIGNMENT_TYPE = "assignment"; 25 | const POLICY_RULES_FILE_NAME = "policy.rules.json"; 26 | const POLICY_PARAMETERS_FILE_NAME = "policy.parameters.json"; 27 | const INITIATIVE_PARAMETERS_FILE_NAME = "policyset.parameters.json"; 28 | const INITIATIVE_DEFINITIONS_FILE_NAME = "policyset.definitions.json"; 29 | const POLICY_DEFINITION_NOT_FOUND = "PolicyDefinitionNotFound"; 30 | const POLICY_ASSIGNMENT_NOT_FOUND = "PolicyAssignmentNotFound"; 31 | const POLICY_INITIATIVE_NOT_FOUND = "PolicySetDefinitionNotFound"; 32 | const POLICY_METADATA_GITHUB_KEY = "gitHubPolicy"; 33 | const POLICY_METADATA_HASH_KEY = "digest"; 34 | const ENFORCEMENT_MODE_KEY = "enforcementMode"; 35 | const ENFORCEMENT_MODE_ENFORCE = "Default"; 36 | const ENFORCEMENT_MODE_DO_NOT_ENFORCE = "DoNotEnforce"; 37 | const POLICY_DEFINITION_BUILTIN = "BuiltIn"; 38 | 39 | export interface PolicyRequest { 40 | path: string; 41 | policy: any; 42 | operation: string; 43 | } 44 | 45 | export interface PolicyDetails { 46 | policyInCode: any; 47 | path: string; 48 | policyInService: any; 49 | } 50 | 51 | export interface PolicyResult { 52 | path: string; 53 | type: string; 54 | operation: string; 55 | displayName: string; 56 | status: string; 57 | message: string; 58 | policyDefinitionId: string; 59 | } 60 | 61 | export interface PolicyMetadata { 62 | commitSha: string; 63 | digest: string; 64 | repoName: string; 65 | runUrl: string; 66 | filepath: string; 67 | } 68 | 69 | export async function getAllPolicyRequests(): Promise { 70 | let policyRequests: PolicyRequest[] = []; 71 | 72 | try { 73 | // Get all policy definition, assignment objects 74 | const allPolicyDetails: PolicyDetails[] = await getAllPolicyDetails(); 75 | let errorWhileFetching: Boolean = false; 76 | 77 | for (const policyDetails of allPolicyDetails) { 78 | const gitPolicy = policyDetails.policyInCode; 79 | const currentHash = getObjectHash(gitPolicy); 80 | const azurePolicy = policyDetails.policyInService; 81 | 82 | if (azurePolicy.error && azurePolicy.error.code != POLICY_DEFINITION_NOT_FOUND && azurePolicy.error.code != POLICY_ASSIGNMENT_NOT_FOUND && azurePolicy.error.code != POLICY_INITIATIVE_NOT_FOUND ) { 83 | // There was some error while fetching the policy. 84 | errorWhileFetching = true; 85 | core.error(`Failed to get policy with id ${gitPolicy.id}, path ${policyDetails.path}. Error : ${JSON.stringify(azurePolicy.error)}`); 86 | } 87 | else { 88 | const operationType = getPolicyOperationType(policyDetails, currentHash); 89 | if (operationType == POLICY_OPERATION_CREATE || operationType == POLICY_OPERATION_UPDATE) { 90 | policyRequests.push(getPolicyRequest(policyDetails.policyInCode, policyDetails.path, currentHash, operationType)); 91 | } 92 | } 93 | } 94 | 95 | // There were errors while getting policies. We will not proceed further. 96 | if (errorWhileFetching) { 97 | return Promise.reject(`Error occurred while fetching policies from Azure.`); 98 | } 99 | } 100 | catch (error) { 101 | return Promise.reject(error); 102 | } 103 | return Promise.resolve(policyRequests); 104 | } 105 | 106 | export async function createUpdatePolicies(policyRequests: PolicyRequest[]): Promise { 107 | const azHttpClient = new AzHttpClient(); 108 | await azHttpClient.initialize(); 109 | 110 | let policyResults: PolicyResult[] = []; 111 | 112 | // Dividing policy requests into definitions, initiatives and assignments. 113 | const [definitionRequests, initiativeRequests, assignmentRequests] = dividePolicyRequests(policyRequests); 114 | 115 | const definitionResponses = await azHttpClient.upsertPolicyDefinitions(definitionRequests); 116 | 117 | // In case we have force update 118 | if (Inputs.forceUpdate) { 119 | await handleForceUpdate(definitionRequests, definitionResponses, assignmentRequests, policyResults); 120 | } 121 | 122 | policyResults.push(...getPolicyResults(definitionRequests, definitionResponses, FRIENDLY_DEFINITION_TYPE)); 123 | 124 | const initiativeResponses = await azHttpClient.upsertPolicyInitiatives(initiativeRequests); 125 | policyResults.push(...getPolicyResults(initiativeRequests, initiativeResponses, FRIENDLY_INITIATIVE_TYPE)); 126 | 127 | const assignmentResponses = await azHttpClient.upsertPolicyAssignments(assignmentRequests, policyResults); 128 | policyResults.push(...getPolicyResults(assignmentRequests, assignmentResponses, FRIENDLY_ASSIGNMENT_TYPE)); 129 | 130 | return Promise.resolve(policyResults); 131 | } 132 | 133 | function dividePolicyRequests(policyRequests: PolicyRequest[]) { 134 | let definitionRequests: PolicyRequest[] = []; 135 | let initiativeRequests: PolicyRequest[] = []; 136 | let assignmentRequests: PolicyRequest[] = []; 137 | 138 | policyRequests.forEach(policyRequest => { 139 | switch(policyRequest.policy.type) { 140 | case DEFINITION_TYPE : 141 | definitionRequests.push(policyRequest); 142 | break; 143 | case INITIATIVE_TYPE : 144 | initiativeRequests.push(policyRequest); 145 | break; 146 | case ASSIGNMENT_TYPE : 147 | assignmentRequests.push(policyRequest); 148 | break; 149 | default : 150 | prettyDebugLog(`Unknown type for policy in path : ${policyRequest.path}`); 151 | } 152 | }); 153 | 154 | return [definitionRequests, initiativeRequests, assignmentRequests]; 155 | } 156 | 157 | function getPolicyDefinition(definitionPath: string): any { 158 | const policyPath = path.join(definitionPath, POLICY_FILE_NAME); 159 | const policyRulesPath = path.join(definitionPath, POLICY_RULES_FILE_NAME); 160 | const policyParametersPath = path.join(definitionPath, POLICY_PARAMETERS_FILE_NAME); 161 | 162 | let definition = getFileJson(policyPath); 163 | validatePolicy(definition, definitionPath, FRIENDLY_DEFINITION_TYPE); 164 | 165 | if (!definition.properties) definition.properties = {}; 166 | if (!definition.properties.policyRule) populatePropertyFromJsonFile(definition.properties, policyRulesPath, "policyRule"); 167 | if (!definition.properties.parameters) populatePropertyFromJsonFile(definition.properties, policyParametersPath, "parameters"); 168 | 169 | return definition; 170 | } 171 | 172 | function getPolicyInitiative(initiativePath: string): any { 173 | const initiativeFilePath = path.join(initiativePath, POLICY_INITIATIVE_FILE_NAME); 174 | const initiativeDefinitionsPath = path.join(initiativePath, INITIATIVE_DEFINITIONS_FILE_NAME); 175 | const initiativeParametersPath = path.join(initiativePath, INITIATIVE_PARAMETERS_FILE_NAME); 176 | 177 | let initiative = getFileJson(initiativeFilePath); 178 | validatePolicy(initiative, initiativePath, FRIENDLY_INITIATIVE_TYPE); 179 | 180 | if (!initiative.properties) initiative.properties = {}; 181 | if (!initiative.properties.policyDefinitions) populatePropertyFromJsonFile(initiative.properties, initiativeDefinitionsPath, "policyDefinitions"); 182 | if (!initiative.properties.parameters) populatePropertyFromJsonFile(initiative.properties, initiativeParametersPath, "parameters"); 183 | 184 | return initiative; 185 | } 186 | 187 | export function getPolicyAssignments(assignmentPaths: string[]): any[] { 188 | let assignments: any[] = []; 189 | 190 | assignmentPaths.forEach(path => { 191 | assignments.push(getPolicyAssignment(path)); 192 | }) 193 | 194 | return assignments; 195 | } 196 | 197 | export function getPolicyAssignment(assignmentPath: string): any { 198 | const assignment = getFileJson(assignmentPath); 199 | validatePolicy(assignment, assignmentPath, FRIENDLY_ASSIGNMENT_TYPE); 200 | 201 | if (!assignment.properties) { 202 | assignment.properties = {}; 203 | } 204 | 205 | if (isNonEnforced(assignmentPath)) { 206 | core.debug(`Assignment path: ${assignmentPath} matches enforcementMode pattern for '${ENFORCEMENT_MODE_DO_NOT_ENFORCE}'. Overriding...`); 207 | assignment.properties[ENFORCEMENT_MODE_KEY] = ENFORCEMENT_MODE_DO_NOT_ENFORCE; 208 | } else if (isEnforced(assignmentPath)) { 209 | core.debug(`Assignment path: ${assignmentPath} matches enforcementMode pattern for '${ENFORCEMENT_MODE_ENFORCE}'. Overriding...`); 210 | assignment.properties[ENFORCEMENT_MODE_KEY] = ENFORCEMENT_MODE_ENFORCE; 211 | } 212 | 213 | return assignment; 214 | } 215 | 216 | export function getPolicyResults(policyRequests: PolicyRequest[], policyResponses: any[], policyType: string): PolicyResult[] { 217 | let policyResults: PolicyResult[] = []; 218 | policyResponses = policyResponses.map(response => response.content); 219 | 220 | policyRequests.forEach((policyRequest, index) => { 221 | const isCreate: boolean = isCreateOperation(policyRequest); 222 | const azureResponse: any = policyResponses[index]; 223 | const policyDefinitionId: string = policyRequest.policy.type == ASSIGNMENT_TYPE ? policyRequest.policy.properties.policyDefinitionId : policyRequest.policy.id; 224 | let status = ""; 225 | let message = ""; 226 | 227 | if (!azureResponse) { 228 | status = POLICY_RESULT_FAILED; 229 | message = `An error occured while ${isCreate ? 'creating' : 'updating'} policy ${policyType}.`; 230 | } 231 | else if (azureResponse.error) { 232 | status = POLICY_RESULT_FAILED; 233 | message = `An error occured while ${isCreate ? 'creating' : 'updating'} policy ${policyType}. Error: ${azureResponse.error.message}`; 234 | } 235 | else { 236 | status = POLICY_RESULT_SUCCEEDED; 237 | message = `Policy ${policyType} ${isCreate ? 'created' : 'updated'} successfully`; 238 | } 239 | 240 | policyResults.push({ 241 | path: policyRequest.path, 242 | type: policyRequest.policy.type, 243 | operation: policyRequest.operation, 244 | displayName: policyRequest.policy.name, 245 | status: status, 246 | message: message, 247 | policyDefinitionId: policyDefinitionId 248 | }); 249 | }); 250 | 251 | return policyResults; 252 | } 253 | 254 | export function isCreateOperation(policyRequest: PolicyRequest): boolean { 255 | return policyRequest.operation == POLICY_OPERATION_CREATE; 256 | } 257 | 258 | function validatePolicy(policy: any, path: string, type: string): void { 259 | if (!policy) { 260 | throw Error(`Path : ${path}. JSON file is invalid.`); 261 | } 262 | 263 | if (!policy.id) { 264 | throw Error(`Path : ${path}. Property id is missing from the policy ${type}. Please add id to the ${type} file.`); 265 | } 266 | 267 | if (!policy.name) { 268 | throw Error(`Path : ${path}. Property name is missing from the policy ${type}. Please add name to the ${type} file.`); 269 | } 270 | 271 | if (!policy.type) { 272 | throw Error(`Path : ${path}. Property type is missing from the policy ${type}. Please add type to the ${type} file.`); 273 | } 274 | } 275 | 276 | // Returns all policy definitions and assignments. 277 | export async function getAllPolicyDetails(): Promise { 278 | let allPolicyDetails: PolicyDetails[] = []; 279 | 280 | const definitionPaths = getAllPolicyDefinitionPaths(); 281 | const initiativePaths = getAllInitiativesPaths(); 282 | const assignmentPaths = getAllPolicyAssignmentPaths(); 283 | 284 | definitionPaths.forEach(definitionPath => { 285 | const definitionDetails = getPolicyDetails(definitionPath, DEFINITION_TYPE); 286 | if (!!definitionDetails) { 287 | allPolicyDetails.push(definitionDetails); 288 | } 289 | }); 290 | 291 | initiativePaths.forEach(initiativePath => { 292 | const initiativeDetails = getPolicyDetails(initiativePath, INITIATIVE_TYPE); 293 | if (!!initiativeDetails) { 294 | allPolicyDetails.push(initiativeDetails); 295 | } 296 | }); 297 | 298 | assignmentPaths.forEach(assignmentPath => { 299 | const assignmentDetails = getPolicyDetails(assignmentPath, ASSIGNMENT_TYPE); 300 | if (!!assignmentDetails) { 301 | allPolicyDetails.push(assignmentDetails); 302 | } 303 | }); 304 | // Fetch policies from service 305 | const azHttpClient = new AzHttpClient(); 306 | await azHttpClient.initialize(); 307 | await azHttpClient.populateServicePolicies(allPolicyDetails); 308 | 309 | return allPolicyDetails; 310 | } 311 | 312 | function getPolicyDetails(policyPath: string, policyType: string): PolicyDetails { 313 | let policyDetails: PolicyDetails = {} as PolicyDetails; 314 | let policy: any; 315 | 316 | try { 317 | switch(policyType){ 318 | case DEFINITION_TYPE : policy = getPolicyDefinition(policyPath); break; 319 | case INITIATIVE_TYPE : policy = getPolicyInitiative(policyPath); break; 320 | case ASSIGNMENT_TYPE : policy = getPolicyAssignment(policyPath); break; 321 | } 322 | 323 | // For definitions and initiatives we have policyType field. For assignment this field is not present so it will be ignored. 324 | if (policy.properties && policy.properties.policyType == POLICY_DEFINITION_BUILTIN) { 325 | prettyDebugLog(`Ignoring policy with BuiltIn type. Id : ${policy.id}, path : ${policyPath}`); 326 | policyDetails = undefined; 327 | } 328 | else { 329 | policyDetails.path = policyPath; 330 | policyDetails.policyInCode = policy; 331 | } 332 | } 333 | catch (error) { 334 | prettyLog(`Error occured while reading policy in path : ${policyPath}. Error : ${error}`); 335 | policyDetails = undefined; 336 | } 337 | 338 | return policyDetails; 339 | } 340 | 341 | function getWorkflowMetadata(policyHash: string, filepath: string): PolicyMetadata { 342 | let metadata: PolicyMetadata = { 343 | digest: policyHash, 344 | repoName: process.env.GITHUB_REPOSITORY, 345 | commitSha: process.env.GITHUB_SHA, 346 | runUrl: getWorkflowRunUrl(), 347 | filepath: filepath 348 | } 349 | 350 | return metadata; 351 | } 352 | 353 | export function getPolicyRequest(policy: any, policyPath: string, hash: string, operation: string): PolicyRequest { 354 | let metadata = getWorkflowMetadata(hash, policyPath); 355 | 356 | if (!policy.properties) { 357 | policy.properties = {}; 358 | } 359 | 360 | if (!policy.properties.metadata) { 361 | policy.properties.metadata = {}; 362 | } 363 | 364 | policy.properties.metadata[POLICY_METADATA_GITHUB_KEY] = metadata; 365 | 366 | let policyRequest: PolicyRequest = { 367 | policy: policy, 368 | path: policyPath, 369 | operation: operation 370 | } 371 | return policyRequest; 372 | } 373 | 374 | /** 375 | * Helper Method's from here - START 376 | */ 377 | 378 | /** 379 | * This method, for a given policy in GitHub repo path, decides if the policy is a newly Created or will be updated 380 | * 381 | * @param policyDetails : Policy Details 382 | * @param currentHash : Hash of the current policy in GitHub repo 383 | */ 384 | export function getPolicyOperationType(policyDetails: PolicyDetails, currentHash: string): string { 385 | const policyInCode = policyDetails.policyInCode; 386 | const policyInService = policyDetails.policyInService; 387 | 388 | if (policyInService.error) { 389 | //The error here will be 'HTTP - Not Found'. This scenario covers Create a New policy. 390 | prettyDebugLog(`Policy with id : ${policyInCode.id}, path : ${policyDetails.path} does not exist in azure. A new policy will be created.`); 391 | return POLICY_OPERATION_CREATE; 392 | } 393 | 394 | /** 395 | * Mode can be: 396 | * Incremental - Push changes for only the files that have been updated in the commit 397 | * Complete - Ignore updates and push ALL files in the path 398 | */ 399 | const mode = Inputs.mode; 400 | let azureHash = getHashFromMetadata(policyInService); 401 | 402 | if (Inputs.MODE_COMPLETE === mode || !azureHash) { 403 | /** 404 | * Scenario 1: If user chooses to override logic of hash comparison he can do it via 'mode' == Complete, ALL files in 405 | * user defined path will be updated to Azure Policy Service irrespective of Hash match. 406 | * 407 | * Scenario 2: If policy file Hash is not available on Policy Service (one such scenario will be the very first time this action 408 | * is run on an already existing policy) we need to update the file. 409 | */ 410 | prettyDebugLog(`IgnoreHash is : ${mode} OR GitHub properties/metaData is not present for policy id : ${policyInCode.id}`); 411 | return POLICY_OPERATION_UPDATE; 412 | } 413 | 414 | //If user has chosen to push only updated files i.e 'mode' == Incremental AND a valid hash is available in policy metadata compare them. 415 | prettyDebugLog(`Comparing Hash for policy id : ${policyInCode.id} : ${azureHash === currentHash}`); 416 | return (azureHash === currentHash) ? POLICY_OPERATION_NONE : POLICY_OPERATION_UPDATE; 417 | } 418 | 419 | /** 420 | * Given a Policy Definition or Policy Assignment this method fetched Hash from metadata 421 | * 422 | * @param azurePolicy Azure Policy 423 | */ 424 | function getHashFromMetadata(azurePolicy: any): string { 425 | const properties = azurePolicy.properties; 426 | if (!properties || !properties.metadata) { 427 | return undefined; 428 | } 429 | if (!properties.metadata[POLICY_METADATA_GITHUB_KEY] || !properties.metadata[POLICY_METADATA_GITHUB_KEY][POLICY_METADATA_HASH_KEY]) { 430 | return undefined; 431 | } 432 | return properties.metadata[POLICY_METADATA_GITHUB_KEY][POLICY_METADATA_HASH_KEY]; 433 | } 434 | 435 | export function createPoliciesUsingIds(policyIds: string[]): any[] { 436 | let policies = []; 437 | 438 | policyIds.forEach(policyId => { 439 | policies.push({ 440 | id : policyId 441 | }); 442 | }); 443 | 444 | return policies; 445 | } 446 | 447 | /** 448 | * Helper Method's - END 449 | */ --------------------------------------------------------------------------------