diff --git a/CHANGELOG.md b/CHANGELOG.md index 9be2dffdf..0875f8a52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ All notable changes to this project will be documented in this file. The format - New key results are now given a start value of 0, a target value of 100, and percentage as unit of measurement by default. +- The API authorization mechanism has been reworked. The API now accepts a pair + of `okr-client-id` and `okr-client-secret` headers to authorize clients. + The interface for managing client credentials can be found in the item + navigation bar. The existing `okr-team-secret` header is considered deprecated + and will continue to work for existing clients until migrated. ### Fixed diff --git a/firestore.rules b/firestore.rules index 8d391cf01..c9ac22e9c 100644 --- a/firestore.rules +++ b/firestore.rules @@ -31,7 +31,7 @@ service cloud.firestore { // Check if the document the user tries to access has an organization - this means that is is a product or department let hasOrgProperty = 'organization' in request.resource.data; - let isAdminFromOrgOfProdOrDep = userIsAdmin && hasOrgProperty && getAfter(request.resource.data.organization).data.id in userDoc.data.admin; + let isAdminFromOrgOfProdOrDep = userIsAdmin && hasOrgProperty && getAfter(request.resource.data.organization).id in userDoc.data.admin; // Check if the user has access to the document which is an organization (given that the first check is false) let isAdminOfOrg = userIsAdmin && request.resource.data.id in userDoc.data.admin; @@ -216,6 +216,16 @@ service cloud.firestore { allow delete: if isSuperAdmin() || isMemberOfParent(document, 'kpis') || isAdminOfParent(document, 'kpis'); } + match /apiClients/{document} { + allow read: if isSignedIn(); + allow write: if isSuperAdmin() || isMemberOfParent(document, 'apiClients') || isAdminOfParent(document, 'apiClients'); + + match /secrets/{key} { + allow read: if false; + allow write: if isSuperAdmin() || isMemberOfParent(document, 'apiClients') || isAdminOfParent(document, 'apiClients'); + } + } + match /slugs/{document} { allow read: if true; allow write: if false; diff --git a/functions/api/helpers.js b/functions/api/helpers.js index 5807213b3..36465b6a6 100644 --- a/functions/api/helpers.js +++ b/functions/api/helpers.js @@ -1,3 +1,4 @@ +import crypto from 'crypto'; import { FieldValue, getFirestore } from 'firebase-admin/firestore'; import { endOfDay, startOfDay, setHours, isWithinInterval, sub } from 'date-fns'; @@ -209,3 +210,82 @@ function isKPIStale(updateFrequency, progressRecord) { return null; } } + +/** + * Return true if the client is authorized to perform the requested API + * operation. Set response data and return false in case the client is not + * authorized based on provided request parameters. + * + * `parentRef` is the item to check API authorization against. + * `req` is the HTTP request object. + * `res` is the HTTP response object. + */ +export async function checkApiAuth(parentRef, req, res) { + const parentData = await parentRef.get().then((snapshot) => snapshot.data()); + + const { + 'okr-client-id': clientId, + 'okr-client-secret': clientSecret, + 'okr-team-secret': teamSecret, + } = req.matchedData; + + if (clientId && clientSecret) { + const db = getFirestore(); + const apiClientRef = await db + .collection('apiClients') + .where('parent', '==', parentRef) + .where('clientId', '==', clientId) + .where('archived', '==', false) + .limit(1) + .get() + .then((snapshot) => (!snapshot.empty ? snapshot.docs[0].ref : null)); + + if (!apiClientRef) { + const { name: parentName } = parentData; + res.status(401).json({ + message: + `No API client \`${clientId}\` exists for '${parentName}'. Please ` + + 'create one in the OKR Tracker integrations admin.', + }); + return false; + } + + const apiClientSecret = await apiClientRef + .collection('secrets') + .where('archived', '==', false) + .orderBy('created', 'desc') + .limit(1) + .get() + .then((snapshot) => (!snapshot.empty ? snapshot.docs[0].data() : null)); + + const hashedSecret = crypto.createHash('sha256').update(clientSecret).digest('hex'); + + if (!apiClientSecret || apiClientSecret.secret !== hashedSecret) { + res.status(401).json({ + message: + `Invalid client secret provided for API client \`${clientId}\`. ` + + 'Check the OKR Tracker integrations admin for how to rotate the secret.', + }); + return false; + } + + await apiClientRef.update({ used: FieldValue.serverTimestamp() }); + return true; + } + + if (!parentData.secret) { + res.status(401).json({ + message: + `'${parentData.name}' is not set up for API usage. Please set ` + + 'a secret through the OKR Tracker integrations admin.', + }); + return false; + } + + if (parentData.secret !== teamSecret) { + res.status(401).json({ message: 'Wrong `okr-team-secret`' }); + return false; + } + + return true; +} diff --git a/functions/api/index.js b/functions/api/index.js index 3ebe920b9..ed0b9985b 100644 --- a/functions/api/index.js +++ b/functions/api/index.js @@ -1,6 +1,7 @@ import functions from 'firebase-functions'; import express from 'express'; +import rateLimit from 'express-rate-limit'; import cors from 'cors'; import morgan from 'morgan'; @@ -12,9 +13,16 @@ import kpiRoutes from './routes/kpi.js'; import statusRoutes from './routes/status.js'; import userRoutes from './routes/user.js'; +const apiLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, // max 100 requests per window + message: 'Too many requests, please try again later.', +}); + const app = express(); app.use(cors()); +app.use(apiLimiter); app.use(express.json()); app.use(morgan('combined')); diff --git a/functions/api/routes/keyres.js b/functions/api/routes/keyres.js index 83b552598..f5260bd01 100644 --- a/functions/api/routes/keyres.js +++ b/functions/api/routes/keyres.js @@ -2,37 +2,30 @@ import express from 'express'; import validator from 'express-validator'; import { getFirestore } from 'firebase-admin/firestore'; +import { checkApiAuth } from '../helpers.js'; import { commentValidator, idValidator, progressValidator, - teamSecretValidator, + clientSecretValidator, } from '../validators.js'; +import validateRules from '../validateRules.js'; -const { param, matchedData, validationResult } = validator; +const { param, matchedData } = validator; const router = express.Router(); router.post( '/:id', - teamSecretValidator, + clientSecretValidator, idValidator, progressValidator, commentValidator, + validateRules, async (req, res) => { - const result = validationResult(req); - - if (!result.isEmpty()) { - res.status(400).json({ - message: 'Invalid request data', - errors: result.mapped(), - }); - return; - } - - const { 'okr-team-secret': teamSecret, id, progress, comment } = matchedData(req); + const { id, progress, comment } = req.matchedData; const db = getFirestore(); - const collection = await db.collection('keyResults'); + const collection = db.collection('keyResults'); let keyRes; try { @@ -47,19 +40,7 @@ router.post( const { parent } = keyRes.data(); - const parentData = await parent.get().then((snapshot) => snapshot.data()); - - if (!parentData.secret) { - res - .status(401) - .send( - `'${parentData.name}' is not set up for API usage. Please set ` + - 'a secret using the OKR Tracker admin interface.' - ); - return; - } - if (parentData.secret !== teamSecret) { - res.status(401).send('Wrong okr-team-secret'); + if (!(await checkApiAuth(parent, req, res))) { return; } diff --git a/functions/api/routes/kpi.js b/functions/api/routes/kpi.js index 5784e879a..24dfa589b 100644 --- a/functions/api/routes/kpi.js +++ b/functions/api/routes/kpi.js @@ -8,38 +8,30 @@ import { isArchived, refreshKPILatestValue, updateKPIProgressionValue, + checkApiAuth, } from '../helpers.js'; import { commentValidator, dateValidator, idValidator, progressValidator, - teamSecretValidator, + clientSecretValidator, valueValidator, } from '../validators.js'; +import validateRules from '../validateRules.js'; -const { matchedData, validationResult } = validator; +const { matchedData } = validator; const router = express.Router(); router.post( '/:id', - teamSecretValidator, + clientSecretValidator, idValidator, progressValidator, commentValidator, + validateRules, async (req, res) => { - const result = validationResult(req); - - if (!result.isEmpty()) { - res.status(400).json({ - message: 'Invalid request data', - errors: result.mapped(), - }); - return; - } - - const { 'okr-team-secret': teamSecret, id, progress, comment } = matchedData(req); - + const { id, progress, comment } = req.matchedData; const db = getFirestore(); try { @@ -54,19 +46,7 @@ router.post( const { parent } = kpi.data(); - const parentData = await parent.get().then((snapshot) => snapshot.data()); - - if (!parentData.secret) { - res - .status(401) - .send( - `'${parentData.name}' is not set up for API usage. Please set ` + - 'a secret using the OKR Tracker admin interface.' - ); - return; - } - if (parentData.secret !== teamSecret) { - res.status(401).send('Wrong okr-team-secret'); + if (!(await checkApiAuth(parent, req, res))) { return; } @@ -188,25 +168,15 @@ router.get('/:id/values', idValidator, async (req, res) => { router.put( '/:id/values/:date', - teamSecretValidator, + clientSecretValidator, idValidator, dateValidator, valueValidator, commentValidator, + validateRules, async (req, res) => { - const result = validationResult(req); - - if (!result.isEmpty()) { - res.status(400).json({ - message: 'Invalid request data', - errors: result.mapped(), - }); - return; - } - - const { 'okr-team-secret': teamSecret, id, date, value, comment } = matchedData(req); + const { id, date, value, comment } = req.matchedData; const formattedDate = format(date, 'yyyy-MM-dd'); - const db = getFirestore(); try { @@ -220,18 +190,8 @@ router.put( } const { parent } = kpi.data(); - const parentData = await parent.get().then((snapshot) => snapshot.data()); - if (!parentData.secret) { - res.status(401).json({ - message: - `'${parentData.name}' is not set up for API usage. Please set ` + - 'a secret using the OKR Tracker admin interface.', - }); - return; - } - if (parentData.secret !== teamSecret) { - res.status(401).json({ message: 'Wrong okr-team-secret' }); + if (!(await checkApiAuth(parent, req, res))) { return; } @@ -250,21 +210,12 @@ router.put( router.delete( '/:id/values/:date', - teamSecretValidator, + clientSecretValidator, idValidator, dateValidator, + validateRules, async (req, res) => { - const result = validationResult(req); - - if (!result.isEmpty()) { - res.status(400).json({ - message: 'Invalid request data', - errors: result.mapped(), - }); - return; - } - - const { 'okr-team-secret': teamSecret, id, date } = matchedData(req); + const { id, date } = req.matchedData; const formattedDate = format(date, 'yyyy-MM-dd'); const db = getFirestore(); @@ -280,18 +231,8 @@ router.delete( } const { parent } = kpi.data(); - const parentData = await parent.get().then((snapshot) => snapshot.data()); - if (!parentData.secret) { - res.status(401).json({ - message: - `'${parentData.name}' is not set up for API usage. Please set ` + - 'a secret using the OKR Tracker admin interface.', - }); - return; - } - if (parentData.secret !== teamSecret) { - res.status(401).json({ message: 'Wrong okr-team-secret' }); + if (!(await checkApiAuth(parent, req, res))) { return; } diff --git a/functions/api/validateRules.js b/functions/api/validateRules.js new file mode 100644 index 000000000..9c0599d85 --- /dev/null +++ b/functions/api/validateRules.js @@ -0,0 +1,17 @@ +import { matchedData, validationResult } from 'express-validator'; + +const validateRules = (req, res, next) => { + const errors = validationResult(req); + + if (errors.isEmpty()) { + req.matchedData = matchedData(req); + return next(); + } + + return res.status(400).json({ + message: 'Invalid request data', + errors: errors.mapped(), + }); +}; + +export default validateRules; diff --git a/functions/api/validators.js b/functions/api/validators.js index de9ccff7c..29edc886c 100644 --- a/functions/api/validators.js +++ b/functions/api/validators.js @@ -1,11 +1,31 @@ import validator from 'express-validator'; -const { body, header, param } = validator; +const { body, header, param, oneOf } = validator; -export const teamSecretValidator = header('okr-team-secret') - .not() - .isEmpty() - .withMessage('The `okr-team-secret` header is required'); +// Allow usage of the `okr-team-secret` header for now, until +// all existing clients are migrated. +export const clientSecretValidator = oneOf( + [ + header('okr-team-secret') + .not() + .isEmpty() + .withMessage('The `okr-team-secret` header is required'), + [ + header('okr-client-id') + .not() + .isEmpty() + .withMessage('The `okr-client-id` header is required'), + header('okr-client-secret') + .not() + .isEmpty() + .withMessage('The `okr-client-secret` header is required'), + ], + ], + { + message: + 'A pair of `okr-client-id`/`okr-client-secret` headers or an `okr-team-secret` header is required', + } +); export const adminSecretValidator = header('okr-admin-secret') .not() diff --git a/functions/package-lock.json b/functions/package-lock.json index 42d6a4f08..8e68077c1 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -14,7 +14,7 @@ "date-fns": "^2.27.0", "express": "^4.18.2", "express-rate-limit": "^6.4.0", - "express-validator": "^6.14.0", + "express-validator": "^7.0.1", "firebase-admin": "^11.4.1", "firebase-functions": "^3.24.1", "google-auth-library": "^7.11.0", @@ -1406,12 +1406,12 @@ } }, "node_modules/express-validator": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-6.14.0.tgz", - "integrity": "sha512-ZWHJfnRgePp3FKRSKMtnZVnD1s8ZchWD+jSl7UMseGIqhweCo1Z9916/xXBbJAa6PrA3pUZfkOvIsHZG4ZtIMw==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.0.1.tgz", + "integrity": "sha512-oB+z9QOzQIE8FnlINqyIFA8eIckahC6qc8KtqLdLJcU3/phVyuhXH3bA4qzcrhme+1RYaCSwrq+TlZ/kAKIARA==", "dependencies": { "lodash": "^4.17.21", - "validator": "^13.7.0" + "validator": "^13.9.0" }, "engines": { "node": ">= 8.0.0" @@ -3307,9 +3307,9 @@ } }, "node_modules/validator": { - "version": "13.7.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz", - "integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw==", + "version": "13.11.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz", + "integrity": "sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==", "engines": { "node": ">= 0.10" } @@ -4562,12 +4562,12 @@ "requires": {} }, "express-validator": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-6.14.0.tgz", - "integrity": "sha512-ZWHJfnRgePp3FKRSKMtnZVnD1s8ZchWD+jSl7UMseGIqhweCo1Z9916/xXBbJAa6PrA3pUZfkOvIsHZG4ZtIMw==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.0.1.tgz", + "integrity": "sha512-oB+z9QOzQIE8FnlINqyIFA8eIckahC6qc8KtqLdLJcU3/phVyuhXH3bA4qzcrhme+1RYaCSwrq+TlZ/kAKIARA==", "requires": { "lodash": "^4.17.21", - "validator": "^13.7.0" + "validator": "^13.9.0" } }, "extend": { @@ -5999,9 +5999,9 @@ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" }, "validator": { - "version": "13.7.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz", - "integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw==" + "version": "13.11.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz", + "integrity": "sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==" }, "vary": { "version": "1.1.2", diff --git a/functions/package.json b/functions/package.json index 599b74454..50e407d82 100644 --- a/functions/package.json +++ b/functions/package.json @@ -22,7 +22,7 @@ "date-fns": "^2.27.0", "express": "^4.18.2", "express-rate-limit": "^6.4.0", - "express-validator": "^6.14.0", + "express-validator": "^7.0.1", "firebase-admin": "^11.4.1", "firebase-functions": "^3.24.1", "google-auth-library": "^7.11.0", diff --git a/public/openapi.yaml b/public/openapi.yaml index 4d42731f6..ccf7d7606 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -21,7 +21,7 @@ x-google-backend: path_translation: APPEND_PATH_TO_ADDRESS protocol: h2 security: - - api_key: [] + - apiKey: [] paths: /kpi: @@ -60,7 +60,9 @@ paths: summary: Post KPI progression. operationId: postKPI security: - - api_key: [] + - apiKey: [] + apiClientId: [] + apiClientSecret: [] parameters: - name: id in: path @@ -81,10 +83,6 @@ paths: example: progress: 123 comment: A fine measurement - - name: okr-team-secret - in: header - description: Identify yourself with the secret you chose in your Team's settings - type: string responses: '200': description: A successful response @@ -131,13 +129,13 @@ paths: required: true type: string format: date - - name: okr-team-secret - in: header - description: Identify yourself with the secret you chose in your Team's settings - type: string put: summary: Create or update KPI progression value. operationId: updateKPIProgressionValue + security: + - apiKey: [] + apiClientId: [] + apiClientSecret: [] parameters: - name: value in: body @@ -170,6 +168,10 @@ paths: delete: summary: Delete KPI progression value. operationId: deleteKPIProgressionValue + security: + - apiKey: [] + apiClientId: [] + apiClientSecret: [] responses: 200: description: A successful response @@ -188,8 +190,6 @@ paths: get: summary: Get key result. operationId: getKeyRes - security: - - api_key: [] parameters: - name: id in: path @@ -209,7 +209,9 @@ paths: summary: Post key result progression. operationId: postKeyRes security: - - api_key: [] + - apiKey: [] + apiClientId: [] + apiClientSecret: [] parameters: - name: id in: path @@ -262,7 +264,8 @@ paths: summary: Update user details. operationId: patchUser security: - - api_key: [] + - apiKey: [] + okrAdminSecret: [] parameters: - name: id in: path @@ -283,10 +286,6 @@ paths: example: displayName: Foo Bar position: director - - name: okr-admin-secret - in: header - description: OKR Tracker admin secret value - type: string responses: '200': description: A successful response @@ -303,10 +302,24 @@ paths: $ref: '#/responses/Unavailable' securityDefinitions: - api_key: - type: "apiKey" - name: "x-api-key" - in: "header" + apiKey: + type: apiKey + name: x-api-key + in: header + apiClientId: + type: apiKey + name: okr-client-id + in: header + description: Identify yourself with the identifier of your API client + apiClientSecret: + type: apiKey + name: okr-client-secret + in: header + description: Identify yourself with the secret of your API client + okrAdminSecret: + type: apiKey + name: okr-admin-secret + in: header definitions: Keyres: diff --git a/src/components/ApiClientCard.vue b/src/components/ApiClientCard.vue new file mode 100644 index 000000000..d7507bd7e --- /dev/null +++ b/src/components/ApiClientCard.vue @@ -0,0 +1,311 @@ + + + + + diff --git a/src/components/ApiClientTag.vue b/src/components/ApiClientTag.vue new file mode 100644 index 000000000..a7e4c70b3 --- /dev/null +++ b/src/components/ApiClientTag.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/src/components/Navigation/SiteHeader.vue b/src/components/Navigation/SiteHeader.vue index 49734fe15..b38643d61 100644 --- a/src/components/Navigation/SiteHeader.vue +++ b/src/components/Navigation/SiteHeader.vue @@ -15,18 +15,27 @@ - + @@ -47,7 +56,7 @@ diff --git a/src/components/modals/ApiClientModal.vue b/src/components/modals/ApiClientModal.vue new file mode 100644 index 000000000..b10c504da --- /dev/null +++ b/src/components/modals/ApiClientModal.vue @@ -0,0 +1,87 @@ + + + diff --git a/src/db/ApiClient/ApiClient.js b/src/db/ApiClient/ApiClient.js new file mode 100644 index 000000000..679915c63 --- /dev/null +++ b/src/db/ApiClient/ApiClient.js @@ -0,0 +1,101 @@ +import { db, auth, serverTimestamp } from '@/config/firebaseConfig'; +import props from './props'; +import { + validateCreateProps, + validateUpdateProps, + createDocument, + updateDocument, +} from '../common'; + +const collection = db.collection('apiClients'); + +const create = async (data) => { + data = { + ...data, + clientId: crypto.randomUUID(), + }; + if (!validateCreateProps(props, data)) { + throw new Error('Invalid data'); + } + return createDocument(collection, data); +}; + +const update = async (clientRef, data) => { + if (!clientRef) { + throw new Error('Missing client reference'); + } + data.description = data.description || ''; + validateUpdateProps(props, data); + return updateDocument(clientRef, data); +}; + +const archive = async (clientRef) => { + if (!clientRef) { + throw new Error('Missing client reference'); + } + try { + return updateDocument(clientRef, { archived: true }); + } catch (error) { + throw new Error(`Could not archive client ${clientRef.id}`); + } +}; + +const remove = async (clientRef) => { + if (!clientRef) { + throw new Error('Missing client reference'); + } + try { + return clientRef.delete(); + } catch (error) { + throw new Error(`Could not delete client ${clientRef.id}`); + } +}; + +const createSecret = async (clientRef) => { + if (!clientRef) { + throw new Error('Missing client reference'); + } + try { + const secret = crypto.randomUUID(); + const encoder = new TextEncoder(); + const dataBuffer = encoder.encode(secret); + const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashedSecret = hashArray + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); + await createDocument(clientRef.collection('secrets'), { + secret: hashedSecret, + }); + return secret; + } catch (error) { + throw new Error(`Could not create secret for client ${clientRef.id}`); + } +}; + +const rotateSecret = async (clientRef) => { + if (!clientRef) { + throw new Error('Missing client reference'); + } + try { + const secret = await createSecret(clientRef); + await clientRef.update({ + rotated: serverTimestamp(), + rotatedBy: db.collection('users').doc(auth.currentUser.email), + }); + return secret; + } catch (error) { + console.log(error); + throw new Error(`Could not rotate secret for client ${clientRef.id}`); + } +}; + +export default { + collection, + create, + update, + archive, + remove, + createSecret, + rotateSecret, +}; diff --git a/src/db/ApiClient/index.js b/src/db/ApiClient/index.js new file mode 100644 index 000000000..3c88cdba1 --- /dev/null +++ b/src/db/ApiClient/index.js @@ -0,0 +1,3 @@ +import ApiClient from './ApiClient'; + +export default ApiClient; diff --git a/src/db/ApiClient/props.js b/src/db/ApiClient/props.js new file mode 100644 index 000000000..ca5e68dc6 --- /dev/null +++ b/src/db/ApiClient/props.js @@ -0,0 +1,18 @@ +export default { + parent: { + type: 'reference', + required: true, + }, + clientId: { + type: 'string', + required: true, + }, + name: { + type: 'string', + required: true, + }, + description: { + type: 'string', + required: false, + }, +}; diff --git a/src/locale/locales/en-US.json b/src/locale/locales/en-US.json index c2fc94be1..b84f3e119 100644 --- a/src/locale/locales/en-US.json +++ b/src/locale/locales/en-US.json @@ -121,7 +121,6 @@ "organization": "Organization", "date": "Date", "password": "Password", - "secret": "API Secret", "comment": "Comment", "format": "Display", "timestamp": "Timestamp", @@ -196,7 +195,8 @@ "frontPage": "Front page", "signIn": "Please sign in", "orgs": "Organizations", - "api": "API", + "api": "API documentation", + "integrations": "Integrations", "resultIndicator": "Result indicator", "keyFigures": "Key figures", "otherMeasurements": "Other measurements", @@ -212,7 +212,7 @@ "step": "Step {step} of {steps}", "or": "or", "shortcuts": "Shortcuts", - "error": "Error" + "unknown": "unknown" }, "home": { "welcome": "Welcome to {appName}!", @@ -449,8 +449,6 @@ "change": "Edit measurement", "delete": "Delete measurement" }, - "apiSecret": "If you are using the API to update key results and/or measurements, you need to write an API Secret UUID here for the API call header (okr-team-secret)", - "apiDocs": "API documentation", "department": { "picture": "Image", "create": "Create new department", @@ -667,6 +665,50 @@ "csv": "Download raw data (.csv)" } }, + "integration": { + "info": "Our API offers endpoints to automate updating of key results and measurements. In order to make use of the API, an API client must be created. For each client, a unique ID and secret is generated which must be added as request headers ({clientIdHeader}, {clientSecretHeader}) to each API call. This in addition to a separate API key ({apiKeyHeader}). It is recommended to create a separate client for each application that integrates with the API. This makes it easier to keep track of each application and secrets in use.", + "placeholderTitle": "New client", + "clientId": "Client ID", + "clientSecret": "Secret", + "action": { + "add": "Create new client", + "edit": "Edit client", + "delete": "Delete client", + "rotate": "Rotate secret", + "confirmRotate": "Confirm rotate" + }, + "tag": { + "created": "Created {when} by {name}", + "edited": "Last edited {when} by {name}", + "lastRotated": "Last rotated {when}", + "lastActivity": "Last active {when}", + "noActivity": "No activity seen yet" + }, + "empty": { + "heading": "No clients", + "body": "Add one or more clients here in order to make use of our API. This makes it easy to automate updating of key results and/or measurements." + }, + "toast": { + "add": "New client created", + "addError": "Could not create new client", + "update": "The client was updated", + "updateError": "Could not update the client", + "delete": "The client was deleted", + "deleteError": "Could not delete the client", + "rotate": "The secret was rotated", + "rotateError": "Could not rotate the secret" + }, + "warning": { + "secret": "The secret above must be copied and stored in a safe location. It's only shown this one time. It's OK, {closeLink}!", + "secretCloseText": "I'm done", + "delete": "When deleting, all running applications which uses this client will stop working.", + "rotate": "When rotating, all running applications which uses the old secret will stop working.", + "deprecation": "It is no longer possible to add/edit the API secret here. Please visit the 'integrations' tab in the menu to create a new API client. The old secret will still work for the time being:" + }, + "error": { + "loading": "Could not fetch clients: {error}." + } + }, "languages": { "en-US": "English", "nb-NO": "Norwegian" diff --git a/src/locale/locales/nb-NO.json b/src/locale/locales/nb-NO.json index 56eb6319c..1553147d8 100644 --- a/src/locale/locales/nb-NO.json +++ b/src/locale/locales/nb-NO.json @@ -121,7 +121,6 @@ "organization": "Organisasjon", "date": "Dato", "password": "Passord", - "secret": "API Hemmelighet", "comment": "Kommentar", "format": "Visning", "timestamp": "Timestamp", @@ -196,7 +195,8 @@ "frontPage": "Forside", "signIn": "Vennligst logg inn", "orgs": "Virksomheter", - "api": "API", + "api": "API-dokumentasjon", + "integrations": "Integrasjoner", "resultIndicator": "Resultatindikator", "keyFigures": "Nøkkeltall", "otherMeasurements": "Andre målinger", @@ -212,7 +212,7 @@ "step": "Steg {step} av {steps}", "or": "eller", "shortcuts": "Snarveier", - "error": "Feil" + "unknown": "ukjent" }, "home": { "welcome": "Velkommen til {appName}!", @@ -449,8 +449,6 @@ "change": "Rediger måling", "delete": "Slett måling" }, - "apiSecret": "Hvis du har tenkt til å bruke API-et for å oppdatere nøkkelresultater og/eller målinger, så trenger du å skrive inn en streng her som legges inn i headeren til kallet (okr-team-secret)", - "apiDocs": "API-dokumentasjon", "department": { "picture": "Bilde", "create": "Opprett nytt produktområde", @@ -667,6 +665,50 @@ "csv": "Last ned rådata (.csv)" } }, + "integration": { + "info": "Vårt API tilbyr funksjonalitet for blant annet å oppdatere nøkkelresultater og/eller målinger maskinelt. For å ta i bruk API-et må man opprette en klient. For hver klient genereres en klient-ID og en hemmelighet som legges inn i headeren til kallet ({clientIdHeader}, {clientSecretHeader}). Dette kommer i tillegg til en egen API-nøkkel ({apiKeyHeader}). Det anbefales å opprette en separat klient for hver enkelt applikasjon man kobler på API-et. Dette gjør det blant annet enklere å holde styr på hvilke hemmeligheter som er i bruk.", + "placeholderTitle": "Ny klient", + "clientId": "Klient ID", + "clientSecret": "Hemmelighet", + "action": { + "add": "Legg til klient", + "edit": "Rediger klient", + "delete": "Slett klient", + "rotate": "Roter hemmelighet", + "confirmRotate": "Bekreft rotering" + }, + "tag": { + "created": "Opprettet {when} av {name}", + "edited": "Sist endret {when} av {name}", + "lastRotated": "Sist rotert for {when}", + "lastActivity": "Sist aktiv for {when}", + "noActivity": "Ingen aktivitet registrert enda" + }, + "empty": { + "heading": "Ingen klienter", + "body": "Legg til en eller flere klienter her for å ta i bruk API-et. Dette gjør det enkelt å oppdatere nøkkelresultater og/eller målinger maskinelt." + }, + "toast": { + "add": "Opprettet ny klient", + "addError": "Kunne ikke opprette ny klient", + "update": "Klienten ble endret", + "updateError": "Kunne ikke endre klienten", + "delete": "Klienten ble slettet", + "deleteError": "Kunne ikke slette klient", + "rotate": "Hemmeligheten ble rotert", + "rotateError": "Kunne ikke rotere hemmelighet" + }, + "warning": { + "secret": "Hemmeligheten over må kopieres og oppbevares et trygt sted. Den vises kun denne ene gangen. Den er grei, {closeLink}!", + "secretCloseText": "jeg er ferdig", + "delete": "Ved sletting vil alle kjørende applikasjoner som benytter denne klienten slutte å fungere.", + "rotate": "Ved rotering vil alle kjørende applikasjoner som benytter gammel hemmelighet slutte å fungere.", + "deprecation": "Det er ikke lenger mulig å legge til eller endre API-hemmelighet her. Besøk fanen 'integrasjoner' i toppmenyen for å opprette en API-klient. Den gamle hemmeligheten fungerer enn så lenge:" + }, + "error": { + "loading": "Kunne ikke hente ut klienter: {error}." + } + }, "languages": { "en-US": "Engelsk", "nb-NO": "Norsk" diff --git a/src/router/index.js b/src/router/index.js index 44af5eddb..2968c6260 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -111,6 +111,12 @@ const routes = [ name: 'ItemAbout', component: ItemAbout, }, + { + path: 'integrations', + name: 'ItemIntegrations', + component: () => import('@/views/Item/ItemIntegrations.vue'), + beforeEnter: routerGuards.itemIntegrations, + }, { path: 'k/:keyResultId', name: 'KeyResultHome', diff --git a/src/router/router-guards/index.js b/src/router/router-guards/index.js index 3737abbdb..733dbdf55 100644 --- a/src/router/router-guards/index.js +++ b/src/router/router-guards/index.js @@ -3,6 +3,7 @@ export { default as home } from './home'; export { default as itemCommon } from './itemCommon'; export { default as itemMeasurements } from './itemMeasurements'; export { default as itemOKRs } from './itemOKRs'; +export { default as itemIntegrations } from './itemIntegrations'; export { default as keyResultHome } from './keyResultHome'; export { default as login } from './login'; export { default as objectiveHome } from './objectiveHome'; diff --git a/src/router/router-guards/itemIntegrations.js b/src/router/router-guards/itemIntegrations.js new file mode 100644 index 000000000..223faac5a --- /dev/null +++ b/src/router/router-guards/itemIntegrations.js @@ -0,0 +1,27 @@ +import store from '@/store'; + +/** + * Router guard for the API integration admin. Checks if the user is an admin or + * a member of the team before allowing access to the page. + */ +export default async function itemAdmin(to, from, next) { + const { + state: { + activeItem: { team, organization, id: activeItemId }, + user: { id: userId, superAdmin, admin }, + }, + } = store; + + const isAdminOfOrganization = organization + ? admin && admin.includes(organization.id) + : admin && admin.includes(activeItemId); + + const isMemberOfTeam = team && team.map(({ id }) => id).includes(userId); + + if (isMemberOfTeam || superAdmin || isAdminOfOrganization) { + next(); + } else { + console.error('Access denied'); + next({ name: 'Forbidden' }); + } +} diff --git a/src/styles/_tooltip.scss b/src/styles/_tooltip.scss index caeb07ed1..ff47d02f0 100644 --- a/src/styles/_tooltip.scss +++ b/src/styles/_tooltip.scss @@ -100,6 +100,7 @@ .tooltip-inner { font-size: 1rem !important; + text-align: center; } } diff --git a/src/views/Item/ItemIntegrations.vue b/src/views/Item/ItemIntegrations.vue new file mode 100644 index 000000000..de55aaa19 --- /dev/null +++ b/src/views/Item/ItemIntegrations.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/tests/firebase/firestore.test.js b/tests/firebase/firestore.test.js index 6efe0880b..e4ffdaca0 100644 --- a/tests/firebase/firestore.test.js +++ b/tests/firebase/firestore.test.js @@ -30,6 +30,7 @@ describe('Test Firestore rules', () => { const db = context.firestore(); const users = db.collection('users'); const organizations = db.collection('organizations'); + const apiClients = db.collection('apiClients'); await users .doc('superadmin@example.com') @@ -40,6 +41,15 @@ describe('Test Firestore rules', () => { await organizations .doc('organization') .set({ name: 'Organization', team: [users.doc('user@example.com')] }); + + await apiClients + .doc('organization-api-client') + .set({ parent: organizations.doc('organization'), clientId: 'foo' }); + await apiClients + .doc('organization-api-client') + .collection('secrets') + .doc('organization-api-client-secret') + .set({ secret: 'bar' }); }); }); @@ -53,8 +63,7 @@ describe('Test Firestore rules', () => { test('users can read user other profiles', async () => { await withAuthenticatedUser(testEnv, 'user@example.com', async (db) => { const user = db.collection('users').doc('user2@example.com'); - const result = await user.get(); - expectGetSucceeds(result); + const result = await expectGetSucceeds(user.get()); expect(result.data().name).toBe('User 2'); }); }); @@ -105,6 +114,71 @@ describe('Test Firestore rules', () => { await expectPermissionDenied(organizations.add({ foo: 'bar' })); }); }); + + test('anonymous users cannot read api clients', async () => { + await withUnauthenticatedUser(testEnv, async (db) => { + const clients = db.collection('apiClients'); + await expectPermissionDenied(clients.get()); + }); + + await withUnauthenticatedUser(testEnv, async (db) => { + const client = db.collection('apiClients').doc('organization-api-client'); + await expectPermissionDenied(client.get()); + }); + }); + + test('signed in users can read api clients', async () => { + await withAuthenticatedUser(testEnv, 'user@example.com', async (db) => { + const orgRef = db.collection('organizations').doc('organization'); + const clients = db.collection('apiClients').where('parent', '==', orgRef); + const result = await expectGetSucceeds(clients.get()); + expect(result).toHaveProperty('size', 1); + }); + }); + + test('members can write own api clients and secrets', async () => { + await withAuthenticatedUser(testEnv, 'user@example.com', async (db) => { + const apiClientRef = db.collection('apiClients').doc('organization-api-client'); + await expectUpdateSucceeds(apiClientRef.update({ name: 'foo' })); + const apiClientSnap = await apiClientRef.get(); + expect(apiClientSnap.data().name).toBe('foo'); + const secrets = apiClientRef.collection('secrets'); + await expectUpdateSucceeds(secrets.doc('foo').set({ foo: 'bar' })); + }); + + await withAuthenticatedUser(testEnv, 'user2@example.com', async (db) => { + const apiClientRef = db.collection('apiClients').doc('organization-api-client'); + await expectPermissionDenied(apiClientRef.update({ name: 'foo' })); + }); + }); + + test('super admin can write any api client', async () => { + await withAuthenticatedUser(testEnv, 'superadmin@example.com', async (db) => { + const clients = db.collection('apiClients'); + const apiClientRef = clients.doc('organization-api-client'); + await expectUpdateSucceeds(apiClientRef.update({ name: 'foo' })); + const secrets = apiClientRef.collection('secrets'); + await expectUpdateSucceeds(secrets.doc('foo').set({ foo: 'bar' })); + }); + }); + + test('no one can read api client secrets', async () => { + async function readApiClientSecrets(db) { + const apiClients = db.collection('apiClients'); + const apiClientRef = apiClients.doc('organization-api-client'); + const apiClientSecrets = apiClientRef.collection('secrets'); + await expectPermissionDenied(apiClientSecrets.get()); + + const apiClientSecretRef = apiClientRef + .collection('secrets') + .doc('organization-api-client-secret'); + await expectPermissionDenied(apiClientSecretRef.get()); + } + + await withUnauthenticatedUser(testEnv, readApiClientSecrets); + await withAuthenticatedUser(testEnv, 'user@example.com', readApiClientSecrets); + await withAuthenticatedUser(testEnv, 'superadmin@example.com', readApiClientSecrets); + }); }); afterAll(async () => { diff --git a/tests/firebase/utils.js b/tests/firebase/utils.js index de92018c9..d06d63c43 100644 --- a/tests/firebase/utils.js +++ b/tests/firebase/utils.js @@ -21,5 +21,7 @@ export async function expectUpdateSucceeds(promise) { } export async function expectGetSucceeds(promise) { - expect(assertSucceeds(promise)).not.toBeUndefined(); + const successResult = await assertSucceeds(promise); + expect(successResult).not.toBeUndefined(); + return successResult; }