diff --git a/packages/api/package.json b/packages/api/package.json index 775a69ff23..a843623c70 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -33,6 +33,7 @@ "go-livepeer:broadcaster": "bin/livepeer -broadcaster -datadir ./bin/broadcaster -orchAddr 127.0.0.1:3086 -rtmpAddr 0.0.0.0:3035 -httpAddr :3085 -cliAddr :3075 -v 6 -authWebhookUrl http://127.0.0.1:3004/api/stream/hook -orchWebhookUrl http://127.0.0.1:3004/api/orchestrator", "go-livepeer:orchestrator": "bin/livepeer -orchestrator -datadir ./bin/orchestrator -transcoder -serviceAddr 127.0.0.1:3086 -cliAddr :3076 -v 6", "test": "POSTGRES_CONNECT_TIMEOUT=120000 jest -i --silent \"${PWD}/src\"", + "test-single": "POSTGRES_CONNECT_TIMEOUT=120000 jest -i --silent \"${PWD}/src/controllers/+$filename.+\"", "test:dev": "jest \"${PWD}/src\" -i --silent --watch", "test:build": "parcel build --no-autoinstall --no-minify --bundle-node-modules -t browser --out-dir ../dist-worker ../src/worker.js", "coverage": "yarn run test --coverage", diff --git a/packages/api/src/controllers/session.ts b/packages/api/src/controllers/session.ts index c32b3cd00f..c82d2afc62 100644 --- a/packages/api/src/controllers/session.ts +++ b/packages/api/src/controllers/session.ts @@ -1,8 +1,8 @@ import { Router, Request } from "express"; import sql from "sql-template-strings"; -import { authorizer } from "../middleware"; -import { User } from "../schema/types"; +import { authorizer, hasAccessToResource } from "../middleware"; +import { User, Project } from "../schema/types"; import { db } from "../store"; import { DBSession } from "../store/session-table"; import { pathJoin } from "../controllers/helpers"; @@ -36,6 +36,7 @@ const fieldsMap: FieldsMap = { userId: `session.data->>'userId'`, "user.email": { val: `users.data->>'email'`, type: "full-text" }, parentId: `session.data->>'parentId'`, + projectId: `session.data->>'projectId'`, record: { val: `session.data->'record'`, type: "boolean" }, sourceSegments: `session.data->'sourceSegments'`, transcodedSegments: { @@ -65,10 +66,14 @@ app.get("/", authorizer({}), async (req, res, next) => { limit = undefined; } + const query = parseFilters(fieldsMap, filters); + if (!req.user.admin) { userId = req.user.id; + query.push( + sql`coalesce(session.data->>'projectId', '') = ${req.project?.id || ""}` + ); } - const query = parseFilters(fieldsMap, filters); if (!all || all === "false" || !req.user.admin) { query.push(sql`(session.data->>'deleted')::boolean IS false`); } @@ -146,8 +151,7 @@ app.get("/:id", authorizer({}), async (req, res) => { let session = await db.session.get(req.params.id); if ( !session || - ((session.userId !== req.user.id || session.deleted) && - !req.user.admin && + (!hasAccessToResource(req, session) && !LVPR_SDK_EMAILS.includes(req.user.email)) ) { // do not reveal that session exists diff --git a/packages/api/src/controllers/signing-key.test.ts b/packages/api/src/controllers/signing-key.test.ts index 203a37eeed..7f07f8d96d 100644 --- a/packages/api/src/controllers/signing-key.test.ts +++ b/packages/api/src/controllers/signing-key.test.ts @@ -2,13 +2,20 @@ import serverPromise, { TestServer } from "../test-server"; import { TestClient, clearDatabase, + createApiToken, setupUsers, verifyJwt, } from "../test-helpers"; -import { SigningKey, SigningKeyResponsePayload, User } from "../schema/types"; +import { + ApiToken, + SigningKey, + SigningKeyResponsePayload, + User, +} from "../schema/types"; import { WithID } from "../store/types"; import jwt, { JsonWebTokenError, JwtPayload } from "jsonwebtoken"; import { db } from "../store"; +import { createProject } from "../test-helpers"; // includes auth file tests @@ -48,6 +55,8 @@ describe("controllers/signing-key", () => { let samplePrivateKey: string; let decodedPublicKey: string; let otherPublicKey: string; + let projectId: string; + let apiKeyWithProject: WithID; beforeEach(async () => { ({ client, adminUser, adminToken, nonAdminUser, nonAdminToken } = @@ -69,10 +78,27 @@ describe("controllers/signing-key", () => { otherSigningKey.publicKey, "base64" ).toString(); + // create a new project + client.jwtAuth = nonAdminToken; + let project = await createProject(client); + expect(project).toBeDefined(); + projectId = project.id; + apiKeyWithProject = await createApiToken({ + client: client, + projectId: project.id, + jwtAuthToken: nonAdminToken, + }); + expect(apiKeyWithProject).toMatchObject({ + id: expect.any(String), + projectId: projectId, + }); + projectId = project.id; }); it("should create a signing key and display the private key only on creation", async () => { const preCreationTime = Date.now(); + client.jwtAuth = ""; + client.apiKey = apiKeyWithProject.id; let res = await client.post("/access-control/signing-key"); expect(res.status).toBe(201); const created = (await res.json()) as SigningKeyResponsePayload; @@ -81,6 +107,7 @@ describe("controllers/signing-key", () => { privateKey: expect.any(String), publicKey: expect.any(String), createdAt: expect.any(Number), + projectId: projectId, }); expect(created.createdAt).toBeGreaterThanOrEqual(preCreationTime); res = await client.get(`/access-control/signing-key/${created.id}`); @@ -91,11 +118,26 @@ describe("controllers/signing-key", () => { }); it("should list all user signing keys", async () => { + client.jwtAuth = nonAdminToken; + client.apiKey = null; + let sigKeyWithoutProject = await client.post( + "/access-control/signing-key" + ); + expect(sigKeyWithoutProject.status).toBe(201); + client.jwtAuth = ""; + client.apiKey = apiKeyWithProject.id; + let sigkey = await client.post("/access-control/signing-key"); + expect(sigkey.status).toBe(201); const res = await client.get(`/access-control/signing-key`); expect(res.status).toBe(200); + const output = await res.json(); + expect(output).toHaveLength(1); + expect(output[0].projectId).toBe(projectId); }); it("should create a JWT using the private key and verify it with the public key", async () => { + client.jwtAuth = ""; + client.apiKey = apiKeyWithProject.id; const expiration = Math.floor(Date.now() / 1000) + 1000; const payload: JwtPayload = { sub: "b0dcxvwml48mxt2s", @@ -120,6 +162,11 @@ describe("controllers/signing-key", () => { }); it("should allow disable and enable the signing key & change the name", async () => { + client.jwtAuth = ""; + client.apiKey = apiKeyWithProject.id; + let sigkey = await client.post("/access-control/signing-key"); + expect(sigkey.status).toBe(201); + let signingKey = await sigkey.json(); let res = await client.patch( `/access-control/signing-key/${signingKey.id}`, { @@ -144,6 +191,11 @@ describe("controllers/signing-key", () => { }); it("should delete the signing key", async () => { + client.jwtAuth = ""; + client.apiKey = apiKeyWithProject.id; + let sigkey = await client.post("/access-control/signing-key"); + expect(sigkey.status).toBe(201); + let signingKey = await sigkey.json(); let res = await client.delete( `/access-control/signing-key/${signingKey.id}` ); diff --git a/packages/api/src/controllers/signing-key.ts b/packages/api/src/controllers/signing-key.ts index ffedd4a43a..07f7d953c5 100644 --- a/packages/api/src/controllers/signing-key.ts +++ b/packages/api/src/controllers/signing-key.ts @@ -25,6 +25,7 @@ const fieldsMap: FieldsMap = { name: { val: `signing_key.data->>'name'`, type: "full-text" }, deleted: { val: `signing_key.data->'deleted'`, type: "boolean" }, createdAt: { val: `signing_key.data->'createdAt'`, type: "int" }, + projectId: `signing_key.data->>'projectId'`, userId: `signing_key.data->>'userId'`, }; @@ -71,6 +72,12 @@ signingKeyApp.get("/", authorizer({}), async (req, res) => { query.push(sql`signing_key.data->>'deleted' IS NULL`); } + query.push( + sql`coalesce(signing_key.data->>'projectId', '') = ${ + req.project?.id || "" + }` + ); + let fields = " signing_key.id as id, signing_key.data as data, users.id as usersId, users.data as usersdata"; if (count) { @@ -106,6 +113,10 @@ signingKeyApp.get("/", authorizer({}), async (req, res) => { query.push(sql`signing_key.data->>'userId' = ${req.user.id}`); query.push(sql`signing_key.data->>'deleted' IS NULL`); + query.push( + sql`coalesce(signing_key.data->>'projectId', '') = ${req.project?.id || ""}` + ); + let fields = " signing_key.id as id, signing_key.data as data"; if (count) { fields = fields + ", count(*) OVER() AS count"; @@ -137,16 +148,7 @@ signingKeyApp.get("/", authorizer({}), async (req, res) => { signingKeyApp.get("/:id", authorizer({}), async (req, res) => { const signingKey = await db.signingKey.get(req.params.id); - if ( - !signingKey || - signingKey.deleted || - (req.user.admin !== true && req.user.id !== signingKey.userId) - ) { - res.status(404); - return res.json({ - errors: ["not found"], - }); - } + req.checkResourceAccess(signingKey); res.json(signingKey); }); @@ -188,6 +190,7 @@ signingKeyApp.post( userId: req.user.id, createdAt: Date.now(), publicKey: b64PublicKey, + projectId: req.project?.id ?? "", }; await db.signingKey.create(doc); @@ -205,9 +208,7 @@ signingKeyApp.post( signingKeyApp.delete("/:id", authorizer({}), async (req, res) => { const { id } = req.params; const signingKey = await db.signingKey.get(id); - if (!signingKey || signingKey.deleted) { - throw new NotFoundError(`signing key not found`); - } + req.checkResourceAccess(signingKey); if (!req.user.admin && req.user.id !== signingKey.userId) { throw new ForbiddenError(`users may only delete their own signing keys`); } @@ -223,9 +224,7 @@ signingKeyApp.patch( async (req, res) => { const { id } = req.params; const signingKey = await db.signingKey.get(id); - if (!signingKey || signingKey.deleted) { - return res.status(404).json({ errors: ["not found"] }); - } + req.checkResourceAccess(signingKey); if (!req.user.admin && req.user.id !== signingKey.userId) { return res.status(403).json({ errors: ["users may change only their own signing key"], diff --git a/packages/api/src/controllers/stream.test.ts b/packages/api/src/controllers/stream.test.ts index 6d4a3eeb1f..f75230eecd 100644 --- a/packages/api/src/controllers/stream.test.ts +++ b/packages/api/src/controllers/stream.test.ts @@ -19,6 +19,8 @@ import { clearDatabase, setupUsers, startAuxTestServer, + createProject, + createApiToken, } from "../test-helpers"; import serverPromise, { TestServer } from "../test-server"; import { semaphore, sleep } from "../util"; @@ -113,6 +115,7 @@ describe("controllers/stream", () => { let nonAdminUser: User; let nonAdminToken: string; let nonAdminApiKey: string; + let projectId: string; beforeEach(async () => { await server.store.create(mockStore); @@ -127,6 +130,9 @@ describe("controllers/stream", () => { nonAdminApiKey, } = await setupUsers(server, mockAdminUser, mockNonAdminUser)); client.jwtAuth = adminToken; + + projectId = await createProject(client); + expect(projectId).toBeDefined(); }); describe("basic CRUD with JWT authorization", () => { @@ -136,6 +142,7 @@ describe("controllers/stream", () => { const document = { id: uuid(), kind: "stream", + projectId: i > 7 ? projectId : undefined, }; await server.store.create(document); const res = await client.get(`/stream/${document.id}`); @@ -151,6 +158,31 @@ describe("controllers/stream", () => { id: uuid(), kind: "stream", deleted: i > 3 ? true : undefined, + projectId: i > 2 ? projectId : undefined, + } as DBStream; + await server.store.create(document); + const res = await client.get(`/stream/${document.id}`); + const stream = await res.json(); + expect(stream).toEqual(server.db.stream.addDefaultFields(document)); + } + + const res = await client.get("/stream"); + expect(res.status).toBe(200); + const streams = await res.json(); + expect(streams.length).toEqual(4); + const resAll = await client.get("/stream?all=1"); + expect(resAll.status).toBe(200); + const streamsAll = await resAll.json(); + expect(streamsAll.length).toEqual(5); + }); + + it("should get all streams with admin authorization and specific projectId in query param", async () => { + for (let i = 0; i < 5; i += 1) { + const document = { + id: uuid(), + kind: "stream", + deleted: i > 3 ? true : undefined, + projectId: i > 2 ? projectId : undefined, } as DBStream; await server.store.create(document); const res = await client.get(`/stream/${document.id}`); @@ -1393,7 +1425,9 @@ describe("controllers/stream", () => { }); describe("stream endpoint with api key", () => { + let newApiKey; beforeEach(async () => { + // create streams without a projectId for (let i = 0; i < 5; i += 1) { const document = { id: uuid(), @@ -1404,7 +1438,45 @@ describe("controllers/stream", () => { const res = await client.get(`/stream/${document.id}`); expect(res.status).toBe(200); } + + // create a new project + client.jwtAuth = nonAdminToken; + let project = await createProject(client); + expect(project).toBeDefined(); + + // then create a new api-key under that project + newApiKey = await createApiToken({ + client: client, + projectId: project.id, + jwtAuthToken: nonAdminToken, + }); + expect(newApiKey).toMatchObject({ + id: expect.any(String), + projectId: project.id, + }); + client.jwtAuth = ""; + client.apiKey = newApiKey.id; + + // create streams with a projectId + for (let i = 0; i < 5; i += 1) { + const document = { + id: uuid(), + kind: "stream", + userId: nonAdminUser.id, + projectId: project.id, + }; + const resCreate = await client.post("/stream", { + ...postMockStream, + name: "videorec+samplePlaybackId", + }); + expect(resCreate.status).toBe(201); + const createdStream = await resCreate.json(); + const res = await client.get(`/stream/${createdStream.id}`); + expect(res.status).toBe(200); + let stream = await res.json(); + expect(stream.projectId).toEqual(project.id); + } }); it("should get own streams", async () => { @@ -1414,6 +1486,22 @@ describe("controllers/stream", () => { const streams = await res.json(); expect(streams.length).toEqual(3); expect(streams[0].userId).toEqual(nonAdminUser.id); + + client.apiKey = newApiKey.id; + let res2 = await client.get(`/stream/user/${nonAdminUser.id}`); + expect(res2.status).toBe(200); + const streams2 = await res2.json(); + expect(streams2.length).toEqual(5); + expect(streams2[0].userId).toEqual(nonAdminUser.id); + }); + + it("should get streams owned by project when using api-key for project", async () => { + client.apiKey = newApiKey.id; + let res = await client.get(`/stream/`); + expect(res.status).toBe(200); + const streams = await res.json(); + expect(streams.length).toEqual(5); + expect(streams[0].userId).toEqual(nonAdminUser.id); }); it("should delete stream", async () => { diff --git a/packages/api/src/controllers/stream.ts b/packages/api/src/controllers/stream.ts index ff3a0c3a10..c80c68b3c3 100644 --- a/packages/api/src/controllers/stream.ts +++ b/packages/api/src/controllers/stream.ts @@ -6,7 +6,12 @@ import { parse as parseUrl } from "url"; import { v4 as uuid } from "uuid"; import logger from "../logger"; -import { authorizer, geolocateMiddleware, validatePost } from "../middleware"; +import { + authorizer, + geolocateMiddleware, + validatePost, + hasAccessToResource, +} from "../middleware"; import { CliArgs } from "../parse-cli"; import { DetectionWebhookPayload, @@ -15,6 +20,7 @@ import { StreamPatchPayload, StreamSetActivePayload, User, + Project, } from "../schema/types"; import { db, jobsDb } from "../store"; import { DB } from "../store/db"; @@ -183,7 +189,7 @@ async function validateMultistreamOpts( async function validateStreamPlaybackPolicy( playbackPolicy: DBStream["playbackPolicy"], - userId: string + req: Request ) { if ( playbackPolicy?.type === "lit_signing_condition" || @@ -201,7 +207,7 @@ async function validateStreamPlaybackPolicy( `webhook ${playbackPolicy.webhookId} not found` ); } - if (webhook.userId !== userId) { + if (!hasAccessToResource(req, webhook)) { throw new BadRequestError( `webhook ${playbackPolicy.webhookId} not found` ); @@ -413,6 +419,7 @@ const fieldsMap: FieldsMap = { "user.email": { val: `users.data->>'email'`, type: "full-text" }, parentId: `stream.data->>'parentId'`, playbackId: `stream.data->>'playbackId'`, + projectId: `stream.data->>'projectId'`, record: { val: `stream.data->'record'`, type: "boolean" }, suspended: { val: `stream.data->'suspended'`, type: "boolean" }, sourceSegmentsDuration: { @@ -446,11 +453,14 @@ app.get("/", authorizer({}), async (req, res) => { limit = undefined; } + const query = parseFilters(fieldsMap, filters); + if (!req.user.admin) { userId = req.user.id; + query.push( + sql`coalesce(stream.data->>'projectId', '') = ${req.project?.id || ""}` + ); } - - const query = parseFilters(fieldsMap, filters); if (!all || all === "false" || !req.user.admin) { query.push(sql`stream.data->>'deleted' IS NULL`); } @@ -612,14 +622,7 @@ app.get("/:parentId/sessions", authorizer({}), async (req, res) => { const raw = req.query.raw && req.user.admin; const stream = await db.stream.get(parentId); - if ( - !stream || - (stream.deleted && !req.isUIAdmin) || - (stream.userId !== req.user.id && !req.isUIAdmin) - ) { - res.status(404); - return res.json({ errors: ["not found"] }); - } + req.checkResourceAccess(stream, true); let filterOut; const query = []; @@ -696,14 +699,7 @@ app.get("/sessions/:parentId", authorizer({}), async (req, res) => { logger.info(`cursor params ${cursor}, limit ${limit}`); const stream = await db.stream.get(parentId); - if ( - !stream || - stream.deleted || - (stream.userId !== req.user.id && !req.isUIAdmin) - ) { - res.status(404); - return res.json({ errors: ["not found"] }); - } + req.checkResourceAccess(stream, true); const { data, cursor: nextCursor } = await req.store.queryObjects({ kind: "stream", @@ -726,16 +722,18 @@ app.get("/user/:userId", authorizer({}), async (req, res) => { const { userId } = req.params; let { limit, cursor, streamsonly, sessionsonly } = toStringValues(req.query); + let projectId = req.token?.projectId; + if (req.user.admin !== true && req.user.id !== req.params.userId) { res.status(403); return res.json({ errors: ["user can only request information on their own streams"], }); } - const query = [ sql`data->>'deleted' IS NULL`, sql`data->>'userId' = ${userId}`, + sql`coalesce(data->>'projectId', '') = ${projectId || ""}`, ]; if (streamsonly) { query.push(sql`data->>'parentId' IS NULL`); @@ -766,15 +764,7 @@ app.get("/:id", authorizer({}), async (req, res) => { const raw = req.query.raw && req.user.admin; const { forceUrl } = req.query; let stream = await db.stream.get(req.params.id); - if ( - !stream || - ((stream.userId !== req.user.id || stream.deleted) && !req.user.admin) - ) { - // do not reveal that stream exists - res.status(404); - return res.json({ errors: ["not found"] }); - } - + req.checkResourceAccess(stream); // fixup 'user' session if (!raw && stream.lastSessionId) { const lastSession = await db.stream.get(stream.lastSessionId); @@ -809,13 +799,8 @@ app.get("/playback/:playbackId", authorizer({}), async (req, res) => { kind: "stream", query: { playbackId: req.params.playbackId }, }); - if ( - !stream || - ((stream.userId !== req.user.id || stream.deleted) && !req.user.admin) - ) { - res.status(404); - return res.json({ errors: ["not found"] }); - } + + req.checkResourceAccess(stream); res.status(200); res.json( db.stream.addDefaultFields( @@ -831,13 +816,7 @@ app.get("/key/:streamKey", authorizer({}), async (req, res) => { { streamKey: req.params.streamKey }, { useReplica } ); - if ( - !docs.length || - ((docs[0].userId !== req.user.id || docs[0].deleted) && !req.user.admin) - ) { - res.status(404); - return res.json({ errors: ["not found"] }); - } + req.checkResourceAccess(docs[0]); res.status(200); res.json( db.stream.addDefaultFields( @@ -887,15 +866,7 @@ app.post( stream = await db.stream.get(req.params.streamId); } - if ( - !stream || - ((stream.userId !== req.user.id || stream.deleted) && - !(req.user.admin && !stream.deleted)) - ) { - // do not reveal that stream exists - res.status(404); - return res.json({ errors: ["not found"] }); - } + req.checkResourceAccess(stream); const sessionId = req.query.sessionId?.toString(); const region = req.config.ownRegion; @@ -912,6 +883,7 @@ app.post( ...req.body, kind: "stream", userId: stream.userId, + projectId: stream.projectId, renditions: {}, objectStoreId: stream.objectStoreId, record, @@ -932,7 +904,7 @@ app.post( const existingSession = await db.session.get(sessionId); if (existingSession) { logger.info( - `user session re-used for session.id=${sessionId} session.parentId=${existingSession.parentId} session.name=${existingSession.name} session.playbackId=${existingSession.playbackId} session.userId=${existingSession.userId} stream.id=${stream.id} stream.name='${stream.name}' stream.playbackId=${stream.playbackId} stream.userId=${stream.userId}` + `user session re-used for session.id=${sessionId} session.parentId=${existingSession.parentId} session.name=${existingSession.name} session.playbackId=${existingSession.playbackId} session.userId=${existingSession.userId} stream.id=${stream.id} stream.name='${stream.name}' stream.playbackId=${stream.playbackId} stream.userId=${stream.userId} stream.projectId=${stream.projectId}` ); } else { const session: DBSession = { @@ -940,6 +912,7 @@ app.post( parentId: stream.id, playbackId: stream.playbackId, userId: stream.userId, + projectId: stream.projectId, kind: "session", version: "v2", name: req.body.name, @@ -980,9 +953,9 @@ app.post( logger.info( `stream session created for stream_id=${stream.id} stream_name='${ stream.name - }' playbackid=${stream.playbackId} session_id=${id} elapsed=${ - Date.now() - start - }ms` + }' playbackid=${stream.playbackId} session_id=${id} projectid=${ + stream.projectId + } elapsed=${Date.now() - start}ms` ); } ); @@ -1134,6 +1107,7 @@ app.put( [ sql`data->>'userId' = ${req.user.id}`, sql`data->>'deleted' IS NULL`, + sql`coalesce(data->>'projectId', '') = ${req.project?.id || ""}`, ...filters, ], { useReplica: false } @@ -1293,6 +1267,7 @@ app.post( sql`data->>'userId' = ${req.user.id}`, sql`data->>'deleted' IS NULL`, sql`data->'pull'->>'source' = ${payload.pull.source}`, + sql`coalesce(data->>'projectId', '') = ${req.project?.id || ""}`, ], { useReplica: false } ); @@ -1366,6 +1341,7 @@ async function handleCreateStream(req: Request, payload: NewStreamPayload) { renditions: {}, objectStoreId, id, + projectId: req.project?.id ?? "", createdAt, streamKey, playbackId, @@ -1375,7 +1351,7 @@ async function handleCreateStream(req: Request, payload: NewStreamPayload) { }; doc = wowzaHydrate(doc); - await validateStreamPlaybackPolicy(doc.playbackPolicy, req.user.id); + await validateStreamPlaybackPolicy(doc.playbackPolicy, req); doc.profiles = hackMistSettings(req, doc.profiles); doc.multistream = await validateMultistreamOpts( @@ -1767,10 +1743,7 @@ app.post( return res.json({ errors: ["stream not found"] }); } - if (stream.userId !== req.user.id) { - res.status(404); - return res.json({ errors: ["stream not found"] }); - } + req.checkResourceAccess(stream); const newTarget = await validateMultistreamTarget( req.user.id, @@ -1806,15 +1779,7 @@ app.delete("/:id/multistream/:targetId", authorizer({}), async (req, res) => { const stream = await db.stream.get(id); - if (!stream || stream.deleted) { - res.status(404); - return res.json({ errors: ["stream not found"] }); - } - - if (stream.userId !== req.user.id) { - res.status(404); - return res.json({ errors: ["stream not found"] }); - } + req.checkResourceAccess(stream); let multistream: DBStream["multistream"] = stream.multistream ?? { targets: [], @@ -1850,7 +1815,7 @@ app.patch( const stream = await db.stream.get(id); const exists = stream && !stream.deleted; - const hasAccess = stream?.userId === req.user.id || req.user.admin; + const hasAccess = hasAccessToResource(req, stream); if (!exists || !hasAccess) { res.status(404); return res.json({ errors: ["not found"] }); @@ -1899,7 +1864,7 @@ app.patch( } if (playbackPolicy) { - await validateStreamPlaybackPolicy(playbackPolicy, req.user.id); + await validateStreamPlaybackPolicy(playbackPolicy, req); patch = { ...patch, playbackPolicy }; } @@ -1930,10 +1895,7 @@ app.patch( app.patch("/:id/record", authorizer({}), async (req, res) => { const { id } = req.params; const stream = await db.stream.get(id); - if (!stream || stream.deleted) { - res.status(404); - return res.json({ errors: ["not found"] }); - } + req.checkResourceAccess(stream); if (stream.parentId) { res.status(400); return res.json({ errors: ["can't set for session"] }); @@ -1960,14 +1922,7 @@ app.patch("/:id/record", authorizer({}), async (req, res) => { app.delete("/:id", authorizer({}), async (req, res) => { const { id } = req.params; const stream = await db.stream.get(id); - if ( - !stream || - stream.deleted || - (stream.userId !== req.user.id && !req.user.admin) - ) { - res.status(404); - return res.json({ errors: ["not found"] }); - } + req.checkResourceAccess(stream); await db.stream.update(stream.id, { deleted: true, @@ -1993,7 +1948,7 @@ app.delete("/", authorizer({}), async (req, res) => { const streams = await db.stream.getMany(ids); if ( streams.length !== ids.length || - streams.some((s) => s.userId !== req.user.id) + streams.some((s) => !hasAccessToResource(req, s)) ) { res.status(404); return res.json({ errors: ["not found"] }); @@ -2024,16 +1979,7 @@ app.get("/:id/info", authorizer({}), async (req, res) => { isSession = true; stream = await db.stream.get(stream.parentId); } - if ( - !stream || - (!req.user.admin && (stream.deleted || stream.userId !== req.user.id)) - ) { - res.status(404); - return res.json({ - errors: ["not found"], - }); - } - + req.checkResourceAccess(stream); if (!session) { // find last session session = await db.stream.getLastSession(stream.id); @@ -2094,13 +2040,7 @@ app.patch("/:id/suspended", authorizer({}), async (req, res) => { } const { suspended } = req.body; const stream = await db.stream.get(id); - if ( - !stream || - (!req.user.admin && (stream.deleted || stream.userId !== req.user.id)) - ) { - res.status(404); - return res.json({ errors: ["not found"] }); - } + req.checkResourceAccess(stream); await db.stream.update(stream.id, { suspended }); if (suspended) { // now kill live stream @@ -2117,13 +2057,7 @@ app.post( async (req, res) => { const { id } = req.params; const stream = await db.stream.get(id); - if ( - !stream || - (!req.user.admin && (stream.deleted || stream.userId !== req.user.id)) - ) { - res.status(404); - return res.json({ errors: ["not found"] }); - } + req.checkResourceAccess(stream); if (!stream.pull) { res.status(400); @@ -2140,13 +2074,7 @@ app.post( app.delete("/:id/terminate", authorizer({}), async (req, res) => { const { id } = req.params; const stream = await db.stream.get(id); - if ( - !stream || - (!req.user.admin && (stream.deleted || stream.userId !== req.user.id)) - ) { - res.status(404); - return res.json({ errors: ["not found"] }); - } + req.checkResourceAccess(stream); if (terminateDelay(stream) > 0) { throw new TooManyRequestsError(`too many terminate requests`); diff --git a/packages/api/src/controllers/webhook.ts b/packages/api/src/controllers/webhook.ts index f39f6e7fa6..bb3e317783 100644 --- a/packages/api/src/controllers/webhook.ts +++ b/packages/api/src/controllers/webhook.ts @@ -1,5 +1,5 @@ import { URL } from "url"; -import { authorizer } from "../middleware"; +import { authorizer, hasAccessToResource } from "../middleware"; import { validatePost } from "../middleware"; import Router from "express/lib/router"; import logger from "../logger"; @@ -10,7 +10,7 @@ import sql from "sql-template-strings"; import { UnprocessableEntityError, NotFoundError } from "../store/errors"; import webhookLog from "./webhook-log"; -function validateWebhookPayload(id, userId, createdAt, payload) { +function validateWebhookPayload(id, userId, projectId, createdAt, payload) { try { new URL(payload.url); } catch (e) { @@ -27,6 +27,7 @@ function validateWebhookPayload(id, userId, createdAt, payload) { return { id, userId, + projectId: projectId ?? "", createdAt, kind: "webhook", name: payload.name, @@ -50,6 +51,7 @@ const fieldsMap: FieldsMap = { createdAt: { val: `webhook.data->'createdAt'`, type: "int" }, userId: `webhook.data->>'userId'`, "user.email": { val: `users.data->>'email'`, type: "full-text" }, + projectId: `webhook.data->>'projectId'`, sharedSecret: `webhook.data->>'sharedSecret'`, }; @@ -65,6 +67,9 @@ app.get("/", authorizer({}), async (req, res) => { if (!all || all === "false") { query.push(sql`webhook.data->>'deleted' IS NULL`); } + query.push( + sql`coalesce(webhook.data->>'projectId', '') = ${req.project?.id || ""}` + ); let fields = " webhook.id as id, webhook.data as data, users.id as usersId, users.data as usersdata"; @@ -96,6 +101,9 @@ app.get("/", authorizer({}), async (req, res) => { const query = parseFilters(fieldsMap, filters); query.push(sql`webhook.data->>'userId' = ${req.user.id}`); + query.push( + sql`coalesce(webhook.data->>'projectId', '') = ${req.project?.id || ""}` + ); if (!all || all === "false" || !req.user.admin) { query.push(sql`webhook.data->>'deleted' IS NULL`); @@ -145,7 +153,13 @@ app.get("/subscribed/:event", authorizer({}), async (req, res) => { app.post("/", authorizer({}), validatePost("webhook"), async (req, res) => { const id = uuid(); - const doc = validateWebhookPayload(id, req.user.id, Date.now(), req.body); + const doc = validateWebhookPayload( + id, + req.user.id, + req.project?.id, + Date.now(), + req.body + ); try { await req.store.create(doc); } catch (e) { @@ -161,13 +175,7 @@ app.get("/:id", authorizer({}), async (req, res) => { logger.info(`webhook params ${req.params.id}`); const webhook = await db.webhook.get(req.params.id); - if ( - !webhook || - ((webhook.deleted || webhook.userId !== req.user.id) && !req.user.admin) - ) { - res.status(404); - return res.json({ errors: ["not found"] }); - } + req.checkResourceAccess(webhook); res.status(200); res.json(webhook); @@ -176,14 +184,16 @@ app.get("/:id", authorizer({}), async (req, res) => { app.put("/:id", authorizer({}), validatePost("webhook"), async (req, res) => { // modify a specific webhook const webhook = await req.store.get(`webhook/${req.body.id}`); - if ((webhook.userId !== req.user.id || webhook.deleted) && !req.user.admin) { - // do not reveal that webhooks exists - res.status(404); - return res.json({ errors: ["not found"] }); - } + req.checkResourceAccess(webhook); - const { id, userId, createdAt } = webhook; - const doc = validateWebhookPayload(id, userId, createdAt, req.body); + const { id, userId, projectId, createdAt } = webhook; + const doc = validateWebhookPayload( + id, + userId, + projectId, + createdAt, + req.body + ); try { await req.store.replace(doc); } catch (e) { @@ -205,15 +215,9 @@ app.patch( throw new NotFoundError(`webhook not found`); } - if ( - (webhook.userId !== req.user.id || webhook.deleted) && - !req.user.admin - ) { - // do not reveal that webhooks exists - throw new NotFoundError(`webhook not found`); - } + req.checkResourceAccess(webhook); - const { id, userId, createdAt, kind } = webhook; + const { id, userId, projectId, createdAt, kind } = webhook; if (req.body.streamId) { const stream = await db.stream.get(req.body.streamId); @@ -238,14 +242,7 @@ app.patch( app.delete("/:id", authorizer({}), async (req, res) => { // delete a specific webhook const webhook = await db.webhook.get(req.params.id); - if ( - !webhook || - ((webhook.deleted || webhook.userId !== req.user.id) && !req.isUIAdmin) - ) { - // do not reveal that webhooks exists - res.status(404); - return res.json({ errors: ["not found"] }); - } + req.checkResourceAccess(webhook, true); try { await db.webhook.markDeleted(webhook.id); @@ -270,7 +267,7 @@ app.delete("/", authorizer({}), async (req, res) => { const webhooks = await db.webhook.getMany(ids); if ( webhooks.length !== ids.length || - webhooks.some((s) => s.deleted || s.userId !== req.user.id) + webhooks.some((s) => !hasAccessToResource(req, s)) ) { res.status(404); return res.json({ errors: ["not found"] }); diff --git a/packages/api/src/middleware/auth.ts b/packages/api/src/middleware/auth.ts index 2d97289d49..19ef0b958d 100644 --- a/packages/api/src/middleware/auth.ts +++ b/packages/api/src/middleware/auth.ts @@ -1,7 +1,7 @@ import { URL } from "url"; import basicAuth from "basic-auth"; import corsLib, { CorsOptions } from "cors"; -import { Request, RequestHandler, Response } from "express"; +import { Request, RequestHandler } from "express"; import jwt, { JwtPayload, TokenExpiredError } from "jsonwebtoken"; import { pathJoin2, trimPathPrefix } from "../controllers/helpers"; import { ApiToken, User, Project } from "../schema/types"; @@ -10,8 +10,9 @@ import { ForbiddenError, BadRequestError, UnauthorizedError, + NotFoundError, } from "../store/errors"; -import { WithID } from "../store/types"; +import { DBOwnedResource, WithID } from "../store/types"; import { AuthRule, AuthPolicy } from "./authPolicy"; import tracking from "./tracking"; @@ -61,7 +62,7 @@ function isAuthorized( export async function getProject(req: Request, projectId: string) { const project = projectId - ? await db.project.get(projectId) + ? await db.project.get(projectId, { useCache: true }) : { id: "", name: "default", userId: req.user.id }; if (!req.user.admin && req.user.id !== project.userId) { throw new ForbiddenError(`invalid user`); @@ -70,6 +71,23 @@ export async function getProject(req: Request, projectId: string) { return project; } +export function hasAccessToResource( + { isUIAdmin, user, project }: Pick, + resource?: DBOwnedResource, + uiAdminOnly = false +) { + if (!resource || !user) { + return false; + } + const isAdmin = uiAdminOnly ? isUIAdmin : user.admin; + return ( + isAdmin || + (!resource.deleted && + resource.userId === user.id && + (resource.projectId ?? "") === (project?.id ?? "")) + ); +} + /** * Creates a middleware that parses and verifies the authentication method from * the request and populates the `express.Request` object. @@ -179,6 +197,15 @@ function authenticator(): RequestHandler { // UI admins must have a JWT req.isUIAdmin = user.admin && authScheme === "jwt"; + req.checkResourceAccess = ( + resource?: DBOwnedResource, + uiAdminOnly = false + ) => { + if (!hasAccessToResource(req, resource, uiAdminOnly)) { + throw new NotFoundError("not found"); + } + }; + return next(); }; } diff --git a/packages/api/src/schema/api-schema.yaml b/packages/api/src/schema/api-schema.yaml index cc2cf0db12..557c47a4f0 100644 --- a/packages/api/src/schema/api-schema.yaml +++ b/packages/api/src/schema/api-schema.yaml @@ -176,6 +176,10 @@ components: name: type: string example: test_webhook + projectId: + type: string + description: The ID of the project + example: aac12556-4d65-4d34-9fb6-d1f0985eb0a9 createdAt: type: number readOnly: true @@ -611,6 +615,10 @@ components: height: 720 items: $ref: "#/components/schemas/ffmpeg-profile" + projectId: + type: string + description: The ID of the project + example: aac12556-4d65-4d34-9fb6-d1f0985eb0a9 record: description: | Should this stream be recorded? Uses default settings. For more @@ -841,6 +849,10 @@ components: type: string example: de7818e7-610a-4057-8f6f-b785dc1e6f88 description: Points to parent stream object + projectId: + type: string + description: The ID of the project + example: aac12556-4d65-4d34-9fb6-d1f0985eb0a9 record: description: > Whether the stream should be recorded. Uses default settings. For @@ -1250,6 +1262,15 @@ components: readOnly: true type: string description: URL to access file via HTTP through an IPFS gateway + new-signing-key-payload: + additionalProperties: false + properties: + name: + type: string + description: Name of the signing key + projectId: + type: string + description: Project ID to which this signing key belongs new-asset-payload: additionalProperties: false required: @@ -2093,6 +2114,10 @@ components: type: boolean description: Disable the signing key to allow rotation safely example: false + projectId: + type: string + description: The ID of the project + example: aac12556-4d65-4d34-9fb user: type: object required: diff --git a/packages/api/src/schema/db-schema.yaml b/packages/api/src/schema/db-schema.yaml index a192dcc0b8..098ccea13f 100644 --- a/packages/api/src/schema/db-schema.yaml +++ b/packages/api/src/schema/db-schema.yaml @@ -594,6 +594,10 @@ components: readOnly: true type: string index: true + projectId: + type: string + index: true + example: 66E2161C-7670-4D05-B71D-DA2D6979556F events: index: true indexType: gin @@ -1049,12 +1053,6 @@ components: example: 09F8B46C-61A0-4254-9875-F71F4C605BC7 catalystPipelineStrategy: $ref: "#/components/schemas/task/properties/params/properties/upload/properties/catalystPipelineStrategy" - new-signing-key-payload: - additionalProperties: false - properties: - name: - type: string - description: Name of the signing key signing-key-response-payload: type: object required: @@ -1323,6 +1321,10 @@ components: deleted: type: boolean default: false + projectId: + type: string + index: true + example: 78df0075-b5f3-4683-a618-1086faca35dc usage: table: usage properties: diff --git a/packages/api/src/store/types.ts b/packages/api/src/store/types.ts index 8d5413dcd7..4f6b40e923 100644 --- a/packages/api/src/store/types.ts +++ b/packages/api/src/store/types.ts @@ -21,6 +21,13 @@ export interface DBLegacyObject extends DBObject { data: Object; } +export interface DBOwnedResource extends DBObject { + // these are never really optional, but we don't have them as required in some schemas + userId?: string; + projectId?: string; + deleted?: boolean; +} + export type WithID = T & { id: string }; export interface FindQuery { diff --git a/packages/api/src/test-helpers.ts b/packages/api/src/test-helpers.ts index c426eb708a..b35d4e48b7 100644 --- a/packages/api/src/test-helpers.ts +++ b/packages/api/src/test-helpers.ts @@ -3,10 +3,11 @@ import fetch, { RequestInit } from "node-fetch"; import { v4 as uuid } from "uuid"; import schema from "./schema/schema.json"; -import { User } from "./schema/types"; +import { ApiToken, User } from "./schema/types"; import { TestServer } from "./test-server"; import fs from "fs"; import jwt, { VerifyOptions } from "jsonwebtoken"; +import { WithID } from "./store/types"; const vhostUrl = (vhost: string) => `http://guest:guest@127.0.0.1:15672/api/vhosts/${vhost}`; @@ -199,6 +200,32 @@ export class TestClient { } } +export async function createProject(client: TestClient) { + let res = await client.post(`/project`); + const project = await res.json(); + return project; +} + +export async function createApiToken({ + client, + projectId, + tokenName = "test", + jwtAuthToken, +}: { + client: TestClient; + projectId: string; + tokenName?: string; + jwtAuthToken: string; +}): Promise> { + client.jwtAuth = jwtAuthToken; + let res = await client.post(`/api-token/?projectId=${projectId}`, { + name: tokenName, + }); + client.jwtAuth = null; + const apiKeyObj = await res.json(); + return apiKeyObj; +} + export async function createUser( server: TestServer, client: TestClient, diff --git a/packages/api/src/types/common.d.ts b/packages/api/src/types/common.d.ts index ef20a3119f..718f89527a 100644 --- a/packages/api/src/types/common.d.ts +++ b/packages/api/src/types/common.d.ts @@ -49,6 +49,10 @@ declare global { isNeverExpiringJWT?: boolean; token?: WithID; + checkResourceAccess: ( + resource?: DBOwnedResource, + uiAdminOnly?: boolean + ) => void; getBroadcasters?: () => Promise; orchestratorsGetters?: Array<() => Promise>; getIngest?: () => Promise;