diff --git a/README.md b/README.md index 795d6645..ccb0f5bb 100644 --- a/README.md +++ b/README.md @@ -10,20 +10,20 @@ A helper action for easily pulling secrets from HashiCorp Vault™. -- [Example Usage](#example-usage) -- [Authentication method](#authentication-method) -- [Key Syntax](#key-syntax) +- [Vault GitHub Action](#vault-github-action) + - [Example Usage](#example-usage) + - [Authentication method](#authentication-method) + - [Key Syntax](#key-syntax) - [Simple Key](#simple-key) - [Set Output Variable Name](#set-output-variable-name) - [Multiple Secrets](#multiple-secrets) - - [Nested Secrets](#nested-secrets) -- [Other Secret Engines](#other-secret-engines) -- [Adding Extra Headers](#adding-extra-headers) -- [Vault Enterprise Features](#vault-enterprise-features) + - [Other Secret Engines](#other-secret-engines) + - [Adding Extra Headers](#adding-extra-headers) + - [Vault Enterprise Features](#vault-enterprise-features) - [Namespace](#namespace) -- [Reference](#reference) -- [Masking - Hiding Secrets from Logs](#masking---hiding-secrets-from-logs) -- [Normalization](#normalization) + - [Reference](#reference) + - [Masking - Hiding Secrets from Logs](#masking---hiding-secrets-from-logs) + - [Normalization](#normalization) @@ -98,6 +98,15 @@ with: jwtTtl: 3600 # 1 hour, default value ``` +- **kubernetes**: you must provide the `role` paramaters. You can optionally override the `kubernetesTokenPath` paramater for custom mounted serviceAccounts. Consider [kubernetes auth](https://www.vaultproject.io/docs/auth/kubernetes) when using self-hosted runners on Kubernetes: +```yaml +... +with: + url: https://vault.mycompany.com:8200 + method: kubernetes + role: ${{ secrets.KUBE_ROLE }} +``` + If any other method is specified and you provide an `authPayload`, the action will attempt to `POST` to `auth/${method}/login` with the provided payload and parse out the client token. ## Key Syntax @@ -261,14 +270,16 @@ Here are all the inputs available through `with`: | `secrets` | A semicolon-separated list of secrets to retrieve. These will automatically be converted to environmental variable keys. See README for more details | | ✔ | | `namespace` | The Vault namespace from which to query secrets. Vault Enterprise only, unset by default | | | | `method` | The method to use to authenticate with Vault. | `token` | | +| `role` | Vault role for specified auth method | | | +| `path` | Custom vault path, if the auth method was enabled at a different path | | | | `token` | The Vault Token to be used to authenticate with Vault | | | | `roleId` | The Role Id for App Role authentication | | | | `secretId` | The Secret Id for App Role authentication | | | | `githubToken` | The Github Token to be used to authenticate with Vault | | | -| `role` | Vault role for specified auth method | | | | `jwtPrivateKey` | Base64 encoded Private key to sign JWT | | | | `jwtKeyPassword` | Password for key stored in jwtPrivateKey (if needed) | | | | `jwtTtl` | Time in seconds, after which token expires | | 3600 | +| `kubernetesTokenPath` | The path to the service-account secret with the jwt token for kubernetes based authentication |`/var/run/secrets/kubernetes.io/serviceaccount/token` | | | `authPayload` | The JSON payload to be sent to Vault when using a custom authentication method. | | | | `extraHeaders` | A string of newline separated extra headers to include on every request. | | | | `exportEnv` | Whether or not export secrets as environment variables. | `true` | | diff --git a/action.yml b/action.yml index bda458bd..b3c0a5c6 100644 --- a/action.yml +++ b/action.yml @@ -14,6 +14,12 @@ inputs: description: 'The method to use to authenticate with Vault.' default: 'token' required: false + role: + description: 'Vault role for specified auth method' + required: false + path: + description: 'Custom Vault path, if the auth method was mounted at a different path' + required: false token: description: 'The Vault Token to be used to authenticate with Vault' required: false @@ -26,6 +32,10 @@ inputs: githubToken: description: 'The Github Token to be used to authenticate with Vault' required: false + kubernetesTokenPath: + description: 'The path to the Kubernetes service account secret' + required: false + default: '/var/run/secrets/kubernetes.io/serviceaccount/token' authPayload: description: 'The JSON payload to be sent to Vault when using a custom authentication method.' required: false @@ -52,10 +62,7 @@ inputs: tlsSkipVerify: description: 'When set to true, disables verification of the Vault server certificate. Setting this to true in production is not recommended.' required: false - default: "false" - role: - description: 'Vault role for specified auth method' - required: false + default: 'false' jwtPrivateKey: description: 'Base64 encoded Private key to sign JWT' required: false diff --git a/src/action.js b/src/action.js index dd2c9041..d301605f 100644 --- a/src/action.js +++ b/src/action.js @@ -5,7 +5,7 @@ const got = require('got').default; const jsonata = require('jsonata'); const { auth: { retrieveToken }, secrets: { getSecrets } } = require('./index'); -const AUTH_METHODS = ['approle', 'token', 'github', 'jwt']; +const AUTH_METHODS = ['approle', 'token', 'github', 'jwt', 'kubernetes']; async function exportSecrets() { const vaultUrl = core.getInput('url', { required: true }); diff --git a/src/auth.js b/src/auth.js index bd3c5359..4b05b77c 100644 --- a/src/auth.js +++ b/src/auth.js @@ -1,22 +1,26 @@ // @ts-check const core = require('@actions/core'); const rsasign = require('jsrsasign'); +const fs = require('fs'); +const defaultKubernetesTokenPath = '/var/run/secrets/kubernetes.io/serviceaccount/token' /*** * Authenticate with Vault and retrieve a Vault token that can be used for requests. * @param {string} method * @param {import('got').Got} client */ async function retrieveToken(method, client) { + const path = core.getInput('path', { required: false }) || method; + switch (method) { case 'approle': { const vaultRoleId = core.getInput('roleId', { required: true }); const vaultSecretId = core.getInput('secretId', { required: true }); - return await getClientToken(client, method, { role_id: vaultRoleId, secret_id: vaultSecretId }); + return await getClientToken(client, method, path, { role_id: vaultRoleId, secret_id: vaultSecretId }); } case 'github': { const githubToken = core.getInput('githubToken', { required: true }); - return await getClientToken(client, method, { token: githubToken }); + return await getClientToken(client, method, path, { token: githubToken }); } case 'jwt': { const role = core.getInput('role', { required: true }); @@ -25,8 +29,18 @@ async function retrieveToken(method, client) { const keyPassword = core.getInput('jwtKeyPassword', { required: false }); const tokenTtl = core.getInput('jwtTtl', { required: false }) || '3600'; // 1 hour const jwt = generateJwt(privateKey, keyPassword, Number(tokenTtl)); - return await getClientToken(client, method, { jwt: jwt, role: role }); + return await getClientToken(client, method, path, { jwt: jwt, role: role }); + } + case 'kubernetes': { + const role = core.getInput('role', { required: true }) + const tokenPath = core.getInput('kubernetesTokenPath', { required: false }) || defaultKubernetesTokenPath + const data = fs.readFileSync(tokenPath, 'utf8') + if (!(role && data) && data != "") { + throw new Error("Role Name must be set and a kubernetes token must set") + } + return await getClientToken(client, method, path, { jwt: data, role: role }) } + default: { if (!method || method === 'token') { return core.getInput('token', { required: true }); @@ -36,7 +50,7 @@ async function retrieveToken(method, client) { if (!payload) { throw Error('When using a custom authentication method, you must provide the payload'); } - return await getClientToken(client, method, JSON.parse(payload.trim())); + return await getClientToken(client, method, path, JSON.parse(payload.trim())); } } } @@ -72,9 +86,10 @@ function generateJwt(privateKey, keyPassword, ttl) { * Call the appropriate login endpoint and parse out the token in the response. * @param {import('got').Got} client * @param {string} method + * @param {string} path * @param {any} payload */ -async function getClientToken(client, method, payload) { +async function getClientToken(client, method, path, payload) { /** @type {'json'} */ const responseType = 'json'; var options = { @@ -82,10 +97,10 @@ async function getClientToken(client, method, payload) { responseType, }; - core.debug(`Retrieving Vault Token from v1/auth/${method}/login endpoint`); + core.debug(`Retrieving Vault Token from v1/auth/${path}/login endpoint`); /** @type {import('got').Response} */ - const response = await client.post(`v1/auth/${method}/login`, options); + const response = await client.post(`v1/auth/${path}/login`, options); if (response && response.body && response.body.auth && response.body.auth.client_token) { core.debug('✔ Vault Token successfully retrieved'); diff --git a/src/auth.test.js b/src/auth.test.js new file mode 100644 index 00000000..c86415ba --- /dev/null +++ b/src/auth.test.js @@ -0,0 +1,83 @@ +jest.mock('got'); +jest.mock('@actions/core'); +jest.mock('@actions/core/lib/command'); +jest.mock("fs") + +const core = require('@actions/core'); +const got = require('got'); +const fs = require("fs") +const { when } = require('jest-when'); + + +const { + retrieveToken +} = require('./auth'); + + +function mockInput(name, key) { + when(core.getInput) + .calledWith(name) + .mockReturnValueOnce(key); +} + +function mockApiResponse() { + const response = { body: { auth: { client_token: testToken, renewable: true, policies: [], accessor: "accessor" } } } + got.post = jest.fn() + got.post.mockReturnValue(response) +} +const testToken = "testoken"; + +describe("test retrival for token", () => { + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("test retrival with approle", async () => { + const method = 'approle' + mockApiResponse() + const testRoleId = "testRoleId" + const testSecretId = "testSecretId" + mockInput("roleId", testRoleId) + mockInput("secretId", testSecretId) + const token = await retrieveToken(method, got) + expect(token).toEqual(testToken) + const payload = got.post.mock.calls[0][1].json + expect(payload).toEqual({ role_id: testRoleId, secret_id: testSecretId }) + const url = got.post.mock.calls[0][0] + expect(url).toContain('approle') + }) + + it("test retrival with github token", async () => { + const method = 'github' + mockApiResponse() + const githubToken = "githubtoken" + mockInput("githubToken", githubToken) + const token = await retrieveToken(method, got) + expect(token).toEqual(testToken) + const payload = got.post.mock.calls[0][1].json + expect(payload).toEqual({ token: githubToken }) + const url = got.post.mock.calls[0][0] + expect(url).toContain('github') + }) + + it("test retrival with kubernetes", async () => { + const method = 'kubernetes' + const jwtToken = "someJwtToken" + const testRole = "testRole" + const testTokenPath = "testTokenPath" + const testPath = 'differentK8sPath' + mockApiResponse() + mockInput("kubernetesTokenPath", testTokenPath) + mockInput("role", testRole) + mockInput("path", testPath) + fs.readFileSync = jest.fn() + fs.readFileSync.mockReturnValueOnce(jwtToken) + const token = await retrieveToken(method, got) + expect(token).toEqual(testToken) + const payload = got.post.mock.calls[0][1].json + expect(payload).toEqual({ jwt: jwtToken, role: testRole }) + const url = got.post.mock.calls[0][0] + expect(url).toContain('differentK8sPath') + }) +})