Skip to content

Commit

Permalink
feat: Add PKI capability (hashicorp#564)
Browse files Browse the repository at this point in the history
  • Loading branch information
snocorp authored Dec 5, 2024
1 parent 8b7eace commit 33b70ff
Show file tree
Hide file tree
Showing 7 changed files with 244 additions and 11 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
3 changes: 3 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions integrationTests/basic/integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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())
Expand Down Expand Up @@ -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');

Expand Down
61 changes: 54 additions & 7 deletions src/action.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand All @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
const auth = require('./auth');
const secrets = require('./secrets');
const pki = require('./pki');

module.exports = {
auth,
secrets
secrets,
pki
};
76 changes: 76 additions & 0 deletions src/pki.js
Original file line number Diff line number Diff line change
@@ -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,
};

0 comments on commit 33b70ff

Please sign in to comment.