From ab6d759ee8c25a6cc65269dcf52039ca7e88895b Mon Sep 17 00:00:00 2001 From: aksm-ms <58936966+aksm-ms@users.noreply.github.com> Date: Wed, 6 May 2020 16:34:34 +0530 Subject: [PATCH] Azure PowerShell Login changes (#25) * Initial commit * updating package version * adding secret info link in logs * adding user-agent * Update README.md * Added changes for azure powershell login Changes in loginAzurePowerShell.ts using latest version Added getlatestazmodule version changes in runner info code refactor changes in loginAzurePowerShell code refactor Code refactor Code refactor added review comments changes in scriptbuilder changes in setmodulepath added paths in tsconfig.json Revert "added paths in tsconfig.json" This reverts commit cb2f4176bfc1e6603b6f7b2c4122f9327c913786. changes in action.yml changes in main added review comments Added changes for review comments Modified description in action.yml Added telemetry info Code refactor added review comments added review comments removed tslint from package.json added log in ServicePrincipalLogin added boolean for error log Added Unit tests (#15) * Added unit tests for Azure PowerShell * Added unit tests * changes in utils * removed babel * changed variable name of enable-PSSession * refactor * added ci.yml * changes in utils test making login calls silent (#19) Co-authored-by: Deepak Sattiraju update utils test (#16) * update utils test * update utils test * update serviceprincipallogin test Co-authored-by: Microsoft GitHub User Co-authored-by: Sumiran Aggarwal Co-authored-by: UshaN --- .github/workflows/ci.yml | 28 +++++ .../PowerShell/ServicePrinicipalLogin.test.ts | 38 ++++++ __tests__/PowerShell/Utilities/Utils.test.ts | 45 ++++++++ action.yml | 6 +- jest.config.js | 14 +++ lib/PowerShell/Constants.js | 14 +++ lib/PowerShell/IAzurePowerShellSession.js | 0 lib/PowerShell/ServicePrincipalLogin.js | 73 ++++++++++++ .../Utilities/PowerShellToolRunner.js | 35 ++++++ lib/PowerShell/Utilities/ScriptBuilder.js | 62 ++++++++++ lib/PowerShell/Utilities/Utils.js | 80 +++++++++++++ lib/main.js | 29 ++++- package-lock.json | 109 ++++++++++++++++++ package.json | 9 +- src/PowerShell/Constants.ts | 13 +++ src/PowerShell/IAzurePowerShellSession.ts | 4 + src/PowerShell/ServicePrincipalLogin.ts | 57 +++++++++ .../Utilities/PowerShellToolRunner.ts | 16 +++ src/PowerShell/Utilities/ScriptBuilder.ts | 51 ++++++++ src/PowerShell/Utilities/Utils.ts | 61 ++++++++++ src/main.ts | 30 ++++- tsconfig.json | 7 +- 22 files changed, 766 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 __tests__/PowerShell/ServicePrinicipalLogin.test.ts create mode 100644 __tests__/PowerShell/Utilities/Utils.test.ts create mode 100644 jest.config.js create mode 100644 lib/PowerShell/Constants.js create mode 100644 lib/PowerShell/IAzurePowerShellSession.js create mode 100644 lib/PowerShell/ServicePrincipalLogin.js create mode 100644 lib/PowerShell/Utilities/PowerShellToolRunner.js create mode 100644 lib/PowerShell/Utilities/ScriptBuilder.js create mode 100644 lib/PowerShell/Utilities/Utils.js create mode 100644 src/PowerShell/Constants.ts create mode 100644 src/PowerShell/IAzurePowerShellSession.ts create mode 100644 src/PowerShell/ServicePrincipalLogin.ts create mode 100644 src/PowerShell/Utilities/PowerShellToolRunner.ts create mode 100644 src/PowerShell/Utilities/ScriptBuilder.ts create mode 100644 src/PowerShell/Utilities/Utils.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..917eec60 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +on: + pull_request: + branches: + - master + push: + branches: + - master + +jobs: + build_test_job: + name: 'Build and test job' + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [windows-latest, ubuntu-latest, macos-latest] + steps: + + - name: 'Checking out repo code' + uses: actions/checkout@v2 + + - name: 'Validate build' + run: | + npm install + npm run build + + - name: 'Run L0 tests' + run: | + npm run test \ No newline at end of file diff --git a/__tests__/PowerShell/ServicePrinicipalLogin.test.ts b/__tests__/PowerShell/ServicePrinicipalLogin.test.ts new file mode 100644 index 00000000..f8fdbb24 --- /dev/null +++ b/__tests__/PowerShell/ServicePrinicipalLogin.test.ts @@ -0,0 +1,38 @@ +import { ServicePrincipalLogin } from '../../src/PowerShell/ServicePrincipalLogin'; + +jest.mock('../../src/PowerShell/Utilities/Utils'); +jest.mock('../../src/PowerShell/Utilities/PowerShellToolRunner'); +let spnlogin: ServicePrincipalLogin; + +beforeAll(() => { + spnlogin = new ServicePrincipalLogin("servicePrincipalID", "servicePrinicipalkey", "tenantId", "subscriptionId"); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('Testing initialize', () => { + let initializeSpy; + + beforeEach(() => { + initializeSpy = jest.spyOn(spnlogin, 'initialize'); + }); + test('ServicePrincipalLogin initialize should pass', async () => { + await spnlogin.initialize(); + expect(initializeSpy).toHaveBeenCalled(); + }); +}); + +describe('Testing login', () => { + let loginSpy; + + beforeEach(() => { + loginSpy = jest.spyOn(spnlogin, 'login'); + }); + test('ServicePrincipal login should pass', async () => { + loginSpy.mockImplementationOnce(() => Promise.resolve()); + await spnlogin.login(); + expect(loginSpy).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/PowerShell/Utilities/Utils.test.ts b/__tests__/PowerShell/Utilities/Utils.test.ts new file mode 100644 index 00000000..f6e2825b --- /dev/null +++ b/__tests__/PowerShell/Utilities/Utils.test.ts @@ -0,0 +1,45 @@ +import Utils from '../../../src/PowerShell/Utilities/Utils'; + +const version: string = '9.0.0'; +const moduleName: string = 'az'; + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('Testing isValidVersion', () => { + const validVersion: string = '1.2.4'; + const invalidVersion: string = 'a.bcd'; + + test('isValidVersion should be true', () => { + expect(Utils.isValidVersion(validVersion)).toBeTruthy(); + }); + test('isValidVersion should be false', () => { + expect(Utils.isValidVersion(invalidVersion)).toBeFalsy(); + }); +}); + +describe('Testing setPSModulePath', () => { + test('PSModulePath with azPSVersion non-empty', () => { + Utils.setPSModulePath(version); + expect(process.env.PSModulePath).toContain(version); + }); + test('PSModulePath with azPSVersion empty', () => { + const prevPSModulePath = process.env.PSModulePath; + Utils.setPSModulePath(); + expect(process.env.PSModulePath).not.toEqual(prevPSModulePath); + }); +}); + +describe('Testing getLatestModule', () => { + let getLatestModuleSpy; + + beforeEach(() => { + getLatestModuleSpy = jest.spyOn(Utils, 'getLatestModule'); + }); + test('getLatestModule should pass', async () => { + getLatestModuleSpy.mockImplementationOnce((_moduleName: string) => Promise.resolve(version)); + await Utils.getLatestModule(moduleName); + expect(getLatestModuleSpy).toHaveBeenCalled(); + }); +}); diff --git a/action.yml b/action.yml index 87cc563d..c166d10e 100644 --- a/action.yml +++ b/action.yml @@ -1,10 +1,14 @@ # Login to Azure subscription name: 'Azure Login' -description: 'Login Azure wraps the az login, allowing Azure actions to log into Azure or to run Az CLI scripts. github.com/Azure/Actions' +description: 'Authenticate to Azure and run your Az CLI or Az PowerShell based Actions or scripts. github.com/Azure/Actions' inputs: creds: description: 'Paste output of `az ad sp create-for-rbac` as value of secret variable: AZURE_CREDENTIALS' required: true + enable-AzPSSession: + description: 'Set this value to true to enable Azure PowerShell Login in addition to Az CLI login' + required: false + default: false branding: icon: 'login.svg' color: 'blue' diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 00000000..6104d0f1 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,14 @@ +// For a detailed explanation regarding each configuration property, visit: +// https://jestjs.io/docs/en/configuration.html + +module.exports = { + clearMocks: true, + moduleFileExtensions: ['js', 'ts'], + testEnvironment: 'node', + testMatch: ['**/*.test.ts'], + testRunner: 'jest-circus/runner', + transform: { + '^.+\\.ts$': 'ts-jest' + }, + verbose: true +}; diff --git a/lib/PowerShell/Constants.js b/lib/PowerShell/Constants.js new file mode 100644 index 00000000..98f75ccf --- /dev/null +++ b/lib/PowerShell/Constants.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +class Constants { +} +exports.default = Constants; +Constants.prefix = "az_"; +Constants.moduleName = "Az.Accounts"; +Constants.versionPattern = /[0-9]\.[0-9]\.[0-9]/; +Constants.AzureCloud = "AzureCloud"; +Constants.Subscription = "Subscription"; +Constants.ServicePrincipal = "ServicePrincipal"; +Constants.Success = "Success"; +Constants.Error = "Error"; +Constants.AzVersion = "AzVersion"; diff --git a/lib/PowerShell/IAzurePowerShellSession.js b/lib/PowerShell/IAzurePowerShellSession.js new file mode 100644 index 00000000..e69de29b diff --git a/lib/PowerShell/ServicePrincipalLogin.js b/lib/PowerShell/ServicePrincipalLogin.js new file mode 100644 index 00000000..7a4be40b --- /dev/null +++ b/lib/PowerShell/ServicePrincipalLogin.js @@ -0,0 +1,73 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k]; + result["default"] = mod; + return result; +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const core = __importStar(require("@actions/core")); +const Utils_1 = __importDefault(require("./Utilities/Utils")); +const PowerShellToolRunner_1 = __importDefault(require("./Utilities/PowerShellToolRunner")); +const ScriptBuilder_1 = __importDefault(require("./Utilities/ScriptBuilder")); +const Constants_1 = __importDefault(require("./Constants")); +class ServicePrincipalLogin { + constructor(servicePrincipalId, servicePrincipalKey, tenantId, subscriptionId) { + this.servicePrincipalId = servicePrincipalId; + this.servicePrincipalKey = servicePrincipalKey; + this.tenantId = tenantId; + this.subscriptionId = subscriptionId; + } + initialize() { + return __awaiter(this, void 0, void 0, function* () { + Utils_1.default.setPSModulePath(); + const azLatestVersion = yield Utils_1.default.getLatestModule(Constants_1.default.moduleName); + core.debug(`Az Module version used: ${azLatestVersion}`); + Utils_1.default.setPSModulePath(`${Constants_1.default.prefix}${azLatestVersion}`); + }); + } + login() { + return __awaiter(this, void 0, void 0, function* () { + let output = ""; + const options = { + listeners: { + stdout: (data) => { + output += data.toString(); + } + } + }; + const args = { + servicePrincipalId: this.servicePrincipalId, + servicePrincipalKey: this.servicePrincipalKey, + subscriptionId: this.subscriptionId, + environment: ServicePrincipalLogin.environment, + scopeLevel: ServicePrincipalLogin.scopeLevel + }; + const script = new ScriptBuilder_1.default().getAzPSLoginScript(ServicePrincipalLogin.scheme, this.tenantId, args); + yield PowerShellToolRunner_1.default.init(); + yield PowerShellToolRunner_1.default.executePowerShellScriptBlock(script, options); + const result = JSON.parse(output.trim()); + if (!(Constants_1.default.Success in result)) { + throw new Error(`Azure PowerShell login failed with error: ${result[Constants_1.default.Error]}`); + } + console.log(`Azure PowerShell session successfully initialized`); + }); + } +} +exports.ServicePrincipalLogin = ServicePrincipalLogin; +ServicePrincipalLogin.environment = Constants_1.default.AzureCloud; +ServicePrincipalLogin.scopeLevel = Constants_1.default.Subscription; +ServicePrincipalLogin.scheme = Constants_1.default.ServicePrincipal; diff --git a/lib/PowerShell/Utilities/PowerShellToolRunner.js b/lib/PowerShell/Utilities/PowerShellToolRunner.js new file mode 100644 index 00000000..2bee28fd --- /dev/null +++ b/lib/PowerShell/Utilities/PowerShellToolRunner.js @@ -0,0 +1,35 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k]; + result["default"] = mod; + return result; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const io = __importStar(require("@actions/io")); +const exec = __importStar(require("@actions/exec")); +class PowerShellToolRunner { + static init() { + return __awaiter(this, void 0, void 0, function* () { + if (!PowerShellToolRunner.psPath) { + PowerShellToolRunner.psPath = yield io.which("pwsh", true); + } + }); + } + static executePowerShellScriptBlock(scriptBlock, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + yield exec.exec(`${PowerShellToolRunner.psPath} -Command`, [scriptBlock], options); + }); + } +} +exports.default = PowerShellToolRunner; diff --git a/lib/PowerShell/Utilities/ScriptBuilder.js b/lib/PowerShell/Utilities/ScriptBuilder.js new file mode 100644 index 00000000..2b6cdcc1 --- /dev/null +++ b/lib/PowerShell/Utilities/ScriptBuilder.js @@ -0,0 +1,62 @@ +"use strict"; +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k]; + result["default"] = mod; + return result; +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const core = __importStar(require("@actions/core")); +const Constants_1 = __importDefault(require("../Constants")); +class ScriptBuilder { + constructor() { + this.script = ""; + } + getAzPSLoginScript(scheme, tenantId, args) { + let command = `Clear-AzContext -Scope Process; + Clear-AzContext -Scope CurrentUser -Force -ErrorAction SilentlyContinue;`; + if (scheme === Constants_1.default.ServicePrincipal) { + command += `Connect-AzAccount -ServicePrincipal -Tenant ${tenantId} -Credential \ + (New-Object System.Management.Automation.PSCredential('${args.servicePrincipalId}',(ConvertTo-SecureString ${args.servicePrincipalKey} -AsPlainText -Force))) \ + -Environment ${args.environment} | out-null;`; + if (args.scopeLevel === Constants_1.default.Subscription) { + command += `Set-AzContext -SubscriptionId ${args.subscriptionId} -TenantId ${tenantId} | out-null;`; + } + } + this.script += `try { + $ErrorActionPreference = "Stop" + $WarningPreference = "SilentlyContinue" + $output = @{} + ${command} + $output['${Constants_1.default.Success}'] = "true" + } + catch { + $output['${Constants_1.default.Error}'] = $_.exception.Message + } + return ConvertTo-Json $output`; + core.debug(`Azure PowerShell Login Script: ${this.script}`); + return this.script; + } + getLatestModuleScript(moduleName) { + const command = `Get-Module -Name ${moduleName} -ListAvailable | Sort-Object Version -Descending | Select-Object -First 1`; + this.script += `try { + $ErrorActionPreference = "Stop" + $WarningPreference = "SilentlyContinue" + $output = @{} + $data = ${command} + $output['${Constants_1.default.AzVersion}'] = $data.Version.ToString() + $output['${Constants_1.default.Success}'] = "true" + } + catch { + $output['${Constants_1.default.Error}'] = $_.exception.Message + } + return ConvertTo-Json $output`; + core.debug(`GetLatestModuleScript: ${this.script}`); + return this.script; + } +} +exports.default = ScriptBuilder; diff --git a/lib/PowerShell/Utilities/Utils.js b/lib/PowerShell/Utilities/Utils.js new file mode 100644 index 00000000..c3bc1d5b --- /dev/null +++ b/lib/PowerShell/Utilities/Utils.js @@ -0,0 +1,80 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k]; + result["default"] = mod; + return result; +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const os = __importStar(require("os")); +const Constants_1 = __importDefault(require("../Constants")); +const ScriptBuilder_1 = __importDefault(require("./ScriptBuilder")); +const PowerShellToolRunner_1 = __importDefault(require("./PowerShellToolRunner")); +class Utils { + /** + * Add the folder path where Az modules are present to PSModulePath based on runner + * @param azPSVersion + * If azPSVersion is empty, folder path in which all Az modules are present are set + * If azPSVersion is not empty, folder path of exact Az module version is set + */ + static setPSModulePath(azPSVersion = "") { + let modulePath = ""; + const runner = process.env.RUNNER_OS || os.type(); + switch (runner.toLowerCase()) { + case "linux": + modulePath = `/usr/share/${azPSVersion}:`; + break; + case "windows": + case "windows_nt": + modulePath = `C:\\Modules\\${azPSVersion};`; + break; + case "macos": + case "darwin": + throw new Error(`OS not supported`); + default: + throw new Error(`Unknown os: ${runner.toLowerCase()}`); + } + process.env.PSModulePath = `${modulePath}${process.env.PSModulePath}`; + } + static getLatestModule(moduleName) { + return __awaiter(this, void 0, void 0, function* () { + let output = ""; + const options = { + listeners: { + stdout: (data) => { + output += data.toString(); + } + } + }; + yield PowerShellToolRunner_1.default.init(); + yield PowerShellToolRunner_1.default.executePowerShellScriptBlock(new ScriptBuilder_1.default() + .getLatestModuleScript(moduleName), options); + const result = JSON.parse(output.trim()); + if (!(Constants_1.default.Success in result)) { + throw new Error(result[Constants_1.default.Error]); + } + const azLatestVersion = result[Constants_1.default.AzVersion]; + if (!Utils.isValidVersion(azLatestVersion)) { + throw new Error(`Invalid AzPSVersion: ${azLatestVersion}`); + } + return azLatestVersion; + }); + } + static isValidVersion(version) { + return !!version.match(Constants_1.default.versionPattern); + } +} +exports.default = Utils; diff --git a/lib/main.js b/lib/main.js index 32c1936b..d9c3b873 100644 --- a/lib/main.js +++ b/lib/main.js @@ -21,16 +21,21 @@ const crypto = __importStar(require("crypto")); const exec = __importStar(require("@actions/exec")); const io = __importStar(require("@actions/io")); const actions_secret_parser_1 = require("actions-secret-parser"); +const ServicePrincipalLogin_1 = require("./PowerShell/ServicePrincipalLogin"); var azPath; var prefix = !!process.env.AZURE_HTTP_USER_AGENT ? `${process.env.AZURE_HTTP_USER_AGENT}` : ""; +var azPSHostEnv = !!process.env.AZUREPS_HOST_ENVIRONMENT ? `${process.env.AZUREPS_HOST_ENVIRONMENT}` : ""; function main() { return __awaiter(this, void 0, void 0, function* () { try { - // Set user agent varable + // Set user agent variable + var isAzCLISuccess = false; let usrAgentRepo = crypto.createHash('sha256').update(`${process.env.GITHUB_REPOSITORY}`).digest('hex'); let actionName = 'AzureLogin'; - let userAgentString = (!!prefix ? `${prefix}+` : '') + `GITHUBACTIONS_${actionName}_${usrAgentRepo}`; + let userAgentString = (!!prefix ? `${prefix}+` : '') + `GITHUBACTIONS/${actionName}@v1_${usrAgentRepo}`; + let azurePSHostEnv = (!!azPSHostEnv ? `${azPSHostEnv}+` : '') + `GITHUBACTIONS/${actionName}@v1_${usrAgentRepo}`; core.exportVariable('AZURE_HTTP_USER_AGENT', userAgentString); + core.exportVariable('AZUREPS_HOST_ENVIRONMENT', azurePSHostEnv); azPath = yield io.which("az", true); yield executeAzCliCommand("--version"); let creds = core.getInput('creds', { required: true }); @@ -39,20 +44,36 @@ function main() { let servicePrincipalKey = secrets.getSecret("$.clientSecret", true); let tenantId = secrets.getSecret("$.tenantId", false); let subscriptionId = secrets.getSecret("$.subscriptionId", false); + const enableAzPSSession = core.getInput('enable-AzPSSession').toLowerCase() === "true"; if (!servicePrincipalId || !servicePrincipalKey || !tenantId || !subscriptionId) { throw new Error("Not all values are present in the creds object. Ensure clientId, clientSecret, tenantId and subscriptionId are supplied."); } + // Attempting Az cli login yield executeAzCliCommand(`login --service-principal -u "${servicePrincipalId}" -p "${servicePrincipalKey}" --tenant "${tenantId}"`, true); yield executeAzCliCommand(`account set --subscription "${subscriptionId}"`, true); + isAzCLISuccess = true; + if (enableAzPSSession) { + // Attempting Az PS login + console.log(`Running Azure PS Login`); + const spnlogin = new ServicePrincipalLogin_1.ServicePrincipalLogin(servicePrincipalId, servicePrincipalKey, tenantId, subscriptionId); + yield spnlogin.initialize(); + yield spnlogin.login(); + } console.log("Login successful."); } catch (error) { - core.error("Login failed. Please check the credentials. For more information refer https://aka.ms/create-secrets-for-GitHub-workflows"); + if (!isAzCLISuccess) { + core.error("Az CLI Login failed. Please check the credentials. For more information refer https://aka.ms/create-secrets-for-GitHub-workflows"); + } + else { + core.error(`Azure PowerShell Login failed. Please check the credentials. For more information refer https://aka.ms/create-secrets-for-GitHub-workflows"`); + } core.setFailed(error); } finally { // Reset AZURE_HTTP_USER_AGENT core.exportVariable('AZURE_HTTP_USER_AGENT', prefix); + core.exportVariable('AZUREPS_HOST_ENVIRONMENT', azPSHostEnv); } }); } @@ -66,4 +87,4 @@ function executeAzCliCommand(command, silent) { } }); } -main(); +main(); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c3e3c9b5..a3ac44be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,35 @@ "xpath": "0.0.27" } }, + "asyncc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/asyncc/-/asyncc-2.0.4.tgz", + "integrity": "sha512-5ohHLrRC6ZJ7ypVlJh3XJlINBdErz7VeQnNNLXq2T9hVsph58x49oykgg+KNbpnOiMF0X2vDmR2i2LVtWBL7Mw==" + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==" + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + }, "deep-is": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", @@ -80,6 +109,38 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=" }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + }, + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, "jsonpath": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/jsonpath/-/jsonpath-1.0.2.tgz", @@ -99,6 +160,27 @@ "type-check": "~0.3.2" } }, + "lodash": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "requires": { + "wrappy": "1" + } + }, "optionator": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", @@ -112,6 +194,23 @@ "word-wrap": "~1.2.3" } }, + "package-lock": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/package-lock/-/package-lock-1.0.3.tgz", + "integrity": "sha512-rEiz3fs0M8jyH1OtWE3TP7MdtWJnsE69qEOipZ4CDF3KoN3XY5mcDucXugTli6Lqig9rhQ/6ST2atxjU2/aIFQ==", + "requires": { + "asyncc": "^2.0.4", + "commander": "^5.0.0", + "glob": "^7.1.6", + "lodash": "^4.17.15", + "traverse": "^0.6.6" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" + }, "prelude-ls": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", @@ -131,6 +230,11 @@ "escodegen": "^1.8.1" } }, + "traverse": { + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.6.tgz", + "integrity": "sha1-y99WD9e5r2MlAv7UD5GMFX6pcTc=" + }, "type-check": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", @@ -155,6 +259,11 @@ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==" }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + }, "xmldom": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.27.tgz", diff --git a/package.json b/package.json index b0b60823..839ebba0 100644 --- a/package.json +++ b/package.json @@ -5,18 +5,23 @@ "main": "lib/main.js", "scripts": { "build": "tsc", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "jest" }, "author": "Sumiran Aggarwal", "license": "MIT", "devDependencies": { + "@types/jest": "^25.1.4", "@types/node": "^12.7.11", + "jest": "^25.2.4", + "jest-circus": "^25.2.7", + "ts-jest": "^25.3.0", "typescript": "^3.6.3" }, "dependencies": { "@actions/core": "^1.1.3", "@actions/exec": "^1.0.1", "@actions/io": "^1.0.1", - "actions-secret-parser": "^1.0.2" + "actions-secret-parser": "^1.0.2", + "package-lock": "^1.0.3" } } diff --git a/src/PowerShell/Constants.ts b/src/PowerShell/Constants.ts new file mode 100644 index 00000000..9613db1d --- /dev/null +++ b/src/PowerShell/Constants.ts @@ -0,0 +1,13 @@ +export default class Constants { + static readonly prefix: string = "az_"; + static readonly moduleName: string = "Az.Accounts"; + static readonly versionPattern = /[0-9]\.[0-9]\.[0-9]/; + + static readonly AzureCloud: string = "AzureCloud"; + static readonly Subscription: string = "Subscription"; + static readonly ServicePrincipal: string = "ServicePrincipal"; + + static readonly Success: string = "Success"; + static readonly Error: string = "Error"; + static readonly AzVersion: string = "AzVersion"; +} \ No newline at end of file diff --git a/src/PowerShell/IAzurePowerShellSession.ts b/src/PowerShell/IAzurePowerShellSession.ts new file mode 100644 index 00000000..544369d5 --- /dev/null +++ b/src/PowerShell/IAzurePowerShellSession.ts @@ -0,0 +1,4 @@ +interface IAzurePowerShellSession { + initialize(); + login(); +} \ No newline at end of file diff --git a/src/PowerShell/ServicePrincipalLogin.ts b/src/PowerShell/ServicePrincipalLogin.ts new file mode 100644 index 00000000..8aa293a4 --- /dev/null +++ b/src/PowerShell/ServicePrincipalLogin.ts @@ -0,0 +1,57 @@ +import * as core from '@actions/core'; + +import Utils from './Utilities/Utils'; +import PowerShellToolRunner from './Utilities/PowerShellToolRunner'; +import ScriptBuilder from './Utilities/ScriptBuilder'; +import Constants from './Constants'; + +export class ServicePrincipalLogin implements IAzurePowerShellSession { + static readonly environment: string = Constants.AzureCloud; + static readonly scopeLevel: string = Constants.Subscription; + static readonly scheme: string = Constants.ServicePrincipal; + servicePrincipalId: string; + servicePrincipalKey: string; + tenantId: string; + subscriptionId: string; + + constructor(servicePrincipalId: string, servicePrincipalKey: string, tenantId: string, subscriptionId: string) { + this.servicePrincipalId = servicePrincipalId; + this.servicePrincipalKey = servicePrincipalKey; + this.tenantId = tenantId; + this.subscriptionId = subscriptionId; + } + + async initialize() { + Utils.setPSModulePath(); + const azLatestVersion: string = await Utils.getLatestModule(Constants.moduleName); + core.debug(`Az Module version used: ${azLatestVersion}`); + Utils.setPSModulePath(`${Constants.prefix}${azLatestVersion}`); + } + + async login() { + let output: string = ""; + const options: any = { + listeners: { + stdout: (data: Buffer) => { + output += data.toString(); + } + } + }; + const args: any = { + servicePrincipalId: this.servicePrincipalId, + servicePrincipalKey: this.servicePrincipalKey, + subscriptionId: this.subscriptionId, + environment: ServicePrincipalLogin.environment, + scopeLevel: ServicePrincipalLogin.scopeLevel + } + const script: string = new ScriptBuilder().getAzPSLoginScript(ServicePrincipalLogin.scheme, this.tenantId, args); + await PowerShellToolRunner.init(); + await PowerShellToolRunner.executePowerShellScriptBlock(script, options); + const result: any = JSON.parse(output.trim()); + if (!(Constants.Success in result)) { + throw new Error(`Azure PowerShell login failed with error: ${result[Constants.Error]}`); + } + console.log(`Azure PowerShell session successfully initialized`); + } + +} \ No newline at end of file diff --git a/src/PowerShell/Utilities/PowerShellToolRunner.ts b/src/PowerShell/Utilities/PowerShellToolRunner.ts new file mode 100644 index 00000000..dd08b2a2 --- /dev/null +++ b/src/PowerShell/Utilities/PowerShellToolRunner.ts @@ -0,0 +1,16 @@ +import * as io from '@actions/io'; +import * as exec from '@actions/exec'; + +export default class PowerShellToolRunner { + static psPath: string; + + static async init() { + if(!PowerShellToolRunner.psPath) { + PowerShellToolRunner.psPath = await io.which("pwsh", true); + } + } + + static async executePowerShellScriptBlock(scriptBlock: string, options: any = {}) { + await exec.exec(`${PowerShellToolRunner.psPath} -Command`, [scriptBlock], options) + } +} \ No newline at end of file diff --git a/src/PowerShell/Utilities/ScriptBuilder.ts b/src/PowerShell/Utilities/ScriptBuilder.ts new file mode 100644 index 00000000..a6871a46 --- /dev/null +++ b/src/PowerShell/Utilities/ScriptBuilder.ts @@ -0,0 +1,51 @@ +import * as core from '@actions/core'; + +import Constants from "../Constants"; + +export default class ScriptBuilder { + script: string = ""; + + getAzPSLoginScript(scheme: string, tenantId: string, args: any): string { + let command = `Clear-AzContext -Scope Process; + Clear-AzContext -Scope CurrentUser -Force -ErrorAction SilentlyContinue;`; + if (scheme === Constants.ServicePrincipal) { + command += `Connect-AzAccount -ServicePrincipal -Tenant ${tenantId} -Credential \ + (New-Object System.Management.Automation.PSCredential('${args.servicePrincipalId}',(ConvertTo-SecureString ${args.servicePrincipalKey} -AsPlainText -Force))) \ + -Environment ${args.environment} | out-null;`; + if (args.scopeLevel === Constants.Subscription) { + command += `Set-AzContext -SubscriptionId ${args.subscriptionId} -TenantId ${tenantId} | out-null;`; + } + } + this.script += `try { + $ErrorActionPreference = "Stop" + $WarningPreference = "SilentlyContinue" + $output = @{} + ${command} + $output['${Constants.Success}'] = "true" + } + catch { + $output['${Constants.Error}'] = $_.exception.Message + } + return ConvertTo-Json $output`; + core.debug(`Azure PowerShell Login Script: ${this.script}`); + return this.script; + } + + getLatestModuleScript(moduleName: string): string { + const command: string = `Get-Module -Name ${moduleName} -ListAvailable | Sort-Object Version -Descending | Select-Object -First 1`; + this.script += `try { + $ErrorActionPreference = "Stop" + $WarningPreference = "SilentlyContinue" + $output = @{} + $data = ${command} + $output['${Constants.AzVersion}'] = $data.Version.ToString() + $output['${Constants.Success}'] = "true" + } + catch { + $output['${Constants.Error}'] = $_.exception.Message + } + return ConvertTo-Json $output`; + core.debug(`GetLatestModuleScript: ${this.script}`); + return this.script; + } +} diff --git a/src/PowerShell/Utilities/Utils.ts b/src/PowerShell/Utilities/Utils.ts new file mode 100644 index 00000000..8814ab1f --- /dev/null +++ b/src/PowerShell/Utilities/Utils.ts @@ -0,0 +1,61 @@ +import * as os from 'os'; + +import Constants from '../Constants'; +import ScriptBuilder from './ScriptBuilder'; +import PowerShellToolRunner from './PowerShellToolRunner'; + +export default class Utils { + /** + * Add the folder path where Az modules are present to PSModulePath based on runner + * @param azPSVersion + * If azPSVersion is empty, folder path in which all Az modules are present are set + * If azPSVersion is not empty, folder path of exact Az module version is set + */ + static setPSModulePath(azPSVersion: string = "") { + let modulePath: string = ""; + const runner: string = process.env.RUNNER_OS || os.type(); + switch (runner.toLowerCase()) { + case "linux": + modulePath = `/usr/share/${azPSVersion}:`; + break; + case "windows": + case "windows_nt": + modulePath = `C:\\Modules\\${azPSVersion};`; + break; + case "macos": + case "darwin": + throw new Error(`OS not supported`); + default: + throw new Error(`Unknown os: ${runner.toLowerCase()}`); + } + process.env.PSModulePath = `${modulePath}${process.env.PSModulePath}`; + } + + static async getLatestModule(moduleName: string): Promise { + let output: string = ""; + const options: any = { + listeners: { + stdout: (data: Buffer) => { + output += data.toString(); + } + } + }; + await PowerShellToolRunner.init(); + await PowerShellToolRunner.executePowerShellScriptBlock(new ScriptBuilder() + .getLatestModuleScript(moduleName), options); + const result = JSON.parse(output.trim()); + if (!(Constants.Success in result)) { + throw new Error(result[Constants.Error]); + } + const azLatestVersion: string = result[Constants.AzVersion]; + if (!Utils.isValidVersion(azLatestVersion)) { + throw new Error(`Invalid AzPSVersion: ${azLatestVersion}`); + } + return azLatestVersion; + } + + static isValidVersion(version: string): boolean { + return !!version.match(Constants.versionPattern); + } +} + diff --git a/src/main.ts b/src/main.ts index 2dfcd52e..cbe0b20e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,17 +4,22 @@ import * as exec from '@actions/exec'; import * as io from '@actions/io'; import { FormatType, SecretParser } from 'actions-secret-parser'; +import { ServicePrincipalLogin } from './PowerShell/ServicePrincipalLogin'; var azPath: string; var prefix = !!process.env.AZURE_HTTP_USER_AGENT ? `${process.env.AZURE_HTTP_USER_AGENT}` : ""; +var azPSHostEnv = !!process.env.AZUREPS_HOST_ENVIRONMENT ? `${process.env.AZUREPS_HOST_ENVIRONMENT}` : ""; async function main() { - try{ - // Set user agent varable + try { + // Set user agent variable + var isAzCLISuccess = false; let usrAgentRepo = crypto.createHash('sha256').update(`${process.env.GITHUB_REPOSITORY}`).digest('hex'); let actionName = 'AzureLogin'; - let userAgentString = (!!prefix ? `${prefix}+` : '') + `GITHUBACTIONS_${actionName}_${usrAgentRepo}`; + let userAgentString = (!!prefix ? `${prefix}+` : '') + `GITHUBACTIONS/${actionName}@v1_${usrAgentRepo}`; + let azurePSHostEnv = (!!azPSHostEnv ? `${azPSHostEnv}+` : '') + `GITHUBACTIONS/${actionName}@v1_${usrAgentRepo}`; core.exportVariable('AZURE_HTTP_USER_AGENT', userAgentString); + core.exportVariable('AZUREPS_HOST_ENVIRONMENT', azurePSHostEnv); azPath = await io.which("az", true); await executeAzCliCommand("--version"); @@ -25,19 +30,33 @@ async function main() { let servicePrincipalKey = secrets.getSecret("$.clientSecret", true); let tenantId = secrets.getSecret("$.tenantId", false); let subscriptionId = secrets.getSecret("$.subscriptionId", false); + const enableAzPSSession = core.getInput('enable-AzPSSession').toLowerCase() === "true"; if (!servicePrincipalId || !servicePrincipalKey || !tenantId || !subscriptionId) { throw new Error("Not all values are present in the creds object. Ensure clientId, clientSecret, tenantId and subscriptionId are supplied."); } - + // Attempting Az cli login await executeAzCliCommand(`login --service-principal -u "${servicePrincipalId}" -p "${servicePrincipalKey}" --tenant "${tenantId}"`, true); await executeAzCliCommand(`account set --subscription "${subscriptionId}"`, true); + isAzCLISuccess = true; + if (enableAzPSSession) { + // Attempting Az PS login + console.log(`Running Azure PS Login`); + const spnlogin: ServicePrincipalLogin = new ServicePrincipalLogin(servicePrincipalId, servicePrincipalKey, tenantId, subscriptionId); + await spnlogin.initialize(); + await spnlogin.login(); + } console.log("Login successful."); } catch (error) { - core.error("Login failed. Please check the credentials. For more information refer https://aka.ms/create-secrets-for-GitHub-workflows"); + if (!isAzCLISuccess) { + core.error("Az CLI Login failed. Please check the credentials. For more information refer https://aka.ms/create-secrets-for-GitHub-workflows"); + } else { + core.error(`Azure PowerShell Login failed. Please check the credentials. For more information refer https://aka.ms/create-secrets-for-GitHub-workflows"`); + } core.setFailed(error); } finally { // Reset AZURE_HTTP_USER_AGENT core.exportVariable('AZURE_HTTP_USER_AGENT', prefix); + core.exportVariable('AZUREPS_HOST_ENVIRONMENT', azPSHostEnv); } } @@ -50,5 +69,4 @@ async function executeAzCliCommand(command: string, silent?: boolean) { } } - main(); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 7a865ad1..90f8bf61 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -56,5 +56,8 @@ /* Experimental Options */ // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ - } -} + }, + "exclude" : [ + "./__tests__" + ] +} \ No newline at end of file