diff --git a/CHANGELOG.md b/CHANGELOG.md index ec8575f3..d8b92084 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Improvements: Features: * `secretId` is no longer required for approle to support advanced use cases like machine login when `bind_secret_id` is false. [GH-522](https://github.com/hashicorp/vault-action/pull/522) +* Use `pki` configuration to generate certificates from Vault [GH-564](https://github.com/hashicorp/vault-action/pull/564) ## 3.0.0 (February 15, 2024) diff --git a/README.md b/README.md index 55f02795..c142b9f3 100644 --- a/README.md +++ b/README.md @@ -417,12 +417,34 @@ secret/data/test Note that the full path is not `secret/test`, but `secret/data/test`. +## PKI Certificate Requests + +You can use the `pki` option to generate a certificate and private key for a given role. + +````yaml +with: + pki: | + pki/issue/rolename {"common_name": "role.mydomain.com", "ttl": "1h"} ; + pki/issue/otherrole {"common_name": "otherrole.mydomain.com", "ttl": "1h"} ; +``` + +Resulting in: + +```bash +ROLENAME_CA=-----BEGIN CERTIFICATE-----... +ROLENAME_CERT=-----BEGIN CERTIFICATE-----... +ROLENAME_KEY=-----BEGIN RSA PRIVATE KEY-----... +ROLENAME_CA_CHAIN=-----BEGIN CERTIFICATE-----... +OTHERROLE_CA=-----BEGIN CERTIFICATE-----... +OTHERROLE_CERT=-----BEGIN CERTIFICATE-----... +OTHERROLE_KEY=-----BEGIN RSA PRIVATE KEY-----... +OTHERROLE_CA_CHAIN=-----BEGIN CERTIFICATE-----... +```` + ## Other Secret Engines Vault Action currently supports retrieving secrets from any engine where secrets -are retrieved via `GET` requests. This means secret engines such as PKI are currently -not supported due to their requirement of sending parameters along with the request -(such as `common_name`). +are retrieved via `GET` requests, except for the PKI engine as noted above. For example, to request a secret from the `cubbyhole` secret engine: diff --git a/action.yml b/action.yml index e3d28550..94c738a0 100644 --- a/action.yml +++ b/action.yml @@ -7,6 +7,9 @@ inputs: secrets: description: 'A semicolon-separated list of secrets to retrieve. These will automatically be converted to environmental variable keys. See README for more details' required: false + pki: + description: 'A semicolon-separated list of certificates to generate. These will automatically be converted to environment variable keys. Cannot be used with "secrets". See README for more details' + required: false namespace: description: 'The Vault namespace from which to query secrets. Vault Enterprise only, unset by default' required: false diff --git a/integrationTests/basic/integration.test.js b/integrationTests/basic/integration.test.js index 93efb14b..d9598cc0 100644 --- a/integrationTests/basic/integration.test.js +++ b/integrationTests/basic/integration.test.js @@ -112,6 +112,69 @@ describe('integration', () => { "other-Secret-dash": 'OTHERCUSTOMSECRET', }, }); + + // Enable pki engine + try { + await got(`${vaultUrl}/v1/sys/mounts/pki`, { + method: 'POST', + headers: { + 'X-Vault-Token': vaultToken, + }, + json: { + type: 'pki' + } + }); + } catch (error) { + const {response} = error; + if (response.statusCode === 400 && response.body.includes("path is already in use")) { + // Engine might already be enabled from previous test runs + } else { + throw error; + } + } + + // Configure Root CA + try { + await got(`${vaultUrl}/v1/pki/root/generate/internal`, { + method: 'POST', + headers: { + 'X-Vault-Token': vaultToken, + }, + json: { + common_name: 'test', + ttl: '24h', + }, + }); + } catch (error) { + const {response} = error; + if (response.statusCode === 400 && response.body.includes("already exists")) { + // Root CA might already be configured from previous test runs + } else { + throw error; + } + } + + // Configure PKI Role + try { + await got(`${vaultUrl}/v1/pki/roles/Test`, { + method: 'POST', + headers: { + 'X-Vault-Token': vaultToken, + }, + json: { + allowed_domains: ['test'], + allow_bare_domains: true, + max_ttl: '1h', + }, + }); + } catch (error) { + const {response} = error; + if (response.statusCode === 400 && response.body.includes("already exists")) { + // Role might already be configured from previous test runs + } else { + throw error; + } + } }); beforeEach(() => { @@ -132,6 +195,12 @@ describe('integration', () => { .mockReturnValueOnce(secrets); } + function mockPkiInput(pki) { + when(core.getInput) + .calledWith('pki', expect.anything()) + .mockReturnValueOnce(pki); + } + function mockIgnoreNotFound(shouldIgnore) { when(core.getInput) .calledWith('ignoreNotFound', expect.anything()) @@ -162,6 +231,19 @@ describe('integration', () => { expect(core.exportVariable).toBeCalledWith('NAMED_SECRET', 'SUPERSECRET'); }) + it('gets a pki certificate', async () => { + mockPkiInput('pki/issue/Test {"common_name":"test","ttl":"1h"}'); + + await exportSecrets(); + + expect(core.exportVariable).toBeCalledTimes(4); + + expect(core.exportVariable).toBeCalledWith('TEST_KEY', expect.anything()); + expect(core.exportVariable).toBeCalledWith('TEST_CERT', expect.anything()); + expect(core.exportVariable).toBeCalledWith('TEST_CA', expect.anything()); + expect(core.exportVariable).toBeCalledWith('TEST_CA_CHAIN', expect.anything()); + }); + it('get simple secret', async () => { mockInput('secret/data/test secret'); diff --git a/src/action.js b/src/action.js index 94c8f8eb..931530df 100644 --- a/src/action.js +++ b/src/action.js @@ -6,7 +6,7 @@ const jsonata = require('jsonata'); const { normalizeOutputKey } = require('./utils'); const { WILDCARD } = require('./constants'); -const { auth: { retrieveToken }, secrets: { getSecrets } } = require('./index'); +const { auth: { retrieveToken }, secrets: { getSecrets }, pki: { getCertificates } } = require('./index'); const AUTH_METHODS = ['approle', 'token', 'github', 'jwt', 'kubernetes', 'ldap', 'userpass']; const ENCODING_TYPES = ['base64', 'hex', 'utf8']; @@ -22,6 +22,16 @@ async function exportSecrets() { const secretsInput = core.getInput('secrets', { required: false }); const secretRequests = parseSecretsInput(secretsInput); + const pkiInput = core.getInput('pki', { required: false }); + let pkiRequests = []; + if (pkiInput) { + if (secretsInput) { + throw Error('You cannot provide both "secrets" and "pki" inputs.'); + } + + pkiRequests = parsePkiInput(pkiInput); + } + const secretEncodingType = core.getInput('secretEncodingType', { required: false }); const vaultMethod = (core.getInput('method', { required: false }) || 'token').toLowerCase(); @@ -84,12 +94,12 @@ async function exportSecrets() { core.exportVariable('VAULT_TOKEN', `${vaultToken}`); } - const requests = secretRequests.map(request => { - const { path, selector } = request; - return request; - }); - - const results = await getSecrets(requests, client); + let results = []; + if (pkiRequests.length > 0) { + results = await getCertificates(pkiRequests, client); + } else { + results = await getSecrets(secretRequests, client); + } for (const result of results) { @@ -128,6 +138,43 @@ async function exportSecrets() { * @property {string} selector */ +/** + * Parses a pki input string into key paths and the request parameters. + * @param {string} pkiInput + */ +function parsePkiInput(pkiInput) { + if (!pkiInput) { + return [] + } + + const secrets = pkiInput + .split(';') + .filter(key => !!key) + .map(key => key.trim()) + .filter(key => key.length !== 0); + + return secrets.map(secret => { + const path = secret.substring(0, secret.indexOf(' ')); + const parameters = secret.substring(secret.indexOf(' ') + 1); + + core.debug(`ℹ Parsing PKI: ${path} with parameters: ${parameters}`); + + if (!path || !parameters) { + throw Error(`You must provide a valid path and parameters. Input: "${secret}"`); + } + + let outputVarName = path.split('/').pop(); + let envVarName = normalizeOutputKey(outputVarName); + + return { + path, + envVarName, + outputVarName, + parameters: JSON.parse(parameters), + }; + }); +} + /** * Parses a secrets input string into key paths and their resulting environment variable name. * @param {string} secretsInput diff --git a/src/index.js b/src/index.js index d1d673b3..b004eb2c 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,9 @@ const auth = require('./auth'); const secrets = require('./secrets'); +const pki = require('./pki'); module.exports = { auth, - secrets + secrets, + pki }; \ No newline at end of file diff --git a/src/pki.js b/src/pki.js new file mode 100644 index 00000000..395a2c24 --- /dev/null +++ b/src/pki.js @@ -0,0 +1,76 @@ +const { normalizeOutputKey } = require('./utils'); +const core = require('@actions/core'); + +/** A map of postfix values mapped to the key in the certificate response and a transformer function */ +const outputMap = { + cert: { key: 'certificate', tx: (v) => v }, + key: { key: 'private_key', tx: (v) => v }, + ca: { key: 'issuing_ca', tx: (v) => v }, + ca_chain: { key: 'ca_chain', tx: (v) => v.join('\n') }, +}; + +/** + * @typedef PkiRequest + * @type {object} + * @property {string} path - The path to the PKI endpoint + * @property {Record} parameters - The parameters to send to the PKI endpoint + * @property {string} envVarName - The name of the environment variable to set + * @property {string} outputVarName - The name of the output variable to set + */ + +/** + * @typedef {Object} PkiResponse + * @property {PkiRequest} request + * @property {string} value + * @property {boolean} cachedResponse + */ + +/** + * Generate and return the certificates from the PKI engine + * @param {Array} pkiRequests + * @param {import('got').Got} client + * @return {Promise>} + */ +async function getCertificates(pkiRequests, client) { + /** @type Array */ + let results = []; + + for (const pkiRequest of pkiRequests) { + const { path, parameters } = pkiRequest; + + const requestPath = `v1/${path}`; + let body; + try { + const result = await client.post(requestPath, { + body: JSON.stringify(parameters), + }); + body = result.body; + } catch (error) { + core.error(`✘ ${error.response?.body ?? error.message}`); + throw error; + } + + body = JSON.parse(body); + + core.info(`✔ Successfully generated certificate (serial number ${body.data.serial_number})`); + + Object.entries(outputMap).forEach(([key, value]) => { + const val = value.tx(body.data[value.key]); + results.push({ + request: { + ...pkiRequest, + envVarName: normalizeOutputKey(`${pkiRequest.envVarName}_${key}`, true), + outputVarName: normalizeOutputKey(`${pkiRequest.outputVarName}_${key}`), + }, + value: val, + cachedResponse: false, + }); + }); + } + + return results; +} + +module.exports = { + getCertificates, +}; \ No newline at end of file