Skip to content
This repository has been archived by the owner on Nov 12, 2019. It is now read-only.

Commit

Permalink
Add the claimCredentials endpoint
Browse files Browse the repository at this point in the history
claimCredentials receives a RSA2048 signature of the identity document
and returns the credentials for the worker.

The document is validated according using the iid-verify [1] package.

Informations about the instance, like instance-id and region
are extracted from the identity document.

The given instance must be in a running state, otherwise the endpoint
will fail.

As iid-verify requires node 10 or newer, we upgrade the required node
version, as well as packages that fail to build with this node version.

[1] https://www.npmjs.com/package/iid-verify
  • Loading branch information
Wander Lairson Costa committed Aug 29, 2018
1 parent 5bcca65 commit 484b2ae
Show file tree
Hide file tree
Showing 13 changed files with 811 additions and 91 deletions.
3 changes: 3 additions & 0 deletions config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ defaults:
credentials:
clientId: !env TASKCLUSTER_CLIENT_ID
accessToken: !env TASKCLUSTER_ACCESS_TOKEN
issuedCredRootCred:
clientId: !env ROOT_TASKCLUSTER_CLIENT_ID
accessToken: !env ROOT_TASKCLUSTER_ACCESS_TOKEN
development:
app:
id: 'ec2-manager-development'
Expand Down
84 changes: 84 additions & 0 deletions lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
const API = require('taskcluster-lib-api');
const assert = require('assert');
const crypto = require('crypto');
const {validate} = require('./identity-document');
const fs = require('fs');
const taskcluster = require('taskcluster-client');
const {util} = require('node-forge');

const {getQueueStats, getQueueUrl, purgeQueue} = require('sqs-simple');

Expand Down Expand Up @@ -29,6 +33,9 @@ let api = new API({
'tagger',
'monitor',
],
errorCodes: {
AuthorizationFailed: 403,
},
});

/**
Expand Down Expand Up @@ -543,6 +550,83 @@ api.declare({
return x;
})});
});

api.declare({
method: 'delete',
route: '/credentials',
name: 'claimCredentials',
title: 'Request worker credentials',
stability: API.stability.experimental,
input: 'claim-credentials-request.json#',
description: [
'Yield credentials for the worker running in the given instance.',
].join(' '),
}, async function(req, res) {
let strDoc = Buffer.from(req.body.document, 'base64').toString();
try {
validate(strDoc, req.body.signature);
} catch (err) {
log.error({
err: e,
signature: req.body.signature,
document: strDoc,
}, 'Failure to verify signature');
return res.reportError('AuthorizationFailed', 'Failure to verify authorization');
}

let doc = JSON.parse(strDoc);
this.state.claimCredentials({
region: doc.region,
id: doc.instanceId,
});
});

api.declare({
method: 'get',
route: '/credentials',
name: 'getCredentials',
title: 'Request worker credentials',
stability: API.stability.experimental,
input: 'claim-credentials-request.json#',
output: 'claim-credentials-response.json#',
description: [
'Yield credentials for the worker running in the given instance.',
].join(' '),
}, async function(req, res) {
let strDoc = Buffer.from(req.body.document, 'base64').toString();

try {
validate(strDoc, req.body.signature);
} catch (e) {
log.error({
err: e,
signature: req.body.signature,
document: strDoc,
}, 'Failure to verify signature');
return res.reportError('AuthorizationFailed', 'Failure to verify authorization');
}

let doc = JSON.parse(strDoc);

if (!await this.state.canClaimCredentials({region: doc.region, id: doc.instanceId})) {
return res.reportError('AuthorizationFailed', 'Failure to verify authorization');
}

let provisionerId = this.id;
let instance = await this.state.listInstances({region: doc.region, id: doc.instanceId});
assert.equal(instance.length, 1);
instance = instance[0];

return res.reply(taskcluster.createTemporaryCredentials({
scopes: [
`assume:worker-type:${provisionerId}/${instance.workerType}`,
`assume:worker-id:${instance.region}:${instance.id}`,
],
expiry: taskcluster.fromNow('96 hours'),
credentials: this.credentials,
}));
});

/*****************************************************************************/
/*****************************************************************************/
/* NOTE: ALL FOLLOWING METHODS ARE INTERNAL ONLY AND ARE NOT */
Expand Down
38 changes: 38 additions & 0 deletions lib/identity-document.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const assert = require('assert');
let verify = require('iid-verify');

