mirror of
https://github.com/hashicorp/vault-action.git
synced 2025-11-06 23:06:54 +00:00
feat: Add PKI capability (#564)
This commit is contained in:
parent
8b7eaceb79
commit
33b70ff01a
7 changed files with 244 additions and 11 deletions
|
|
@ -7,6 +7,7 @@ Improvements:
|
||||||
Features:
|
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)
|
* `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)
|
## 3.0.0 (February 15, 2024)
|
||||||
|
|
||||||
|
|
|
||||||
28
README.md
28
README.md
|
|
@ -417,12 +417,34 @@ secret/data/test
|
||||||
|
|
||||||
Note that the full path is not `secret/test`, but `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
|
## Other Secret Engines
|
||||||
|
|
||||||
Vault Action currently supports retrieving secrets from any engine where secrets
|
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
|
are retrieved via `GET` requests, except for the PKI engine as noted above.
|
||||||
not supported due to their requirement of sending parameters along with the request
|
|
||||||
(such as `common_name`).
|
|
||||||
|
|
||||||
For example, to request a secret from the `cubbyhole` secret engine:
|
For example, to request a secret from the `cubbyhole` secret engine:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ inputs:
|
||||||
secrets:
|
secrets:
|
||||||
description: 'A semicolon-separated list of secrets to retrieve. These will automatically be converted to environmental variable keys. See README for more details'
|
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
|
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:
|
namespace:
|
||||||
description: 'The Vault namespace from which to query secrets. Vault Enterprise only, unset by default'
|
description: 'The Vault namespace from which to query secrets. Vault Enterprise only, unset by default'
|
||||||
required: false
|
required: false
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,69 @@ describe('integration', () => {
|
||||||
"other-Secret-dash": 'OTHERCUSTOMSECRET',
|
"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(() => {
|
beforeEach(() => {
|
||||||
|
|
@ -132,6 +195,12 @@ describe('integration', () => {
|
||||||
.mockReturnValueOnce(secrets);
|
.mockReturnValueOnce(secrets);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mockPkiInput(pki) {
|
||||||
|
when(core.getInput)
|
||||||
|
.calledWith('pki', expect.anything())
|
||||||
|
.mockReturnValueOnce(pki);
|
||||||
|
}
|
||||||
|
|
||||||
function mockIgnoreNotFound(shouldIgnore) {
|
function mockIgnoreNotFound(shouldIgnore) {
|
||||||
when(core.getInput)
|
when(core.getInput)
|
||||||
.calledWith('ignoreNotFound', expect.anything())
|
.calledWith('ignoreNotFound', expect.anything())
|
||||||
|
|
@ -162,6 +231,19 @@ describe('integration', () => {
|
||||||
expect(core.exportVariable).toBeCalledWith('NAMED_SECRET', 'SUPERSECRET');
|
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 () => {
|
it('get simple secret', async () => {
|
||||||
mockInput('secret/data/test secret');
|
mockInput('secret/data/test secret');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ const jsonata = require('jsonata');
|
||||||
const { normalizeOutputKey } = require('./utils');
|
const { normalizeOutputKey } = require('./utils');
|
||||||
const { WILDCARD } = require('./constants');
|
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 AUTH_METHODS = ['approle', 'token', 'github', 'jwt', 'kubernetes', 'ldap', 'userpass'];
|
||||||
const ENCODING_TYPES = ['base64', 'hex', 'utf8'];
|
const ENCODING_TYPES = ['base64', 'hex', 'utf8'];
|
||||||
|
|
@ -22,6 +22,16 @@ async function exportSecrets() {
|
||||||
const secretsInput = core.getInput('secrets', { required: false });
|
const secretsInput = core.getInput('secrets', { required: false });
|
||||||
const secretRequests = parseSecretsInput(secretsInput);
|
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 secretEncodingType = core.getInput('secretEncodingType', { required: false });
|
||||||
|
|
||||||
const vaultMethod = (core.getInput('method', { required: false }) || 'token').toLowerCase();
|
const vaultMethod = (core.getInput('method', { required: false }) || 'token').toLowerCase();
|
||||||
|
|
@ -84,12 +94,12 @@ async function exportSecrets() {
|
||||||
core.exportVariable('VAULT_TOKEN', `${vaultToken}`);
|
core.exportVariable('VAULT_TOKEN', `${vaultToken}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const requests = secretRequests.map(request => {
|
let results = [];
|
||||||
const { path, selector } = request;
|
if (pkiRequests.length > 0) {
|
||||||
return request;
|
results = await getCertificates(pkiRequests, client);
|
||||||
});
|
} else {
|
||||||
|
results = await getSecrets(secretRequests, client);
|
||||||
const results = await getSecrets(requests, client);
|
}
|
||||||
|
|
||||||
|
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
|
|
@ -128,6 +138,43 @@ async function exportSecrets() {
|
||||||
* @property {string} selector
|
* @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.
|
* Parses a secrets input string into key paths and their resulting environment variable name.
|
||||||
* @param {string} secretsInput
|
* @param {string} secretsInput
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
const auth = require('./auth');
|
const auth = require('./auth');
|
||||||
const secrets = require('./secrets');
|
const secrets = require('./secrets');
|
||||||
|
const pki = require('./pki');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
auth,
|
auth,
|
||||||
secrets
|
secrets,
|
||||||
|
pki
|
||||||
};
|
};
|
||||||
76
src/pki.js
Normal file
76
src/pki.js
Normal file
|
|
@ -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<string, any>} 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<PkiRequest>} pkiRequests
|
||||||
|
* @param {import('got').Got} client
|
||||||
|
* @return {Promise<Array<PkiResponse>>}
|
||||||
|
*/
|
||||||
|
async function getCertificates(pkiRequests, client) {
|
||||||
|
/** @type Array<PkiResponse> */
|
||||||
|
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,
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue