diff --git a/api/models/site.js b/api/models/site.js index 44b2b9328..82561abdc 100644 --- a/api/models/site.js +++ b/api/models/site.js @@ -211,6 +211,11 @@ module.exports = (sequelize, DataTypes) => { type: DataTypes.STRING, allowNull: false, }, + awsBucketKeyUpdatedAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, config: { type: DataTypes.JSONB, defaultValue: {}, diff --git a/api/services/SiteBucketKeyRotator.js b/api/services/SiteBucketKeyRotator.js new file mode 100644 index 000000000..8a5e00ee9 --- /dev/null +++ b/api/services/SiteBucketKeyRotator.js @@ -0,0 +1,44 @@ +const { Site } = require('../models'); +const CFApiClient = require('../utils/cfApiClient'); + +async function rotateBucketKey(site) { + const cfApi = new CFApiClient(); + const serviceName = site.s3ServiceName; + const serviceBindingName = `${serviceName}-key`; + const serviceInstance = await cfApi.fetchServiceInstance(serviceName); + const credentials = await cfApi + .fetchCredentialBindingsInstance(serviceBindingName) + .catch((error) => { + // Return null to skip credentials delete if not found + // fetchCredentialBindingsInstance throws error + // with name string starting "Not found" when credentials + // do not exist + if (error.name.toLowerCase().trim().startswith('not found')) { + return null; + } + + throw error; + }); + + if (credentials) { + // Delete existing credential service if they exist + await cfApi.deleteServiceInstanceCredentials(credentials.guid); + await cfApi.sleep('3000'); + } + + await cfApi.createServiceKey(serviceInstance.name, serviceInstance.guid); +} + +async function rotateSitesBucketKeys(limit = 20) { + const sites = await Site.findAll({ + order: [['awsBucketKeyUpdatedAt', 'ASC']], + limit, + }); + + return Promise.allSettled(sites.map(site => rotateBucketKey(site))); +} + +module.exports = { + rotateBucketKey, + rotateSitesBucketKeys, +}; diff --git a/api/utils/cfApiClient.js b/api/utils/cfApiClient.js index 5a5eff48b..81e149a85 100644 --- a/api/utils/cfApiClient.js +++ b/api/utils/cfApiClient.js @@ -228,13 +228,20 @@ class CloudFoundryAPIClient { } fetchCredentialBindingsInstance(name) { - const path = `/v3/service_credential_bindings?names=${name}`; + const endpoint = `/v3/service_credential_bindings?names=${name}`; return this.accessToken() - .then(token => this.request('GET', path, token)) + .then(token => this.request('GET', endpoint, token)) .then(res => findEntity(res, name)); } + deleteServiceInstanceCredentials(guid) { + const endpoint = `/v3/service_credential_bindings/${guid}`; + + return this.accessToken() + .then(token => this.request('DELETE', endpoint, token)); + } + fetchServiceInstanceCredentials(name) { return this.accessToken() .then(token => this.request( @@ -332,5 +339,6 @@ CloudFoundryAPIClient.findEntity = findEntity; CloudFoundryAPIClient.firstEntity = firstEntity; CloudFoundryAPIClient.objToQueryParams = objToQueryParams; CloudFoundryAPIClient.buildRequestBody = buildRequestBody; +CloudFoundryAPIClient.sleep = sleep; module.exports = CloudFoundryAPIClient; diff --git a/ci/partials/rotate-bucket-keys.yml b/ci/partials/rotate-bucket-keys.yml new file mode 100644 index 000000000..6b8db2fcf --- /dev/null +++ b/ci/partials/rotate-bucket-keys.yml @@ -0,0 +1,7 @@ +platform: linux +inputs: [name: src] +outputs: [name: src] +run: + dir: src/admin-client + path: bash + args: [-c, yarn rotate-bucket-keys] \ No newline at end of file diff --git a/ci/pipeline-dev.yml b/ci/pipeline-dev.yml index d8cdcac5b..fc7e9d3b4 100644 --- a/ci/pipeline-dev.yml +++ b/ci/pipeline-dev.yml @@ -277,6 +277,30 @@ jobs: <<: *env-cf CF_APP_NAME: pages-queues-ui-((deploy-env)) + - name: nightly-site-bucket-key-rotator + plan: + - get: src + resource: pr-((deploy-env)) + passed: [set-pipeline] + # - get: nightly + # trigger: true + - get: node + - task: install-deps-admin-client + file: src/ci/partials/install-deps-api + image: node + - task: rotate-keys + platform: linux + inputs: [name: src] + outputs: [name: src] + run: + dir: src/admin-client + path: bash + args: [-c, yarn rotate-bucket-keys] + params: + <<: *env-cf + CF_APP_NAME: pages-((deploy-env)) + + - name: set-pipeline plan: - get: src diff --git a/migrations/20231027154728-site-aws-bucket-key-updated-at.js b/migrations/20231027154728-site-aws-bucket-key-updated-at.js new file mode 100644 index 000000000..ae05d8328 --- /dev/null +++ b/migrations/20231027154728-site-aws-bucket-key-updated-at.js @@ -0,0 +1,15 @@ +const TABLE = 'site'; +const COLUMN_NAME = 'awsBucketKeyUpdatedAt'; +const COLUMN_TYPE = { + type: 'timestamp', + notNull: true, + defaultValue: new String('now()'), +}; + +exports.up = async (db) => { + await db.addColumn(TABLE, COLUMN_NAME, COLUMN_TYPE); +}; + +exports.down = async (db) => { + await db.removeColumn(TABLE, COLUMN_NAME); +}; diff --git a/package.json b/package.json index 51bab2f1f..4e0938f50 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,8 @@ "migrate-build-notification-settings": "node ./scripts/migrate-build-notification-settings.js", "remove-bucket-website-configs": "node ./scripts/remove-bucket-website-configs.js", "check-object-paths": "node ./scripts/check-object-paths.js", - "queued-builds-check": "node ./scripts/queued-builds-check.js" + "queued-builds-check": "node ./scripts/queued-builds-check.js", + "rotate-bucket-keys": "node ./scripts/rotate-bucket-keys.js" }, "main": "index.js", "repository": { diff --git a/scripts/rotate-bucket-keys.js b/scripts/rotate-bucket-keys.js new file mode 100644 index 000000000..bb09faaf3 --- /dev/null +++ b/scripts/rotate-bucket-keys.js @@ -0,0 +1,15 @@ +const { + rotateSitesBucketKeys, +} = require('../api/services/SiteBucketKeyRotator'); + +async function main() { + const rotatedKeyServices = await rotateSitesBucketKeys(); + + // eslint-disable-next-line + rotatedKeyServices.map(result => { + // eslint-disable-next-line + console.log(JSON.stringify(result, null, 2)); + }); +} + +main();