diff --git a/libraries/access-token-encryptor/package.json b/libraries/access-token-encryptor/package.json index 2dd8195baf..7ddd515bc1 100644 --- a/libraries/access-token-encryptor/package.json +++ b/libraries/access-token-encryptor/package.json @@ -13,8 +13,12 @@ }, "author": "", "license": "AGPL-3.0-only", + "dependencies": { + "lodash": "^4.17.21" + }, "peerDependencies": { - "@overleaf/logger": "*" + "@overleaf/logger": "*", + "mongodb": "*" }, "devDependencies": { "@overleaf/logger": "*", diff --git a/libraries/access-token-encryptor/scripts/helpers/format-usage-stats.js b/libraries/access-token-encryptor/scripts/helpers/format-usage-stats.js new file mode 100644 index 0000000000..02ed58743d --- /dev/null +++ b/libraries/access-token-encryptor/scripts/helpers/format-usage-stats.js @@ -0,0 +1,20 @@ +function formatTokenUsageStats(STATS) { + const prettyStats = [] + const sortedStats = Object.entries(STATS).sort((a, b) => + a[0] > b[0] ? 1 : -1 + ) + const totalByName = {} + for (const [key, n] of sortedStats) { + const [name, version, collectionName, path, label] = key.split(':') + totalByName[name] = (totalByName[name] || 0) + n + prettyStats.push({ name, version, collectionName, path, label, n }) + } + for (const row of prettyStats) { + row.percentage = ((100 * row.n) / totalByName[row.name]) + .toFixed(2) + .padStart(6) + } + console.table(prettyStats) +} + +module.exports = { formatTokenUsageStats } diff --git a/libraries/access-token-encryptor/scripts/helpers/re-encrypt-tokens.js b/libraries/access-token-encryptor/scripts/helpers/re-encrypt-tokens.js new file mode 100644 index 0000000000..e2c1e67e50 --- /dev/null +++ b/libraries/access-token-encryptor/scripts/helpers/re-encrypt-tokens.js @@ -0,0 +1,106 @@ +const { ReadPreference } = require('mongodb') +const _ = require('lodash') +const { formatTokenUsageStats } = require('./format-usage-stats') + +const LOG_EVERY_IN_S = parseInt(process.env.LOG_EVERY_IN_S || '5', 10) +const DRY_RUN = !process.argv.includes('--dry-run=false') + +/** + * @param {AccessTokenEncryptor} accessTokenEncryptor + * @param {string} encryptedJson + * @return {Promise} + */ +async function reEncryptTokens(accessTokenEncryptor, encryptedJson) { + return new Promise((resolve, reject) => { + accessTokenEncryptor.decryptToJson(encryptedJson, (err, json) => { + if (err) return reject(err) + accessTokenEncryptor.encryptJson(json, (err, reEncryptedJson) => { + if (err) return reject(err) + resolve(reEncryptedJson) + }) + }) + }) +} + +/** + * @param {AccessTokenEncryptor} accessTokenEncryptor + * @param {Collection} collection + * @param {Object} paths + * @return {Promise<{}>} + */ +async function reEncryptTokensInCollection({ + accessTokenEncryptor, + collection, + paths, +}) { + const { collectionName } = collection + const stats = {} + + let processed = 0 + let updatedNUsers = 0 + let lastLog = 0 + const logProgress = () => { + if (DRY_RUN) { + console.warn( + `processed ${processed} | Would have updated ${updatedNUsers} users` + ) + } else { + console.warn(`processed ${processed} | Updated ${updatedNUsers} users`) + } + } + + const projection = { _id: 1 } + for (const path of Object.values(paths)) { + projection[path] = 1 + } + const cursor = collection.find( + {}, + { + readPreference: ReadPreference.SECONDARY, + projection, + } + ) + + for await (const doc of cursor) { + processed++ + + let update = null + for (const [name, path] of Object.entries(paths)) { + const blob = _.get(doc, path) + if (!blob) continue + // Schema: LABEL:SALT:CIPHERTEXT:IV + const [label, , , iv] = blob.split(':', 4) + const version = iv ? 'v2' : 'v1' + + const key = [name, version, collectionName, path, label].join(':') + stats[key] = (stats[key] || 0) + 1 + + if (version === 'v1') { + update = update || {} + update[path] = await reEncryptTokens(accessTokenEncryptor, blob) + } + } + + if (Date.now() - lastLog >= LOG_EVERY_IN_S * 1000) { + logProgress() + lastLog = Date.now() + } + if (update) { + updatedNUsers++ + + const { _id } = doc + if (DRY_RUN) { + console.log('Would upgrade tokens for user', _id, Object.keys(update)) + } else { + console.log('Upgrading tokens for user', _id, Object.keys(update)) + await collection.updateOne({ _id }, { $set: update }) + } + } + } + logProgress() + formatTokenUsageStats(stats) +} + +module.exports = { + reEncryptTokensInCollection, +} diff --git a/package-lock.json b/package-lock.json index 6ecad6d443..53ce2a706f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,6 +80,9 @@ "name": "@overleaf/access-token-encryptor", "version": "2.2.0", "license": "AGPL-3.0-only", + "dependencies": { + "lodash": "^4.17.21" + }, "devDependencies": { "@overleaf/logger": "*", "bunyan": "^1.8.15", @@ -90,7 +93,8 @@ "sinon": "^9.2.4" }, "peerDependencies": { - "@overleaf/logger": "*" + "@overleaf/logger": "*", + "mongodb": "*" } }, "libraries/logger": { @@ -34322,6 +34326,7 @@ "@opentelemetry/sdk-trace-base": "^1.2.0", "@opentelemetry/sdk-trace-web": "^1.2.0", "@opentelemetry/semantic-conventions": "^1.2.0", + "@overleaf/access-token-encryptor": "*", "@overleaf/logger": "*", "@overleaf/metrics": "*", "@overleaf/o-error": "*", @@ -41978,6 +41983,7 @@ "@overleaf/logger": "*", "bunyan": "^1.8.15", "chai": "^4.3.6", + "lodash": "^4.17.21", "mocha": "^10.2.0", "nock": "0.15.2", "sandboxed-module": "^2.0.4", @@ -43467,6 +43473,7 @@ "@opentelemetry/sdk-trace-base": "^1.2.0", "@opentelemetry/sdk-trace-web": "^1.2.0", "@opentelemetry/semantic-conventions": "^1.2.0", + "@overleaf/access-token-encryptor": "*", "@overleaf/logger": "*", "@overleaf/metrics": "*", "@overleaf/o-error": "*", diff --git a/services/web/package.json b/services/web/package.json index 8ce337777f..a7c043f112 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -97,6 +97,7 @@ "@opentelemetry/sdk-trace-base": "^1.2.0", "@opentelemetry/sdk-trace-web": "^1.2.0", "@opentelemetry/semantic-conventions": "^1.2.0", + "@overleaf/access-token-encryptor": "*", "@overleaf/logger": "*", "@overleaf/metrics": "*", "@overleaf/o-error": "*", diff --git a/services/web/scripts/count_encrypted_access_tokens.js b/services/web/scripts/count_encrypted_access_tokens.js index 223aa13772..321cbad3e5 100644 --- a/services/web/scripts/count_encrypted_access_tokens.js +++ b/services/web/scripts/count_encrypted_access_tokens.js @@ -5,6 +5,9 @@ process.env.MONGO_SOCKET_TIMEOUT = const { ReadPreference } = require('mongodb') const { db, waitForDb } = require('../app/src/infrastructure/mongodb') const _ = require('lodash') +const { + formatTokenUsageStats, +} = require('@overleaf/access-token-encryptor/scripts/helpers/format-usage-stats') const CASES = { users: { @@ -57,22 +60,7 @@ async function main() { Object.assign(STATS, stats) } - const prettyStats = [] - const sortedStats = Object.entries(STATS).sort((a, b) => - a[0] > b[0] ? 1 : -1 - ) - const totalByName = {} - for (const [key, n] of sortedStats) { - const [name, version, collectionName, path, label] = key.split(':') - totalByName[name] = (totalByName[name] || 0) + n - prettyStats.push({ name, version, collectionName, path, label, n }) - } - for (const row of prettyStats) { - row.percentage = ((100 * row.n) / totalByName[row.name]) - .toFixed(2) - .padStart(6) - } - console.table(prettyStats) + formatTokenUsageStats() } main()