const AWS_PUBLIC_CERTIFICATE =
`-----BEGIN CERTIFICATE-----
MIIC7TCCAq0CCQCWukjZ5V4aZzAJBgcqhkjOOAQDMFwxCzAJBgNVBAYTAlVTMRkw
FwYDVQQIExBXYXNoaW5ndG9uIFN0YXRlMRAwDgYDVQQHEwdTZWF0dGxlMSAwHgYD
VQQKExdBbWF6b24gV2ViIFNlcnZpY2VzIExMQzAeFw0xMjAxMDUxMjU2MTJaFw0z
ODAxMDUxMjU2MTJaMFwxCzAJBgNVBAYTAlVTMRkwFwYDVQQIExBXYXNoaW5ndG9u
IFN0YXRlMRAwDgYDVQQHEwdTZWF0dGxlMSAwHgYDVQQKExdBbWF6b24gV2ViIFNl
cnZpY2VzIExMQzCCAbcwggEsBgcqhkjOOAQBMIIBHwKBgQCjkvcS2bb1VQ4yt/5e
ih5OO6kK/n1Lzllr7D8ZwtQP8fOEpp5E2ng+D6Ud1Z1gYipr58Kj3nssSNpI6bX3
VyIQzK7wLclnd/YozqNNmgIyZecN7EglK9ITHJLP+x8FtUpt3QbyYXJdmVMegN6P
hviYt5JH/nYl4hh3Pa1HJdskgQIVALVJ3ER11+Ko4tP6nwvHwh6+ERYRAoGBAI1j
k+tkqMVHuAFcvAGKocTgsjJem6/5qomzJuKDmbJNu9Qxw3rAotXau8Qe+MBcJl/U
hhy1KHVpCGl9fueQ2s6IL0CaO/buycU1CiYQk40KNHCcHfNiZbdlx1E9rpUp7bnF
lRa2v1ntMX3caRVDdbtPEWmdxSCYsYFDk4mZrOLBA4GEAAKBgEbmeve5f8LIE/Gf
MNmP9CM5eovQOGx5ho8WqD+aTebs+k2tn92BBPqeZqpWRa5P/+jrdKml1qx4llHW
MXrs3IgIb6+hUIB+S8dz8/mmO0bpr76RoZVCXYab2CZedFut7qc3WUH9+EUAH5mw
vSeDCOUMYQR7R9LINYwouHIziqQYMAkGByqGSM44BAMDLwAwLAIUWXBlk40xTwSw
7HX32MxXYruse9ACFBNGmdX2ZBrVNGrN9N2f6ROk0k9K
-----END CERTIFICATE-----`;

// Returns the Identity Document if the signature is valid, null otherwise.
//
// References:
// - https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-identity-documents.html
// - http://www.pkiglobe.org/pkcs7.html
// - https://tools.ietf.org/html/rfc2315
function validate(doc, signedData) {
if (!verify(AWS_PUBLIC_CERTIFICATE, doc, signedData)) {
throw new Error(`Document isn't valid. Document:\n${doc}\nSigned Data:\n${signedData}`);
}
};

module.exports = {
validate,
};
1 change: 1 addition & 0 deletions lib/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ let load = loader({
ec2: ec2,
runaws: runaws,
lsChecker: lsChecker,
credentials: cfg.taskcluster.issuedCredRootCred,
pricing,
tagger,
monitor,
Expand Down
60 changes: 54 additions & 6 deletions lib/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,17 @@ function assertValidInstance(instance) {
'lastEvent',
];

let allowedKeys = [
'hasClaimedCredentials',
];

let dateKeys = [
'launched',
'lastEvent',
];

return _checker(instance, {requiredKeys, dateKeys});
}
return _checker(instance, {requiredKeys, allowedKeys, dateKeys});
}

