From ed3a08d9ec4a7c7a746ffd5de06319a1ba5b1dad Mon Sep 17 00:00:00 2001 From: Gary Homewood Date: Wed, 26 Jan 2022 15:11:34 +0000 Subject: [PATCH] feat: pinning api allowlist (#705) * feat(pinning-api): add skeleton and validation for pinning apis * feat: add pinning endpoints * feat: add tests * feat: add validation for endpoints and refactor error messages * fix: validation bug * feat: add tests for get and delete endpoints * fix: test assertions * feat: add replace pin api endpoint * chore: rollback type changes for now Co-authored-by: Paolo * feat(pinning-apis): work on db to support new pinning apis * chore: rename PinsUpsertInput to PinUpsertInput * chore: fix PinUpsertInput type * chore: use PinUpsertInput where required * chore(refactor types): refactor pinItem * chore(refactor types): Use location with other types * chore(refactor types): fix some type errors in db client * chore(refactor types): rename PinItemOutput to PinItemNormalized * chore(refactor types): Pin item should have an _id field * Rename PinItemOutput back * feat(pinning-api-db): create pinning table and update reset.sql * feat(pinning-api-db): create initial types * feat(pinning-api-db): create db client signatures * feat(pinning-api-db): updat types * feat(pinning-api-db): write first intial add pin spec * feat(pinning-api-db): first implementation of create and get pinRequest * feat(pinning-api-db): get pin data for request * feat(pinning-api-db): housekeeping * feat(pinning-api-db): get pin request tests * feat(pinning-api-db): housekeeping * feat: add types to db data types * feat(pinning-api-db): create content function * feat(pinning-api-db): remove duplicated type * feat(pinning-api-db): housekeeping * feat(pinning-api-db): update types * feat(pinning-api-db): update documentation * chore: db change for allowlist * Add endpoint and logic to our api service (#702) * feat(pinning-apis): get request endpoint work * chore: remove logging * chore: improve validation get /pin/requestId * chore: better integer validation * fix: requestId conditional * fix: remove extra bracket Co-authored-by: Alexandra Stoica * feat(pinning-apis): POST /pin endpoint * wip: create pin request * feat: add async task * chore: merge feature branch * feat: code improvements * chore: add todo * feat: add comments and some small fixes * fix: pass token id * fix: normalized cid vs source cid * fix: minor fix and tests updates * fix: update return of get user mock Co-authored-by: Paolo * chore: make standard happier * feat: wip add pinning allowlist checking to API * fix: fix token referemce * chore: fix test mocking * feat: add tests for pinning allowlist * fix: merge conflict issue * chore: fix tests after merge * chore: review amends * feat : delete and replace pin requests endpoints * wip: delete pin request * feat: add db definitions * feat: wip replace pin endpoint * chore: update mocks * fix: do not list deleted requests * feat: add delete db tests * fix: tests * fix: improve validation * feat: tests * chore: update comments * fix: replace pin bugs and tests * fix: coerce to number using parseInt * chore: disable tests for now Co-authored-by: Paolo * chore: simplified allowlist middleware * feat: GET /pins endpoint * feat: wip list pin requests * feat: list pinning requests * chore: renaming test descriptions * chore: filter for list pins * chore: fixing tests * chore: list pins tests * feat: db and api tests for list pins * chore: reorder list test * fix: correct count in list response * fix: avoid double query on list * feat: PR feedback * feat: add todo for future improv * fix: typos, use new URL in test, improve Date test * feat: don't skip test * fix: update tests and more fixes * fix: take back the todo Co-authored-by: Gary Homewood * feat: use separate pinning auth table * fix: db user test * fix: correct db type * Refactor Pin APIs and PR feedback (#810) chore: pinning apis refactoring and feedback * feat: update sql and db client logic * feat: update tests and minor fixes * feat: more updates/fixes to db package * feat: refactor pin creation flow and refactor car upload as well * feat: pinning, fix tests and add more * fix: rename table, functions and types * chore: PR feedback * fix: rename types * chore: rename requestedCid to sourceCid * fix: delete should not return a body and status should be 202 * chore: add some documentation to waitOkPins * chore: delete stale mock * fix: allowlist on user id instead of token * fix: allow pinning user for post * fix: do not passa meta to cluster * chore: remove comment * chore: refactor createPin * chore: linting * chore: remove stale mock * Get request id by user token (#863) * fix: get pin req ids by user token * fix: update tests * fix: rest api types * fix: update function params * fix: type name * feat: add test * fix: tests * fix: merge conflict * fix: merge * fix: rename authToken id * fix: db test dead code * chore: merge from pinning-apis, de-dupe tests * fix: skip status db test * Add metadata to API (#881) * feat: add metadata to psa pin req * feat: add metadata to api * feat: add metadata tests * chore: feedback * feat: added pinning service request issue mailto link/github button (#879) * feat: added pinning service request issue mailto link/github button * chore: new line * Fix test * fix: update comment to reflect changes * fix: code review * chore: spelling consistency * chore: review changes Co-authored-by: Alexandra Stoica Co-authored-by: Paolo Co-authored-by: Paolo Chillari Co-authored-by: Alexandra Stoica Co-authored-by: Leslie Owusu-Appiah --- packages/api/src/auth.js | 22 +++- packages/api/src/errors.js | 9 ++ packages/api/src/index.js | 15 +-- packages/api/test/fixtures/init-data.sql | 31 ++++-- packages/api/test/pin.spec.js | 131 ++++++++++++++++++++--- packages/api/test/upload.spec.js | 6 +- packages/api/wrangler.toml | 5 + packages/db/README.md | 23 ++-- packages/db/index.js | 28 ++++- packages/db/postgres/tables.sql | 10 +- 10 files changed, 232 insertions(+), 48 deletions(-) diff --git a/packages/api/src/auth.js b/packages/api/src/auth.js index 1e6974b65d..05ffe35d3e 100644 --- a/packages/api/src/auth.js +++ b/packages/api/src/auth.js @@ -1,6 +1,7 @@ import * as JWT from './utils/jwt.js' import { UserNotFoundError, + PinningUnauthorizedError, TokenNotFoundError, UnrecognisedTokenError, NoTokenError, @@ -68,11 +69,28 @@ export function withApiOrMagicToken (handler) { } } +/** + * Middleware: verify that the authenticated request is for a user who is + * authorized to pin. + * + * @param {import('itty-router').RouteHandler} handler + * @returns {import('itty-router').RouteHandler} + */ +export function withPinningAuthorized (handler) { + return async (request, env, ctx) => { + const authorized = await env.db.isPinningAuthorized(request.auth.user._id) + if (authorized) { + return handler(request, env, ctx) + } + throw new PinningUnauthorizedError() + } +} + /** * @param {string} token - * @param {import('./env').Env} + * @param {import('./env').Env} env * @throws UserNotFoundError - * @returns {import(./user).User | null } + * @returns {Promise | null } */ async function tryMagicToken (token, env) { let issuer = null diff --git a/packages/api/src/errors.js b/packages/api/src/errors.js index 28961d58da..c2c98bfb5d 100644 --- a/packages/api/src/errors.js +++ b/packages/api/src/errors.js @@ -20,6 +20,15 @@ export class UserNotFoundError extends HTTPError { } UserNotFoundError.CODE = 'ERROR_USER_NOT_FOUND' +export class PinningUnauthorizedError extends HTTPError { + constructor (msg = 'Pinning not authorized for this user') { + super(msg, 403) + this.name = 'PinningUnauthorizedError' + this.code = PinningUnauthorizedError.CODE + } +} +PinningUnauthorizedError.CODE = 'ERROR_PINNING_UNAUTHORIZED' + export class TokenNotFoundError extends HTTPError { constructor (msg = 'API token no longer valid') { super(msg, 401) diff --git a/packages/api/src/index.js b/packages/api/src/index.js index ed9077b264..2cd5642c87 100644 --- a/packages/api/src/index.js +++ b/packages/api/src/index.js @@ -2,7 +2,7 @@ import { Router } from 'itty-router' import { errorHandler } from './error-handler.js' import { addCorsHeaders, withCorsHeaders, corsOptions } from './cors.js' -import { withApiOrMagicToken, withMagicToken } from './auth.js' +import { withApiOrMagicToken, withMagicToken, withPinningAuthorized } from './auth.js' import { envAll } from './env.js' import { statusGet } from './status.js' import { carHead, carGet, carPut, carPost } from './car.js' @@ -26,7 +26,8 @@ router.all('*', envAll) const auth = { '🤲': handler => withCorsHeaders(handler), '🔒': handler => withCorsHeaders(withApiOrMagicToken(handler)), - '👮': handler => withCorsHeaders(withMagicToken(handler)) + '👮': handler => withCorsHeaders(withMagicToken(handler)), + '📌': handler => auth['🔒'](withPinningAuthorized(handler)) } const mode = { @@ -45,11 +46,11 @@ router.put('/car/:cid', mode['📝'](auth['🔒'](carPut))) router.post('/upload', mode['📝'](auth['🔒'](uploadPost))) router.get('/user/uploads', mode['👀'](auth['🔒'](userUploadsGet))) -router.post('/pins', mode['📝'](auth['🔒'](pinPost))) -router.post('/pins/:requestId', mode['📝'](auth['🔒'](pinPost))) -router.get('/pins/:requestId', mode['👀'](auth['🔒'](pinGet))) -router.get('/pins', mode['👀'](auth['🔒'](pinsGet))) -router.delete('/pins/:requestId', mode['📝'](auth['🔒'](pinDelete))) +router.post('/pins', mode['📝'](auth['📌'](pinPost))) +router.post('/pins/:requestId', mode['📝'](auth['📌'](pinPost))) +router.get('/pins/:requestId', mode['👀'](auth['📌'](pinGet))) +router.get('/pins', mode['👀'](auth['📌'](pinsGet))) +router.delete('/pins/:requestId', mode['📝'](auth['📌'](pinDelete))) router.get('/name/:key', mode['👀'](auth['🤲'](nameGet))) router.post('/name/:key', mode['📝'](auth['🔒'](namePost))) diff --git a/packages/api/test/fixtures/init-data.sql b/packages/api/test/fixtures/init-data.sql index 6f8ed64ae6..02cd4ff6ad 100644 --- a/packages/api/test/fixtures/init-data.sql +++ b/packages/api/test/fixtures/init-data.sql @@ -8,6 +8,12 @@ VALUES (2, 'test-upload-user', 'test-upload@user.com', 'test-upload', 'test-uplo INSERT INTO public.user (id, name, email, issuer, public_address) VALUES (3, 'test-status-user', 'test-status@user.com', 'test-status', 'test-status'); +INSERT INTO public.user (id, name, email, issuer, public_address) +VALUES (4, 'test-pinning-user', 'test-pinning@user.com', 'test-pinning', 'test-pinning'); + +INSERT INTO public.user (id, name, email, issuer, public_address) +VALUES (5, 'test-pinning-2-user', 'test-pinning2@user.com', 'test-pinning-2', 'test-pinning-2'); + INSERT INTO auth_key (name, secret, user_id) VALUES ('test-key', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LW1hZ2ljLWlzc3VlciIsImlzcyI6IndlYjMtc3RvcmFnZSIsImlhdCI6MTYzMzk1NzM4OTg3MiwibmFtZSI6InRlc3QtbWFnaWMtaXNzdWVyIn0.p2nD1Q4X4Z6DtJ0vxk35hhZOqSPVymhN5uyXrXth1zs', 1); @@ -18,6 +24,12 @@ VALUES ('test-magic-key', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0 INSERT INTO auth_key (name, secret, user_id) VALUES ('test-upload-key', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LXVwbG9hZCIsImlzcyI6IndlYjMtc3RvcmFnZSIsImlhdCI6MTYzMzk1NzM4OTg3MiwibmFtZSI6InRlc3QtdXBsb2FkIn0.K30NZxNOTUdJ0u-2dRGbZYXu2A2-TqcNdRV-G1HkKnI', 2); +-- Used to test pinning +INSERT INTO auth_key (name, secret, user_id) +VALUES ('test-pinning', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LXBpbm5pbmciLCJpc3MiOiJ3ZWIzLXN0b3JhZ2UiLCJpYXQiOjE2MzM5NTczODk4NzIsIm5hbWUiOiJ0ZXN0LXBpbm5pbmcifQ.li8kWohG90P8TdsKL_dUStJb2f6-43G98uZsLrVEaho', 4); +INSERT INTO auth_key (name, secret, user_id) +VALUES ('test-pinning-2', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LXBpbm5pbmctMiIsImlzcyI6IndlYjMtc3RvcmFnZSIsImlhdCI6MTYzMzk1NzM4OTg3MiwibmFtZSI6InRlc3QtcGlubmluZy0yIn0.B0lwP5T2KLP0D1XGvz_f7AJcJ_j65NPN3BsxZ4Io2-g', 5); + -- /user route data testing INSERT INTO content (cid) VALUES ('bafkreigpimx5kl6thyfysh2witvbo5nexvu3q3uc3y65rj5sr5czcc7wae'), @@ -69,7 +81,12 @@ VALUES ( 1669394359626000000 ); --- /pins route testing +-- user 'test-pinning' is authorized +INSERT INTO pinning_authorization (user_id) +VALUES (4); +INSERT INTO pinning_authorization (user_id) +VALUES (5); + INSERT INTO content (cid) VALUES ('bafybeid46f7zggioxjm5p2ze2l6s6wbqvoo4gzbdzfjtdosthmfyxdign4'), ('bafybeig7yvw6a4uhio4pmg5gahyd2xumowkfljdukad7pmdsv5uk5zcseu'), @@ -90,11 +107,11 @@ VALUES ('Pinning', 'bafybeifsrhq2qtkcgjt4gzi7rkafrv2gaai24ptt6rohe2ebqzydkz47sm', 1, '2021-07-14T19:27:14.934572+00:00', '2021-07-14T19:27:14.934572+00:00'); INSERT INTO psa_pin_request (id, auth_key_id, content_cid, source_cid, name, origins, meta, inserted_at, updated_at) -VALUES ('ab62cf3c-c98d-494b-a756-b3a3fb6ddcab', 3, 'bafybeid46f7zggioxjm5p2ze2l6s6wbqvoo4gzbdzfjtdosthmfyxdign4', 'bafybeid46f7zggioxjm5p2ze2l6s6wbqvoo4gzbdzfjtdosthmfyxdign4', 'ReportDoc.pdf', '["/ip6/2606:4700:60::6/tcp/4009/p2p/QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx5N", "/ip4/172.65.0.13/tcp/4009/p2p/QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx4N"]', null, '2021-07-14T19:27:14.934572Z', '2021-07-14T19:27:14.934572Z'), - ('bebd5f62-1381-4124-93a1-1e4eeed52635', 3, 'bafybeig7yvw6a4uhio4pmg5gahyd2xumowkfljdukad7pmdsv5uk5zcseu', 'bafybeig7yvw6a4uhio4pmg5gahyd2xumowkfljdukad7pmdsv5uk5zcseu', 'reportdoc.pdf', null, '{"app_id": "99986338-1113-4706-8302-4420da6158aa"}', '2021-01-01T19:27:14.934572Z', '2021-07-14T19:27:14.934572Z'), - ('d9670c3b-179c-4799-b4fa-66e2bb82c84f', 3, 'bafybeia45bscvzxngto555xsel4gwoclb5fxd7zpxige7rl3maoleznswu', 'bafybeia45bscvzxngto555xsel4gwoclb5fxd7zpxige7rl3maoleznswu', 'FailedPinning.doc', null, null, '2021-07-14T19:27:14.934572Z', '2021-07-14T19:27:14.934572Z'), - ('63992f6e-5bbf-4d01-8a69-9e0561c38b04', 3, 'bafybeidw7pc6nvm7u4rfhpctac4qgtpmwxapw4duugvsl3ppivvzibdlgy', 'bafybeidw7pc6nvm7u4rfhpctac4qgtpmwxapw4duugvsl3ppivvzibdlgy', 'Image.jpeg', null, null, '2021-07-14T19:27:14.934572Z', '2021-07-14T19:27:14.934572Z'), - ('3fa630f2-f22c-486f-ad1c-b36d4d740e31', 3, 'bafybeidrzt6t4k25qjeasydgi3fyh6ejos5x4d6tk2pdzxkb66bkomezy4', 'bafybeidrzt6t4k25qjeasydgi3fyh6ejos5x4d6tk2pdzxkb66bkomezy4', 'Image.png', null, null, '2021-07-14T19:27:14.934572Z', '2021-07-14T19:27:14.934572Z'), - ('5c7e7885-7f68-462d-bdfb-3f0abfb367b5', 3, 'bafybeifsrhq2qtkcgjt4gzi7rkafrv2gaai24ptt6rohe2ebqzydkz47sm', 'bafybeifsrhq2qtkcgjt4gzi7rkafrv2gaai24ptt6rohe2ebqzydkz47sm', 'Image.jpg', null, null, '2021-07-20T19:27:14.934572Z', '2021-07-14T19:27:14.934572Z'), +VALUES ('ab62cf3c-c98d-494b-a756-b3a3fb6ddcab', 4, 'bafybeid46f7zggioxjm5p2ze2l6s6wbqvoo4gzbdzfjtdosthmfyxdign4', 'bafybeid46f7zggioxjm5p2ze2l6s6wbqvoo4gzbdzfjtdosthmfyxdign4', 'ReportDoc.pdf', '["/ip6/2606:4700:60::6/tcp/4009/p2p/QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx5N", "/ip4/172.65.0.13/tcp/4009/p2p/QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx4N"]', null, '2021-07-14T19:27:14.934572Z', '2021-07-14T19:27:14.934572Z'), + ('bebd5f62-1381-4124-93a1-1e4eeed52635', 4, 'bafybeig7yvw6a4uhio4pmg5gahyd2xumowkfljdukad7pmdsv5uk5zcseu', 'bafybeig7yvw6a4uhio4pmg5gahyd2xumowkfljdukad7pmdsv5uk5zcseu', 'reportdoc.pdf', null, '{"app_id": "99986338-1113-4706-8302-4420da6158aa"}', '2021-01-01T19:27:14.934572Z', '2021-07-14T19:27:14.934572Z'), + ('d9670c3b-179c-4799-b4fa-66e2bb82c84f', 4, 'bafybeia45bscvzxngto555xsel4gwoclb5fxd7zpxige7rl3maoleznswu', 'bafybeia45bscvzxngto555xsel4gwoclb5fxd7zpxige7rl3maoleznswu', 'FailedPinning.doc', null, null, '2021-07-14T19:27:14.934572Z', '2021-07-14T19:27:14.934572Z'), + ('63992f6e-5bbf-4d01-8a69-9e0561c38b04', 4, 'bafybeidw7pc6nvm7u4rfhpctac4qgtpmwxapw4duugvsl3ppivvzibdlgy', 'bafybeidw7pc6nvm7u4rfhpctac4qgtpmwxapw4duugvsl3ppivvzibdlgy', 'Image.jpeg', null, null, '2021-07-14T19:27:14.934572Z', '2021-07-14T19:27:14.934572Z'), + ('3fa630f2-f22c-486f-ad1c-b36d4d740e31', 4, 'bafybeidrzt6t4k25qjeasydgi3fyh6ejos5x4d6tk2pdzxkb66bkomezy4', 'bafybeidrzt6t4k25qjeasydgi3fyh6ejos5x4d6tk2pdzxkb66bkomezy4', 'Image.png', null, null, '2021-07-14T19:27:14.934572Z', '2021-07-14T19:27:14.934572Z'), + ('5c7e7885-7f68-462d-bdfb-3f0abfb367b5', 4, 'bafybeifsrhq2qtkcgjt4gzi7rkafrv2gaai24ptt6rohe2ebqzydkz47sm', 'bafybeifsrhq2qtkcgjt4gzi7rkafrv2gaai24ptt6rohe2ebqzydkz47sm', 'Image.jpg', null, null, '2021-07-20T19:27:14.934572Z', '2021-07-14T19:27:14.934572Z'), ('3a19e48d-d6db-4f36-b686-fb8bc37c9d48', 2, 'bafybeiaqu6ijhfhwzjipwesbqf4myz6uczyigahib5joqbo5jw2xmjczfa', 'bafybeiaqu6ijhfhwzjipwesbqf4myz6uczyigahib5joqbo5jw2xmjczfa', 'Image.jpg', null, null, '2021-07-20T19:27:14.934572Z', '2021-07-14T19:27:14.934572Z'), ('9be23b92-918e-44b8-98f4-6043c346fb4e', 2, 'bafybeidqts3rbwkprggjojbvcxy4jzpgzgcvs4a73y3gx2jjxphjeerbcy', 'bafybeidqts3rbwkprggjojbvcxy4jzpgzgcvs4a73y3gx2jjxphjeerbcy', 'Image.jpg', null, null, '2021-07-14T19:27:14.934572Z', '2021-07-14T19:27:14.934572Z'); diff --git a/packages/api/test/pin.spec.js b/packages/api/test/pin.spec.js index 337bd58e83..f205a1f12c 100644 --- a/packages/api/test/pin.spec.js +++ b/packages/api/test/pin.spec.js @@ -14,10 +14,11 @@ import { INVALID_LIMIT, INVALID_REPLACE } from '../src/pins.js' +import { PinningUnauthorizedError } from '../src/errors' /** * - * @param {string} status + * @param {import('../../db/postgres/pg-rest-api-types').definitions['pin']['status']} status * @returns {import('../../db/db-client-types.js').PinItemOutput} */ const createPinWithStatus = (status) => { @@ -82,17 +83,26 @@ const createPinRequest = async (cid, token) => { } describe('Pinning APIs endpoints', () => { - let token = null - - before(async () => { - token = await getTestJWT('test-upload', 'test-upload') - }) - describe('GET /pins', () => { let baseUrl + let token before(async () => { baseUrl = new URL('pins', endpoint).toString() + token = await getTestJWT('test-pinning', 'test-pinning') + }) + + it('returns unauthorized if no token', async () => { + const res = await fetch(new URL('pins/1', endpoint).toString(), { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }) + + assert(res, 'Server responded') + assert(!res.ok) + assert.deepEqual(res.status, 401) }) it('validates filter values', async () => { @@ -321,9 +331,28 @@ describe('Pinning APIs endpoints', () => { assert.strictEqual(data.count, 6) assert.strictEqual(data.results.length, 3) }) + + it('error if user not authorized to pin', async () => { + const notAuthorizedToken = await getTestJWT('test-upload', 'test-upload') + const res = await fetch(new URL('pins', endpoint).toString(), { + method: 'GET', + headers: { + Authorization: `Bearer ${notAuthorizedToken}`, + 'Content-Type': 'application/json' + } + }) + assert(!res.ok) + const data = await res.json() + assert.strictEqual(data.code, PinningUnauthorizedError.CODE) + }) }) describe('POST /pins', () => { + let token = null + before(async () => { + token = await getTestJWT('test-pinning', 'test-pinning') + }) + it('should receive pin data containing cid', async () => { const cid = 'bafybeibqmrg5e5bwhx2ny4kfcjx2mm3ohh2cd4i54wlygquwx7zbgwqs4e' const res = await fetch(new URL('pins', endpoint).toString(), { @@ -495,13 +524,31 @@ describe('Pinning APIs endpoints', () => { assert.strictEqual(error.reason, ERROR_STATUS) assert.strictEqual(error.details, INVALID_META) }) + + it('error if user not authorized to pin', async () => { + const notAuthorizedToken = await getTestJWT() + const res = await fetch(new URL('pins', endpoint).toString(), { + method: 'POST', + headers: { + Authorization: `Bearer ${notAuthorizedToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + cid: 'bafybeibqmrg5e5bwhx2ny4kfcjx2mm3ohh2cd4i54wlygquwx7zbgwqs4e' + }) + }) + + assert(!res.ok) + const data = await res.json() + assert.strictEqual(data.code, PinningUnauthorizedError.CODE) + }) }) describe('GET /pins/:requestId', () => { - before(async () => { - // Create token - token = await getTestJWT('test-upload', 'test-upload') + let token = null + before(async () => { + token = await getTestJWT('test-pinning', 'test-pinning') const cid = 'bafybeihy6bymmfcdjdrkhaha2srphnhrewimtkdxdmcama2dpgvpyx4efu' await (await fetch(new URL('pins', endpoint).toString(), { method: 'POST', @@ -540,7 +587,7 @@ describe('Pinning APIs endpoints', () => { }) it('returns not found if the request does not belong to the user token', async () => { - const wrongToken = await getTestJWT() + const wrongToken = await getTestJWT('test-pinning-2', 'test-pinning-2') const res = await fetch(new URL('pins/ab62cf3c-c98d-494b-a756-b3a3fb6ddcab', endpoint).toString(), { method: 'GET', headers: { Authorization: `Bearer ${wrongToken}` } @@ -571,7 +618,6 @@ describe('Pinning APIs endpoints', () => { it('returns the pin request with specified name', async () => { const requestId = 'bebd5f62-1381-4124-93a1-1e4eeed52635' - const res = await fetch(new URL(`pins/${requestId}`, endpoint).toString(), { method: 'GET', headers: { @@ -591,7 +637,6 @@ describe('Pinning APIs endpoints', () => { it('returns the pin request with specified metadata', async () => { const requestId = 'bebd5f62-1381-4124-93a1-1e4eeed52635' const meta = { app_id: '99986338-1113-4706-8302-4420da6158aa' } - const res = await fetch(new URL(`pins/${requestId}`, endpoint).toString(), { method: 'GET', headers: { @@ -608,6 +653,24 @@ describe('Pinning APIs endpoints', () => { assert.deepStrictEqual(data.pin.meta, meta) }) + it('error if user not authorized to pin', async () => { + const notAuthorizedToken = await getTestJWT() + const res = await fetch(new URL('pins', endpoint).toString(), { + method: 'POST', + headers: { + Authorization: `Bearer ${notAuthorizedToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + cid: 'bafybeibqmrg5e5bwhx2ny4kfcjx2mm3ohh2cd4i54wlygquwx7zbgwqs4e' + }) + }) + + assert(!res.ok) + const data = await res.json() + assert.strictEqual(data.code, PinningUnauthorizedError.CODE) + }) + it('returns the pin request with specified origins', async () => { const requestId = 'ab62cf3c-c98d-494b-a756-b3a3fb6ddcab' const origins = [ @@ -672,14 +735,18 @@ describe('Pinning APIs endpoints', () => { createPinWithStatus('UnpinQueued'), createPinWithStatus('PinError') ] - assert.strictEqual(getEffectivePinStatus(pins), 'failed') }) }) describe('DELETE /pins/:requestId', () => { + let token = null + before(async () => { + token = await getTestJWT('test-pinning', 'test-pinning') + }) + it('fails to delete if there is no user token', async () => { - const res = await fetch(new URL('pins/ab62cf3c-c98d-494b-a756-b3a3fb6ddcab', endpoint).toString(), { + const res = await fetch(new URL('pins/1', endpoint).toString(), { method: 'DELETE' }) @@ -745,9 +812,28 @@ describe('Pinning APIs endpoints', () => { assert(res.ok, 'Server responded') assert.equal(res.status, 202) }) + + it('error if user not authorized to pin', async () => { + const notAuthorizedToken = await getTestJWT() + const res = await fetch(new URL('pins/1', endpoint).toString(), { + method: 'DELETE', + headers: { + Authorization: `Bearer ${notAuthorizedToken}`, + 'Content-Type': 'application/json' + } + }) + assert(!res.ok) + const data = await res.json() + assert.strictEqual(data.code, PinningUnauthorizedError.CODE) + }) }) describe('POST /pins/:requestId', () => { + let token = null + before(async () => { + token = await getTestJWT('test-pinning', 'test-pinning') + }) + it('should not replace a pin request that doesn\'t exist', async () => { const res = await fetch(new URL('pins/100', endpoint).toString(), { method: 'POST', @@ -816,5 +902,20 @@ describe('Pinning APIs endpoints', () => { const { error } = await res.json() assert.equal(error.details, INVALID_REPLACE) }) + + it('error if user not authorized to pin', async () => { + const notAuthorizedToken = await getTestJWT() + const res = await fetch(new URL('pins/UniqueIdOfPinRequest', endpoint).toString(), { + method: 'POST', + headers: { + Authorization: `Bearer ${notAuthorizedToken}`, + 'Content-Type': 'application/json' + } + }) + + assert(!res.ok) + const data = await res.json() + assert.strictEqual(data.code, PinningUnauthorizedError.CODE) + }) }) }) diff --git a/packages/api/test/upload.spec.js b/packages/api/test/upload.spec.js index e5e1a0dc4d..aec7fea50a 100644 --- a/packages/api/test/upload.spec.js +++ b/packages/api/test/upload.spec.js @@ -14,7 +14,7 @@ describe('POST /upload', () => { // expected CID for the above data const expectedCid = 'bafkreidvbhs33ighmljlvr7zbv2ywwzcmp5adtf4kqvlly67cy56bdtmve' - const res = await fetch(new URL('upload', endpoint), { + const res = await fetch(new URL('upload', endpoint).toString(), { method: 'POST', headers: { Authorization: `Bearer ${token}`, @@ -44,7 +44,7 @@ describe('POST /upload', () => { // expected CID for the above data const expectedCid = 'bafkreidekh6xmx5iqumo63i2fipsdtjmpzj4liok7wzptv4tvmnj2ptu6u' - const res = await fetch(new URL('upload', endpoint), { + const res = await fetch(new URL('upload', endpoint).toString(), { method: 'POST', headers: { Authorization: `Bearer ${token}`, @@ -66,7 +66,7 @@ describe('POST /upload', () => { // Create token const token = await getTestJWT('test-upload', 'test-upload') - const res = await fetch(new URL('upload', endpoint), { + const res = await fetch(new URL('upload', endpoint).toString(), { method: 'POST', headers: { Authorization: `Bearer ${token}`, diff --git a/packages/api/wrangler.toml b/packages/api/wrangler.toml index 708714e4b5..a268a7644d 100644 --- a/packages/api/wrangler.toml +++ b/packages/api/wrangler.toml @@ -75,6 +75,11 @@ workers_dev = true account_id = "21ba9b25d0fe2fbefc6c9f17e1b90d86" vars = { CLUSTER_API_URL = "https://paolo-cluster-api-web3-storage.loca.lt/", PG_REST_URL = "https://paolo-postgres-api-web3-storage.loca.lt/", ENV = "dev" } +[env.garyhomewood] +workers_dev = true +account_id = "e7a8a523464d26ce98e3747575b366b6" +vars = { CLUSTER_API_URL = "https://garyhomewood-cluster-api-web3-storage.loca.lt/", PG_REST_URL = "https://garyhomewood-postgres-api-web3-storage.loca.lt/", ENV = "dev" } + [env.leslieoa] workers_dev = true account_id = "ede80955a038a6911c9b03ae4343fee4" diff --git a/packages/db/README.md b/packages/db/README.md index f37c8573c5..90d834a47a 100644 --- a/packages/db/README.md +++ b/packages/db/README.md @@ -69,22 +69,23 @@ npm run stop:clean ### 4. Alter DB schema -In order for your changes to the DB schema to be reflected in Postgres you need to run an `openapi-typescript` script: +1. Add the schema changes to `db/postgres` sql files as needed. -```bash -node scripts/cli.js db-sql -``` +2. Apply the changes to the Postgres DB using: -`pg-rest-api-types.ts` stores the types of the various DB tables and their columns. -In order to get them updated after a change to your db structure you can run: + ```bash + node scripts/cli.js --reset db-sql + ``` -```bash -node scripts/cli.js pg-rest-api-types -``` +3. Run the following which uses `openapi-typescript` to update `pg-rest-api-types.ts`, the TypeScript interface version of the OpenAPI schema for the updated DB: + + ```bash + node scripts/cli.js pg-rest-api-types + ``` -Do not forget to update `db-client-types.ts` to reflect your changes to the schema. +4. Add the schema changes to `db-client-types.ts` as well. -If you're creating a new table, type or view please remember to update `reset.sql` as well. +5. If the schema changes include creating a new table, type or view update `reset.sql` as well. ## DB package CLI The `scripts/cli.js` to run some common operations on the database. diff --git a/packages/db/index.js b/packages/db/index.js index aea8394da9..036f3eb428 100644 --- a/packages/db/index.js +++ b/packages/db/index.js @@ -136,6 +136,26 @@ export class DBClient { return data.length ? data[0] : undefined } + /** + * Check that a user is authorized to pin + * + * @param {number} userId + * @returns {Promise} + */ + async isPinningAuthorized (userId) { + const { error, count } = await this._client + .from('pinning_authorization') + .select('id', { count: 'exact' }) + .eq('user_id', userId) + .filter('deleted_at', 'is', null) + + if (error) { + throw new DBError(error) + } + + return count > 0 + } + /** * Get used storage in bytes. * @@ -660,13 +680,17 @@ export class DBClient { * @return {Promise} */ async getKey (issuer, secret) { - /** @type {{ data, error: PostgrestError } */ + /** @type {{ data, error: PostgrestError }} */ const { data, error } = await this._client .from('user') .select(` _id:id::text, issuer, - keys:auth_key_user_id_fkey(_id:id::text, name,secret) + keys:auth_key_user_id_fkey( + _id:id::text, + name, + secret + ) `) .match({ issuer diff --git a/packages/db/postgres/tables.sql b/packages/db/postgres/tables.sql index b86f623dee..389ae660a6 100644 --- a/packages/db/postgres/tables.sql +++ b/packages/db/postgres/tables.sql @@ -201,7 +201,6 @@ CREATE TABLE IF NOT EXISTS pin_sync_request CREATE INDEX IF NOT EXISTS pin_sync_request_pin_id_idx ON pin_sync_request (pin_id); - -- Setting search_path to public scope for uuid function(s) SET search_path TO public; DROP extension IF EXISTS "uuid-ossp"; @@ -252,3 +251,12 @@ CREATE TABLE IF NOT EXISTS name inserted_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL, updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL ); + +CREATE TABLE IF NOT EXISTS pinning_authorization +( + id BIGSERIAL PRIMARY KEY, + -- Points to user allowed to pin content. + user_id BIGINT NOT NULL REFERENCES public.user (id), + inserted_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL, + deleted_at TIMESTAMP WITH TIME ZONE +)