function assertValidTermination(termination) {
let requiredKeys = [
Expand Down Expand Up @@ -321,12 +325,14 @@ class State {
imageId,
launched,
lastEvent,
hasClaimedCredentials=false,
} = assertValidInstance(instance);

let query = [
'INSERT INTO instances',
'(id, "workerType", region, az, "instanceType", state, "imageId", launched, "lastEvent")',
'VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)',
'(id, "workerType", region, az, "instanceType", state, "imageId", ' +
'launched, "lastEvent", "hasClaimedCredentials")',
'VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)',
].join(' ');

let values = [
Expand All @@ -339,6 +345,7 @@ class State {
imageId,
launched,
lastEvent,
hasClaimedCredentials,
];

let result = await this.runQuery({query, values, client});
Expand All @@ -358,12 +365,14 @@ class State {
imageId,
launched,
lastEvent,
hasClaimedCredentials=false,
} = assertValidInstance(instance);

let query = [
'INSERT INTO instances',
'(id, "workerType", region, az, "instanceType", state, "imageId", launched, "lastEvent")',
'VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)',
'(id, "workerType", region, az, "instanceType", state, "imageId", ' +
'launched, "lastEvent", "hasClaimedCredentials")',
'VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)',
'ON CONFLICT (region, id) DO',
'UPDATE SET state = EXCLUDED.state',
'WHERE instances."lastEvent" IS NULL OR instances."lastEvent" < EXCLUDED."lastEvent";',
Expand All @@ -379,6 +388,7 @@ class State {
imageId,
launched,
lastEvent,
hasClaimedCredentials,
];

await this.runQuery({query, values, client});
Expand Down Expand Up @@ -406,6 +416,44 @@ class State {
return result.rowCount === 1;
}

async claimCredentials({region, id}, client) {
assert.equal(typeof region, 'string');
assert.equal(typeof id, 'string');

const text = 'UPDATE instances SET "hasClaimedCredentials" = TRUE WHERE region = $1 AND id = $2';
const values = [region, id];
const name = 'update-claimed-credentials';

let result;
if (client) {
result = await client.query({text, values, name});
} else {
result = await this._pgpool.query({text, values, name});
}

assert.equal(result.rowCount, 1, 'updating instance state had incorrect rowCount');
}

async canClaimCredentials({region, id}, client) {
let instances = await this.listInstances({id, region});

if (instances.length != 1) {
log.error({region, id, numberOfInstances: instances.length}, 'The number of instances is different than 1');
return false;
}

let instance = instances[0];
if (instance.state != 'running' || instance.hasClaimedCredentials) {
log.error(
{region, id, state: instance.state, claimed: instance.hasClaimedCredentials},
'Instance is in invalid state'
);
return false;
}

return true;
}

/**
* Stop tracking an instance
*/
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
"author": "John Ford <john@johnford.org>",
"license": "MPL-2.0",
"engines": {
"node": "^9.11.1",
"yarn": "^1.0.0"
"node": ">=10"
},
"scripts": {
"heroku-prebuild": "echo $SOURCE_VERSION > .git-version",
Expand All @@ -22,10 +21,13 @@
"babel-compile": "^2.0.0",
"babel-preset-taskcluster": "^3.0.0",
"bluebird": "^3.5.0",
"iid-verify": "^1.0.0",
"lodash": "^4.17.4",
"node-forge": "^0.7.2",
"nyc": "^11.0.3",
"pg": "^7.4.0",
"pg-pool": "^2.0.3",
"promise-retry": "^1.1.1",
"redis": "^2.7.1",
"sinon": "^2.3.8",
"sqs-simple": "^1.3.0",
Expand Down
19 changes: 19 additions & 0 deletions schemas/claim-credentials-request.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
$schema: http://json-schema.org/draft-04/schema#
title: "Get Credentials Request"
description: |
An object with the Instance Identity Document and its corresponding signature
type: object
properties:
signature:
type: string
description:
The signature if the Identity Document, base64 encoded
document:
type: string
description:
The identity document
additionalProperties: false
required:
- signature
- document

18 changes: 18 additions & 0 deletions schemas/claim-credentials-response.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
$schema: http://json-schema.org/draft-04/schema#
title: "Get Secret Response"
description: |
The temporary credentials for the worker
type: object
properties:
clientId:
type: string
accessToken:
type: string
certificate:
type: string
additionalProperties: false
required:
- clientId
- accessToken
- certificate

1 change: 1 addition & 0 deletions sql/create-db.sql
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ CREATE TABLE IF NOT EXISTS instances (
-- to ensure that we have correct ordering of cloud watch
-- events
touched TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"hasClaimedCredentials" BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY(id, region)
);
-- Automatically keep instances touched parameter up to date
Expand Down
Loading

0 comments on commit 484b2ae

Please sign in to comment.