From b1549310e7835d6137d10b8b26fa3ff7fbe449e7 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 6 Nov 2024 11:56:23 +0000 Subject: [PATCH] feat: header based user permissions --- .gitignore | 1 + meteor/.meteor/packages | 2 - meteor/.meteor/versions | 9 - meteor/__mocks__/_setupMocks.ts | 1 - meteor/__mocks__/accounts-base.ts | 81 ---- meteor/__mocks__/meteor.ts | 39 +- meteor/__mocks__/mongo.ts | 2 - meteor/server/Connections.ts | 20 + meteor/server/__tests__/cronjobs.test.ts | 13 +- meteor/server/api/ExternalMessageQueue.ts | 31 +- .../api/__tests__/peripheralDevice.test.ts | 2 +- .../userActions/mediaManager.test.ts | 28 +- .../api/blueprints/__tests__/api.test.ts | 54 +-- .../api/blueprints/__tests__/http.test.ts | 2 +- meteor/server/api/blueprints/api.ts | 46 +-- meteor/server/api/blueprints/http.ts | 27 +- meteor/server/api/buckets.ts | 146 +++---- meteor/server/api/cleanup.ts | 4 - meteor/server/api/client.ts | 74 ++-- meteor/server/api/deviceTriggers/observer.ts | 2 +- meteor/server/api/evaluations.ts | 11 +- meteor/server/api/heapSnapshot.ts | 25 +- meteor/server/api/ingest/actions.ts | 6 +- meteor/server/api/ingest/lib.ts | 23 -- .../api/ingest/mosDevice/mosIntegration.ts | 8 +- meteor/server/api/ingest/rundownInput.ts | 8 +- .../api/integration/expectedPackages.ts | 2 +- .../server/api/integration/media-scanner.ts | 2 +- .../server/api/integration/mediaWorkFlows.ts | 2 +- meteor/server/api/lib.ts | 67 ---- meteor/server/api/mediaManager.ts | 125 +++--- meteor/server/api/methodContext.ts | 13 +- meteor/server/api/organizations.ts | 22 +- meteor/server/api/packageManager.ts | 56 ++- meteor/server/api/peripheralDevice.ts | 28 +- meteor/server/api/playout/api.ts | 17 +- meteor/server/api/playout/playout.ts | 11 +- .../server/api/rest/v0/__tests__/rest.test.ts | 17 - meteor/server/api/rest/v1/buckets.ts | 43 +-- meteor/server/api/rest/v1/index.ts | 12 +- meteor/server/api/rest/v1/playlists.ts | 2 +- meteor/server/api/rest/v1/studios.ts | 10 +- meteor/server/api/rest/v1/types.ts | 2 - meteor/server/api/rundown.ts | 33 +- meteor/server/api/rundownLayouts.ts | 16 +- meteor/server/api/showStyles.ts | 52 +-- meteor/server/api/singleUseTokens.ts | 2 +- meteor/server/api/snapshot.ts | 158 +++----- meteor/server/api/studio/api.ts | 18 +- meteor/server/api/system.ts | 22 +- meteor/server/api/triggeredActions.ts | 20 +- meteor/server/api/user.ts | 132 +------ meteor/server/api/userActions.ts | 193 ++++++---- meteor/server/collections/collection.ts | 37 +- meteor/server/collections/index.ts | 87 ++--- meteor/server/email.ts | 12 - .../__tests__/optimizedObserver.test.ts | 4 - .../server/lib/customPublication/publish.ts | 5 - meteor/server/main.ts | 3 +- meteor/server/methods.ts | 21 +- meteor/server/migration/api.ts | 31 +- .../blueprintUpgradeStatus/publication.ts | 12 +- meteor/server/publications/buckets.ts | 97 +++-- .../publications/deviceTriggersPreview.ts | 10 +- meteor/server/publications/lib/lib.ts | 105 +---- meteor/server/publications/mountedTriggers.ts | 61 ++- meteor/server/publications/organization.ts | 87 ++--- .../expectedPackages/publication.ts | 53 ++- .../packageManager/packageContainers.ts | 49 +-- .../packageManager/playoutContext.ts | 49 +-- .../partInstancesUI/publication.ts | 38 +- .../publications/partsUI/publication.ts | 39 +- .../server/publications/peripheralDevice.ts | 121 +++--- .../publications/peripheralDeviceForDevice.ts | 41 +- .../bucket/publication.ts | 44 +-- .../rundown/publication.ts | 46 +-- meteor/server/publications/rundown.ts | 349 +++++++---------- meteor/server/publications/rundownPlaylist.ts | 35 +- .../segmentPartNotesUI/publication.ts | 46 +-- meteor/server/publications/showStyle.ts | 73 ++-- meteor/server/publications/showStyleUI.ts | 46 +-- meteor/server/publications/studio.ts | 106 +++--- meteor/server/publications/studioUI.ts | 25 +- meteor/server/publications/system.ts | 94 +---- meteor/server/publications/timeline.ts | 54 ++- .../publications/translationsBundles.ts | 18 +- .../server/publications/triggeredActionsUI.ts | 41 +- meteor/server/security/README.md | 53 --- .../security/__tests__/security.test.ts | 358 ------------------ meteor/server/security/_security.ts | 11 - .../security/{lib/lib.ts => allowDeny.ts} | 7 +- meteor/server/security/auth.ts | 81 ++++ meteor/server/security/buckets.ts | 80 ---- meteor/server/security/check.ts | 104 +++++ meteor/server/security/lib/access.ts | 64 ---- meteor/server/security/lib/credentials.ts | 171 --------- meteor/server/security/lib/security.ts | 349 ----------------- meteor/server/security/noSecurity.ts | 12 - meteor/server/security/organization.ts | 165 -------- meteor/server/security/peripheralDevice.ts | 180 --------- meteor/server/security/rundown.ts | 126 ------ meteor/server/security/rundownPlaylist.ts | 126 ------ .../security/{lib => }/securityVerify.ts | 4 +- meteor/server/security/showStyle.ts | 154 -------- meteor/server/security/studio.ts | 155 -------- meteor/server/security/system.ts | 65 ---- meteor/server/security/translationsBundles.ts | 8 - meteor/server/systemStatus/api.ts | 60 ++- meteor/server/systemStatus/systemStatus.ts | 40 +- meteor/server/worker/worker.ts | 2 + packages/corelib/src/dataModel/Collections.ts | 1 - packages/meteor-lib/src/Settings.ts | 6 +- packages/meteor-lib/src/api/pubsub.ts | 4 - packages/meteor-lib/src/api/user.ts | 31 +- packages/meteor-lib/src/api/userActions.ts | 3 + packages/meteor-lib/src/collections/Users.ts | 29 -- packages/meteor-lib/src/userPermissions.ts | 58 +++ .../src/peripheralDevice/methodsAPI.ts | 2 +- packages/webui/src/__mocks__/meteor.ts | 31 +- packages/webui/src/__mocks__/mongo.ts | 2 - packages/webui/src/client/ui/App.tsx | 2 +- .../src/client/ui/Status/MediaManager.tsx | 6 +- .../ui/Status/SystemStatus/SystemStatus.tsx | 19 +- .../webui/src/client/ui/UserPermissions.tsx | 79 +++- packages/webui/src/meteor/meteor.js | 72 ---- .../src/meteor/socket-stream-client/urls.js | 4 - packages/webui/vite.config.mts | 4 + scripts/run.mjs | 31 +- 128 files changed, 1776 insertions(+), 4664 deletions(-) delete mode 100644 meteor/__mocks__/accounts-base.ts delete mode 100644 meteor/server/api/lib.ts delete mode 100644 meteor/server/email.ts delete mode 100644 meteor/server/security/README.md delete mode 100644 meteor/server/security/__tests__/security.test.ts delete mode 100644 meteor/server/security/_security.ts rename meteor/server/security/{lib/lib.ts => allowDeny.ts} (84%) create mode 100644 meteor/server/security/auth.ts delete mode 100644 meteor/server/security/buckets.ts create mode 100644 meteor/server/security/check.ts delete mode 100644 meteor/server/security/lib/access.ts delete mode 100644 meteor/server/security/lib/credentials.ts delete mode 100644 meteor/server/security/lib/security.ts delete mode 100644 meteor/server/security/noSecurity.ts delete mode 100644 meteor/server/security/organization.ts delete mode 100644 meteor/server/security/peripheralDevice.ts delete mode 100644 meteor/server/security/rundown.ts delete mode 100644 meteor/server/security/rundownPlaylist.ts rename meteor/server/security/{lib => }/securityVerify.ts (99%) delete mode 100644 meteor/server/security/showStyle.ts delete mode 100644 meteor/server/security/studio.ts delete mode 100644 meteor/server/security/system.ts delete mode 100644 meteor/server/security/translationsBundles.ts delete mode 100644 packages/meteor-lib/src/collections/Users.ts create mode 100644 packages/meteor-lib/src/userPermissions.ts diff --git a/.gitignore b/.gitignore index 5892e69d3e..6d86bbd070 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ meteor/.coverage/ node_modules **/yarn-error.log scratch/ +meteor-settings.json # Exclude JetBrains IDE specific files .idea diff --git a/meteor/.meteor/packages b/meteor/.meteor/packages index 8d1724b1db..34ab0cf5f5 100644 --- a/meteor/.meteor/packages +++ b/meteor/.meteor/packages @@ -19,6 +19,4 @@ typescript@5.4.3 # Enable TypeScript syntax in .ts and .tsx modules tracker@1.3.4 # Meteor's client-side reactive programming library -accounts-password@3.0.2 - zodern:types diff --git a/meteor/.meteor/versions b/meteor/.meteor/versions index 6048cd7897..cb58ec86a6 100644 --- a/meteor/.meteor/versions +++ b/meteor/.meteor/versions @@ -1,5 +1,3 @@ -accounts-base@3.0.3 -accounts-password@3.0.2 allow-deny@2.0.0 babel-compiler@7.11.1 babel-runtime@1.5.2 @@ -12,7 +10,6 @@ core-runtime@1.0.0 ddp@1.4.2 ddp-client@3.0.2 ddp-common@1.4.4 -ddp-rate-limiter@1.2.2 ddp-server@3.0.2 diff-sequence@1.1.3 dynamic-import@0.7.4 @@ -21,13 +18,11 @@ ecmascript-runtime@0.8.3 ecmascript-runtime-client@0.12.2 ecmascript-runtime-server@0.11.1 ejson@1.1.4 -email@3.1.0 facts-base@1.0.2 fetch@0.1.5 geojson-utils@1.0.12 id-map@1.2.0 inter-process-messaging@0.1.2 -localstorage@1.2.1 logging@1.3.5 meteor@2.0.1 minimongo@2.0.1 @@ -42,18 +37,14 @@ npm-mongo@4.17.4 ordered-dict@1.2.0 promise@1.0.0 random@1.2.2 -rate-limit@1.1.2 react-fast-refresh@0.2.9 -reactive-var@1.0.13 reload@1.3.2 retry@1.1.1 routepolicy@1.1.2 -sha@1.0.10 socket-stream-client@0.5.3 tracker@1.3.4 typescript@5.4.3 underscore@1.6.4 -url@1.3.4 webapp@2.0.3 webapp-hashing@1.1.2 zodern:types@1.0.13 diff --git a/meteor/__mocks__/_setupMocks.ts b/meteor/__mocks__/_setupMocks.ts index b4508a82bb..b9e7936792 100644 --- a/meteor/__mocks__/_setupMocks.ts +++ b/meteor/__mocks__/_setupMocks.ts @@ -14,7 +14,6 @@ jest.mock('meteor/meteor', (...args) => require('./meteor').setup(args), { virtu jest.mock('meteor/random', (...args) => require('./random').setup(args), { virtual: true }) jest.mock('meteor/check', (...args) => require('./check').setup(args), { virtual: true }) jest.mock('meteor/tracker', (...args) => require('./tracker').setup(args), { virtual: true }) -jest.mock('meteor/accounts-base', (...args) => require('./accounts-base').setup(args), { virtual: true }) jest.mock('meteor/ejson', (...args) => require('./ejson').setup(args), { virtual: true }) jest.mock('meteor/mdg:validated-method', (...args) => require('./validated-method').setup(args), { virtual: true }) diff --git a/meteor/__mocks__/accounts-base.ts b/meteor/__mocks__/accounts-base.ts deleted file mode 100644 index 468f6e8d78..0000000000 --- a/meteor/__mocks__/accounts-base.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { RandomMock } from './random' -import { MeteorMock } from './meteor' -import { Accounts } from 'meteor/accounts-base' - -export class AccountsBaseMock { - static mockUsers: any = {} - - // From https://docs.meteor.com/api/passwords.html - - static createUser( - options: Parameters[0], - cb: (err: any | undefined, result?: any) => void - ): void { - const user = { - _id: RandomMock.id(), - ...options, - } - AccountsBaseMock.mockUsers[user._id] = user - MeteorMock.setTimeout(() => { - cb(undefined, user._id) - }, 1) - throw new Error('Mocked function not implemented') - } - static setUsername(userId: string, newUsername: string): void { - AccountsBaseMock.mockUsers[userId].username = newUsername - throw new Error('Mocked function not implemented') - } - static removeEmail(_userId: string, _email: string): void { - throw new Error('Mocked function not implemented') - } - static verifyEmail(_token: string, _cb: (err: any | undefined, result?: any) => void): void { - throw new Error('Mocked function not implemented') - } - static findUserByUsername(_username: string): void { - throw new Error('Mocked function not implemented') - } - static findUserByEmail(_email: string): void { - throw new Error('Mocked function not implemented') - } - static changePassword( - _oldPassword: string, - _newPassword: string, - _cb: (err: any | undefined, result?: any) => void - ): void { - throw new Error('Mocked function not implemented') - } - static forgotPassword( - _options: { email?: string | undefined }, - _cb: (err: any | undefined, result?: any) => void - ): void { - throw new Error('Mocked function not implemented') - } - static resetPassword( - _token: string, - _newPassword: string, - _cb: (err: any | undefined, result?: any) => void - ): void { - throw new Error('Mocked function not implemented') - } - static setPassword(_userId: string, _newPassword: string, _options?: { logout?: Object | undefined }): void { - throw new Error('Mocked function not implemented') - } - static sendResetPasswordEmail(_userId: string, _email: string): void { - throw new Error('Mocked function not implemented') - } - static sendEnrollmentEmail(_userId: string, _email: string): void { - throw new Error('Mocked function not implemented') - } - static sendVerificationEmail(_userId: string, _email: string): void { - throw new Error('Mocked function not implemented') - } - static onResetPasswordLink?: () => void - static onEnrollmentLink?: () => void - static onEmailVerificationLink?: () => void - static emailTemplates?: () => void -} -export function setup(): any { - return { - Accounts: AccountsBaseMock, - } -} diff --git a/meteor/__mocks__/meteor.ts b/meteor/__mocks__/meteor.ts index 1b2ec69418..593eab34c4 100644 --- a/meteor/__mocks__/meteor.ts +++ b/meteor/__mocks__/meteor.ts @@ -1,4 +1,4 @@ -import { MongoMock } from './mongo' +import { USER_PERMISSIONS_HEADER } from '@sofie-automation/meteor-lib/dist/userPermissions' let controllableDefer = false @@ -9,7 +9,7 @@ export function useNextTickDefer(): void { controllableDefer = false } -namespace Meteor { +export namespace Meteor { export interface Settings { public: { [id: string]: any @@ -17,19 +17,6 @@ namespace Meteor { [id: string]: any } - export interface UserEmail { - address: string - verified: boolean - } - export interface User { - _id?: string - username?: string - emails?: UserEmail[] - createdAt?: number - profile?: any - services?: any - } - export interface ErrorStatic { new (error: string | number, reason?: string, details?: string): Error } @@ -103,22 +90,18 @@ export namespace MeteorMock { export const settings: any = {} export const mockMethods: { [name: string]: Function } = {} - export let mockUser: Meteor.User | undefined = undefined export const mockStartupFunctions: Function[] = [] export const absolutePath = process.cwd() - export function user(): Meteor.User | undefined { - return mockUser - } - export function userId(): string | undefined { - return mockUser ? mockUser._id : undefined - } function getMethodContext() { return { - userId: mockUser ? mockUser._id : undefined, connection: { clientAddress: '1.1.1.1', + httpHeaders: { + // Default to full permissions for tests + [USER_PERMISSIONS_HEADER]: 'admin', + }, }, unblock: () => { // noop @@ -212,9 +195,6 @@ export namespace MeteorMock { // but it'll do for now: return callAsync(methodName, ...args) } - export function absoluteUrl(path?: string): string { - return path + '' // todo - } export function setTimeout(fcn: () => void | Promise, time: number): number { return $.setTimeout(() => { Promise.resolve() @@ -256,7 +236,6 @@ export namespace MeteorMock { return fcn(...args) } } - export let users: MongoMock.Collection | undefined = undefined // -- Mock functions: -------------------------- /** @@ -269,12 +248,6 @@ export namespace MeteorMock { await waitTimeNoFakeTimers(10) // So that any observers or defers has had time to run. } - export function mockLoginUser(newUser: Meteor.User): void { - mockUser = newUser - } - export function mockSetUsersCollection(usersCollection: MongoMock.Collection): void { - users = usersCollection - } export function mockSetClientEnvironment(): void { mockIsClient = true } diff --git a/meteor/__mocks__/mongo.ts b/meteor/__mocks__/mongo.ts index d39e071ef0..fdd2074222 100644 --- a/meteor/__mocks__/mongo.ts +++ b/meteor/__mocks__/mongo.ts @@ -453,5 +453,3 @@ export function setup(): any { Mongo: MongoMock, } } - -MeteorMock.mockSetUsersCollection(new MongoMock.Collection('Meteor.users')) diff --git a/meteor/server/Connections.ts b/meteor/server/Connections.ts index d97d44d5fa..e0d094199d 100644 --- a/meteor/server/Connections.ts +++ b/meteor/server/Connections.ts @@ -4,6 +4,8 @@ import { logger } from './logging' import { sendTrace } from './api/integration/influx' import { PeripheralDevices } from './collections' import { MetricsGauge } from '@sofie-automation/corelib/dist/prometheus' +import { parseUserPermissions, USER_PERMISSIONS_HEADER } from '@sofie-automation/meteor-lib/dist/userPermissions' +import { Settings } from './Settings' const connections = new Set() const connectionsGauge = new MetricsGauge({ @@ -14,6 +16,24 @@ const connectionsGauge = new MetricsGauge({ Meteor.onConnection((conn: Meteor.Connection) => { // This is called whenever a new ddp-connection is opened (ie a web-client or a peripheral-device) + if (Settings.enableHeaderAuth) { + const userLevel = parseUserPermissions(conn.httpHeaders[USER_PERMISSIONS_HEADER]) + + // HACK: force the userId of the connection before it can be used. + // This ensures we know the permissions of the connection before it can try to do anything + // This could probably be safely done inside a meteor method, as we only need it when directly modifying a collection in the client, + // but that will cause all the publications to restart when changing the userId. + const connSession = (Meteor as any).server.sessions.get(conn.id) + if (!connSession) { + logger.error(`Failed to find session for ddp connection! "${conn.id}"`) + // Close the connection, it won't be secure + conn.close() + return + } else { + connSession.userId = JSON.stringify(userLevel) + } + } + const connectionId: string = conn.id // var clientAddress = conn.clientAddress; // ip-adress diff --git a/meteor/server/__tests__/cronjobs.test.ts b/meteor/server/__tests__/cronjobs.test.ts index 65bd80d24c..58cc06c469 100644 --- a/meteor/server/__tests__/cronjobs.test.ts +++ b/meteor/server/__tests__/cronjobs.test.ts @@ -466,7 +466,8 @@ describe('cronjobs', () => { expect(await Snapshots.findOneAsync(snapshot1)).toBeUndefined() }) async function insertPlayoutDevice( - props: Pick + props: Pick & + Partial> ): Promise { const deviceId = protectString(getRandomString()) await PeripheralDevices.insertAsync({ @@ -495,29 +496,35 @@ describe('cronjobs', () => { } async function createMockPlayoutGatewayAndDevices(lastSeen: number): Promise<{ + deviceToken: string mockPlayoutGw: PeripheralDeviceId mockCasparCg: PeripheralDeviceId mockAtem: PeripheralDeviceId }> { + const deviceToken = 'token1' const mockPlayoutGw = await insertPlayoutDevice({ deviceName: 'Playout Gateway', lastSeen: lastSeen, subType: PERIPHERAL_SUBTYPE_PROCESS, + token: deviceToken, }) const mockCasparCg = await insertPlayoutDevice({ deviceName: 'CasparCG', lastSeen: lastSeen, subType: TSR.DeviceType.CASPARCG, parentDeviceId: mockPlayoutGw, + token: deviceToken, }) const mockAtem = await insertPlayoutDevice({ deviceName: 'ATEM', lastSeen: lastSeen, subType: TSR.DeviceType.ATEM, parentDeviceId: mockPlayoutGw, + token: deviceToken, }) return { + deviceToken, mockPlayoutGw, mockCasparCg, mockAtem, @@ -525,7 +532,7 @@ describe('cronjobs', () => { } test('Attempts to restart CasparCG when job is enabled', async () => { - const { mockCasparCg } = await createMockPlayoutGatewayAndDevices(Date.now()) // Some time after the threshold + const { mockCasparCg, deviceToken } = await createMockPlayoutGatewayAndDevices(Date.now()) // Some time after the threshold ;(logger.info as jest.Mock).mockClear() // set time to 2020/07/{date} 04:05 Local Time, should be more than 24 hours after 2020/07/19 00:00 UTC @@ -548,7 +555,7 @@ describe('cronjobs', () => { Meteor.callAsync( 'peripheralDevice.functionReply', cmd.deviceId, // deviceId - '', // deviceToken + deviceToken, // deviceToken cmd._id, // commandId null, // err null // result diff --git a/meteor/server/api/ExternalMessageQueue.ts b/meteor/server/api/ExternalMessageQueue.ts index 5d90abb7e3..0a5fdf7414 100644 --- a/meteor/server/api/ExternalMessageQueue.ts +++ b/meteor/server/api/ExternalMessageQueue.ts @@ -9,11 +9,14 @@ import { } from '@sofie-automation/meteor-lib/dist/api/ExternalMessageQueue' import { StatusObject, setSystemStatus } from '../systemStatus/systemStatus' import { MethodContextAPI, MethodContext } from './methodContext' -import { StudioContentWriteAccess } from '../security/studio' import { ExternalMessageQueueObjId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { ExternalMessageQueue } from '../collections' import { ExternalMessageQueueObj } from '@sofie-automation/corelib/dist/dataModel/ExternalMessageQueue' import { MongoQuery } from '@sofie-automation/corelib/dist/mongo' +import { UserPermissions } from '@sofie-automation/meteor-lib/dist/userPermissions' +import { assertConnectionHasOneOfPermissions } from '../security/auth' + +const USER_PERMISSIONS_FOR_EXTERNAL_MESSAGES: Array = ['configure', 'studio', 'service'] let updateExternalMessageQueueStatusTimeout = 0 function updateExternalMessageQueueStatus(): void { @@ -69,28 +72,33 @@ Meteor.startup(async () => { async function removeExternalMessage(context: MethodContext, messageId: ExternalMessageQueueObjId): Promise { check(messageId, String) - await StudioContentWriteAccess.externalMessage(context, messageId) + + assertConnectionHasOneOfPermissions(context.connection, ...USER_PERMISSIONS_FOR_EXTERNAL_MESSAGES) // TODO - is this safe? what if it is in the middle of execution? await ExternalMessageQueue.removeAsync(messageId) } async function toggleHold(context: MethodContext, messageId: ExternalMessageQueueObjId): Promise { check(messageId, String) - const access = await StudioContentWriteAccess.externalMessage(context, messageId) - const m = access.message - if (!m) throw new Meteor.Error(404, `ExternalMessage "${messageId}" not found!`) + + assertConnectionHasOneOfPermissions(context.connection, ...USER_PERMISSIONS_FOR_EXTERNAL_MESSAGES) + + const existingMessage = await ExternalMessageQueue.findOneAsync(messageId) + if (!existingMessage) throw new Meteor.Error(404, `ExternalMessage "${messageId}" not found!`) await ExternalMessageQueue.updateAsync(messageId, { $set: { - hold: !m.hold, + hold: !existingMessage.hold, }, }) } async function retry(context: MethodContext, messageId: ExternalMessageQueueObjId): Promise { check(messageId, String) - const access = await StudioContentWriteAccess.externalMessage(context, messageId) - const m = access.message - if (!m) throw new Meteor.Error(404, `ExternalMessage "${messageId}" not found!`) + + assertConnectionHasOneOfPermissions(context.connection, ...USER_PERMISSIONS_FOR_EXTERNAL_MESSAGES) + + const existingMessage = await ExternalMessageQueue.findOneAsync(messageId) + if (!existingMessage) throw new Meteor.Error(404, `ExternalMessage "${messageId}" not found!`) const tryGap = getCurrentTime() - 1 * 60 * 1000 await ExternalMessageQueue.updateAsync(messageId, { @@ -98,7 +106,10 @@ async function retry(context: MethodContext, messageId: ExternalMessageQueueObjI manualRetry: true, hold: false, errorFatal: false, - lastTry: m.lastTry !== undefined && m.lastTry > tryGap ? tryGap : m.lastTry, + lastTry: + existingMessage.lastTry !== undefined && existingMessage.lastTry > tryGap + ? tryGap + : existingMessage.lastTry, }, }) // triggerdoMessageQueue(1000) diff --git a/meteor/server/api/__tests__/peripheralDevice.test.ts b/meteor/server/api/__tests__/peripheralDevice.test.ts index 6efe7a1596..4a3b69fe5a 100644 --- a/meteor/server/api/__tests__/peripheralDevice.test.ts +++ b/meteor/server/api/__tests__/peripheralDevice.test.ts @@ -618,7 +618,7 @@ describe('test peripheralDevice general API methods', () => { const deviceObj = await PeripheralDevices.findOneAsync(device?._id) expect(deviceObj).toBeDefined() - await MeteorCall.peripheralDevice.removePeripheralDevice(device?._id) + await MeteorCall.peripheralDevice.removePeripheralDevice(device._id, device.token) } { diff --git a/meteor/server/api/__tests__/userActions/mediaManager.test.ts b/meteor/server/api/__tests__/userActions/mediaManager.test.ts index 3680cffde2..bf58417d6f 100644 --- a/meteor/server/api/__tests__/userActions/mediaManager.test.ts +++ b/meteor/server/api/__tests__/userActions/mediaManager.test.ts @@ -47,11 +47,16 @@ describe('User Actions - Media Manager', () => { jest.resetAllMocks() }) test('Restart workflow', async () => { - const { workFlowId } = await setupMockWorkFlow() + const { workFlowId, workFlow } = await setupMockWorkFlow() // should fail if the workflow doesn't exist await expect( - MeteorCall.userAction.mediaRestartWorkflow('', getCurrentTime(), protectString('FAKE_ID')) + MeteorCall.userAction.mediaRestartWorkflow( + '', + getCurrentTime(), + workFlow.deviceId, + protectString('FAKE_ID') + ) ).resolves.toMatchUserRawError(/not found/gi) { @@ -72,16 +77,16 @@ describe('User Actions - Media Manager', () => { }) }, MAX_WAIT_TIME) - await MeteorCall.userAction.mediaRestartWorkflow('', getCurrentTime(), workFlowId) + await MeteorCall.userAction.mediaRestartWorkflow('', getCurrentTime(), workFlow.deviceId, workFlowId) await p } }) test('Abort worfklow', async () => { - const { workFlowId } = await setupMockWorkFlow() + const { workFlowId, workFlow } = await setupMockWorkFlow() // should fail if the workflow doesn't exist await expect( - MeteorCall.userAction.mediaAbortWorkflow('', getCurrentTime(), protectString('FAKE_ID')) + MeteorCall.userAction.mediaAbortWorkflow('', getCurrentTime(), workFlow.deviceId, protectString('FAKE_ID')) ).resolves.toMatchUserRawError(/not found/gi) { @@ -103,16 +108,21 @@ describe('User Actions - Media Manager', () => { }) }, MAX_WAIT_TIME) - await MeteorCall.userAction.mediaAbortWorkflow('', getCurrentTime(), workFlowId) + await MeteorCall.userAction.mediaAbortWorkflow('', getCurrentTime(), workFlow.deviceId, workFlowId) await p } }) test('Prioritize workflow', async () => { - const { workFlowId } = await setupMockWorkFlow() + const { workFlowId, workFlow } = await setupMockWorkFlow() // should fail if the workflow doesn't exist await expect( - MeteorCall.userAction.mediaPrioritizeWorkflow('', getCurrentTime(), protectString('FAKE_ID')) + MeteorCall.userAction.mediaPrioritizeWorkflow( + '', + getCurrentTime(), + workFlow.deviceId, + protectString('FAKE_ID') + ) ).resolves.toMatchUserRawError(/not found/gi) { @@ -134,7 +144,7 @@ describe('User Actions - Media Manager', () => { }) }, MAX_WAIT_TIME) - await MeteorCall.userAction.mediaPrioritizeWorkflow('', getCurrentTime(), workFlowId) + await MeteorCall.userAction.mediaPrioritizeWorkflow('', getCurrentTime(), workFlow.deviceId, workFlowId) await p } }) diff --git a/meteor/server/api/blueprints/__tests__/api.test.ts b/meteor/server/api/blueprints/__tests__/api.test.ts index b92bf0a0ac..b2c60d3d4a 100644 --- a/meteor/server/api/blueprints/__tests__/api.test.ts +++ b/meteor/server/api/blueprints/__tests__/api.test.ts @@ -6,29 +6,23 @@ import { BlueprintManifestType } from '@sofie-automation/blueprints-integration' import { SYSTEM_ID, ICoreSystem } from '@sofie-automation/meteor-lib/dist/collections/CoreSystem' import { insertBlueprint, uploadBlueprint } from '../api' import { MeteorCall } from '../../methods' -import { MethodContext } from '../../methodContext' import '../../../../__mocks__/_extendJest' import { Blueprints, CoreSystem } from '../../../collections' import { SupressLogMessages } from '../../../../__mocks__/suppressLogging' import { JSONBlobStringify } from '@sofie-automation/shared-lib/dist/lib/JSONBlob' +import { Meteor } from 'meteor/meteor' // we don't want the deviceTriggers observer to start up at this time jest.mock('../../deviceTriggers/observer') require('../../peripheralDevice.ts') // include in order to create the Meteor methods needed -const DEFAULT_CONTEXT: MethodContext = { - userId: null, - isSimulation: false, - connection: { - id: 'mockConnectionId', - close: () => undefined, - onClose: () => undefined, - clientAddress: '127.0.0.1', - httpHeaders: {}, - }, - setUserId: () => undefined, - unblock: () => undefined, +const DEFAULT_CONNECTION: Meteor.Connection = { + id: 'mockConnectionId', + close: () => undefined, + onClose: () => undefined, + clientAddress: '127.0.0.1', + httpHeaders: {}, } describe('Test blueprint management api', () => { @@ -195,7 +189,7 @@ describe('Test blueprint management api', () => { }) test('with name', async () => { const rawName = 'some_fake_name' - const newId = await insertBlueprint(DEFAULT_CONTEXT, undefined, rawName) + const newId = await insertBlueprint(DEFAULT_CONNECTION, undefined, rawName) expect(newId).toBeTruthy() // Check some props @@ -206,7 +200,7 @@ describe('Test blueprint management api', () => { }) test('with type', async () => { const type = BlueprintManifestType.STUDIO - const newId = await insertBlueprint(DEFAULT_CONTEXT, type) + const newId = await insertBlueprint(DEFAULT_CONNECTION, type) expect(newId).toBeTruthy() // Check some props @@ -219,20 +213,20 @@ describe('Test blueprint management api', () => { describe('uploadBlueprint', () => { test('empty id', async () => { - await expect(uploadBlueprint(DEFAULT_CONTEXT, protectString(''), '0')).rejects.toThrowMeteor( + await expect(uploadBlueprint(DEFAULT_CONNECTION, protectString(''), '0')).rejects.toThrowMeteor( 400, 'Blueprint id "" is not valid' ) }) test('empty body', async () => { - await expect(uploadBlueprint(DEFAULT_CONTEXT, protectString('blueprint99'), '')).rejects.toThrowMeteor( + await expect(uploadBlueprint(DEFAULT_CONNECTION, protectString('blueprint99'), '')).rejects.toThrowMeteor( 400, 'Blueprint blueprint99 failed to parse' ) }) test('body not a manifest', async () => { await expect( - uploadBlueprint(DEFAULT_CONTEXT, protectString('blueprint99'), `({default: (() => 5)()})`) + uploadBlueprint(DEFAULT_CONNECTION, protectString('blueprint99'), `({default: (() => 5)()})`) ).rejects.toThrowMeteor(400, 'Blueprint blueprint99 returned a manifest of type number') }) test('manifest missing blueprintType', async () => { @@ -254,7 +248,7 @@ describe('Test blueprint management api', () => { } }) await expect( - uploadBlueprint(DEFAULT_CONTEXT, protectString('blueprint99'), blueprintStr) + uploadBlueprint(DEFAULT_CONNECTION, protectString('blueprint99'), blueprintStr) ).rejects.toThrowMeteor( 400, `Blueprint blueprint99 returned a manifest of unknown blueprintType "undefined"` @@ -281,7 +275,9 @@ describe('Test blueprint management api', () => { })) as Blueprint expect(existingBlueprint).toBeTruthy() - await expect(uploadBlueprint(DEFAULT_CONTEXT, existingBlueprint._id, blueprintStr)).rejects.toThrowMeteor( + await expect( + uploadBlueprint(DEFAULT_CONNECTION, existingBlueprint._id, blueprintStr) + ).rejects.toThrowMeteor( 400, `Cannot replace old blueprint (of type "showstyle") with new blueprint of type "studio"` ) @@ -305,7 +301,7 @@ describe('Test blueprint management api', () => { } ) - const blueprint = await uploadBlueprint(DEFAULT_CONTEXT, protectString('tmp_showstyle'), blueprintStr) + const blueprint = await uploadBlueprint(DEFAULT_CONNECTION, protectString('tmp_showstyle'), blueprintStr) expect(blueprint).toBeTruthy() expect(blueprint).toMatchObject( literal>({ @@ -344,7 +340,7 @@ describe('Test blueprint management api', () => { ) const blueprint = await uploadBlueprint( - DEFAULT_CONTEXT, + DEFAULT_CONNECTION, protectString('tmp_studio'), blueprintStr, 'tmp name' @@ -388,7 +384,7 @@ describe('Test blueprint management api', () => { ) const blueprint = await uploadBlueprint( - DEFAULT_CONTEXT, + DEFAULT_CONNECTION, protectString('tmp_system'), blueprintStr, 'tmp name' @@ -436,7 +432,7 @@ describe('Test blueprint management api', () => { expect(existingBlueprint).toBeTruthy() expect(existingBlueprint.blueprintId).toBeFalsy() - const blueprint = await uploadBlueprint(DEFAULT_CONTEXT, existingBlueprint._id, blueprintStr) + const blueprint = await uploadBlueprint(DEFAULT_CONNECTION, existingBlueprint._id, blueprintStr) expect(blueprint).toBeTruthy() expect(blueprint).toMatchObject( literal>({ @@ -482,7 +478,7 @@ describe('Test blueprint management api', () => { expect(existingBlueprint).toBeTruthy() expect(existingBlueprint.blueprintId).toBeTruthy() - const blueprint = await uploadBlueprint(DEFAULT_CONTEXT, existingBlueprint._id, blueprintStr) + const blueprint = await uploadBlueprint(DEFAULT_CONNECTION, existingBlueprint._id, blueprintStr) expect(blueprint).toBeTruthy() expect(blueprint).toMatchObject( literal>({ @@ -528,7 +524,9 @@ describe('Test blueprint management api', () => { expect(existingBlueprint).toBeTruthy() expect(existingBlueprint.blueprintId).toBeTruthy() - await expect(uploadBlueprint(DEFAULT_CONTEXT, existingBlueprint._id, blueprintStr)).rejects.toThrowMeteor( + await expect( + uploadBlueprint(DEFAULT_CONNECTION, existingBlueprint._id, blueprintStr) + ).rejects.toThrowMeteor( 422, `Cannot replace old blueprint "${existingBlueprint._id}" ("ss1") with new blueprint "show2"` ) @@ -558,7 +556,9 @@ describe('Test blueprint management api', () => { expect(existingBlueprint).toBeTruthy() expect(existingBlueprint.blueprintId).toBeTruthy() - await expect(uploadBlueprint(DEFAULT_CONTEXT, existingBlueprint._id, blueprintStr)).rejects.toThrowMeteor( + await expect( + uploadBlueprint(DEFAULT_CONNECTION, existingBlueprint._id, blueprintStr) + ).rejects.toThrowMeteor( 422, `Cannot replace old blueprint "${existingBlueprint._id}" ("ss1") with new blueprint ""` ) diff --git a/meteor/server/api/blueprints/__tests__/http.test.ts b/meteor/server/api/blueprints/__tests__/http.test.ts index 17d493b0e3..887b7d61d8 100644 --- a/meteor/server/api/blueprints/__tests__/http.test.ts +++ b/meteor/server/api/blueprints/__tests__/http.test.ts @@ -8,7 +8,7 @@ jest.mock('../../deviceTriggers/observer') import * as api from '../api' jest.mock('../api.ts') -const DEFAULT_CONTEXT = { userId: '' } +const DEFAULT_CONTEXT = expect.objectContaining({ req: expect.any(Object), res: expect.any(Object) }) require('../http.ts') // include in order to create the Meteor methods needed diff --git a/meteor/server/api/blueprints/api.ts b/meteor/server/api/blueprints/api.ts index e2f2ec7bc5..8a294bdfec 100644 --- a/meteor/server/api/blueprints/api.ts +++ b/meteor/server/api/blueprints/api.ts @@ -20,10 +20,6 @@ import { parseVersion } from '../../systemStatus/semverUtils' import { evalBlueprint } from './cache' import { removeSystemStatus } from '../../systemStatus/systemStatus' import { MethodContext, MethodContextAPI } from '../methodContext' -import { OrganizationContentWriteAccess, OrganizationReadAccess } from '../../security/organization' -import { SystemWriteAccess } from '../../security/system' -import { Credentials, isResolvedCredentials } from '../../security/lib/credentials' -import { Settings } from '../../Settings' import { generateTranslationBundleOriginId, upsertBundles } from '../translationsBundles' import { BlueprintId, OrganizationId, ShowStyleBaseId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { Blueprints, CoreSystem, ShowStyleBases, ShowStyleVariants, Studios } from '../../collections' @@ -32,21 +28,21 @@ import { getSystemStorePath } from '../../coreSystem' import { DBShowStyleBase } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' import { DBShowStyleVariant } from '@sofie-automation/corelib/dist/dataModel/ShowStyleVariant' import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio' +import { UserPermissions } from '@sofie-automation/meteor-lib/dist/userPermissions' +import { assertConnectionHasOneOfPermissions, RequestCredentials } from '../../security/auth' + +const PERMISSIONS_FOR_MANAGE_BLUEPRINTS: Array = ['configure'] export async function insertBlueprint( - methodContext: MethodContext, + cred: RequestCredentials | null, type?: BlueprintManifestType, name?: string ): Promise { - const { organizationId, cred } = await OrganizationContentWriteAccess.blueprint(methodContext) - if (Settings.enableUserAccounts && isResolvedCredentials(cred)) { - if (!cred.user || !cred.user.superAdmin) { - throw new Meteor.Error(401, 'Only super admins can create new blueprints') - } - } + assertConnectionHasOneOfPermissions(cred, ...PERMISSIONS_FOR_MANAGE_BLUEPRINTS) + return Blueprints.insertAsync({ _id: getRandomId(), - organizationId: organizationId, + organizationId: null, name: name || 'New Blueprint', hasCode: false, code: '', @@ -72,7 +68,9 @@ export async function insertBlueprint( } export async function removeBlueprint(methodContext: MethodContext, blueprintId: BlueprintId): Promise { check(blueprintId, String) - await OrganizationContentWriteAccess.blueprint(methodContext, blueprintId, true) + + assertConnectionHasOneOfPermissions(methodContext.connection, ...PERMISSIONS_FOR_MANAGE_BLUEPRINTS) + if (!blueprintId) throw new Meteor.Error(404, `Blueprint id "${blueprintId}" was not found`) await Blueprints.removeAsync(blueprintId) @@ -80,7 +78,7 @@ export async function removeBlueprint(methodContext: MethodContext, blueprintId: } export async function uploadBlueprint( - context: Credentials, + cred: RequestCredentials, blueprintId: BlueprintId, body: string, blueprintName?: string, @@ -90,19 +88,21 @@ export async function uploadBlueprint( check(body, String) check(blueprintName, Match.Maybe(String)) - // TODO: add access control here - const { organizationId } = await OrganizationContentWriteAccess.blueprint(context, blueprintId, true) + assertConnectionHasOneOfPermissions(cred, ...PERMISSIONS_FOR_MANAGE_BLUEPRINTS) + if (!Meteor.isTest) logger.info(`Got blueprint '${blueprintId}'. ${body.length} bytes`) if (!blueprintId) throw new Meteor.Error(400, `Blueprint id "${blueprintId}" is not valid`) const blueprint = await fetchBlueprintLight(blueprintId) - return innerUploadBlueprint(organizationId, blueprint, blueprintId, body, blueprintName, ignoreIdChange) + return innerUploadBlueprint(null, blueprint, blueprintId, body, blueprintName, ignoreIdChange) } -export async function uploadBlueprintAsset(_context: Credentials, fileId: string, body: string): Promise { +export async function uploadBlueprintAsset(cred: RequestCredentials, fileId: string, body: string): Promise { check(fileId, String) check(body, String) + assertConnectionHasOneOfPermissions(cred, ...PERMISSIONS_FOR_MANAGE_BLUEPRINTS) + const storePath = getSystemStorePath() // TODO: add access control here @@ -115,12 +115,11 @@ export async function uploadBlueprintAsset(_context: Credentials, fileId: string await fsp.mkdir(path.join(storePath, parsedPath.dir), { recursive: true }) await fsp.writeFile(path.join(storePath, fileId), data) } -export function retrieveBlueprintAsset(_context: Credentials, fileId: string): ReadStream { +export function retrieveBlueprintAsset(_cred: RequestCredentials, fileId: string): ReadStream { check(fileId, String) const storePath = getSystemStorePath() - // TODO: add access control here return createReadStream(path.join(storePath, fileId)) } /** Only to be called from internal functions */ @@ -363,7 +362,7 @@ async function syncConfigPresetsToStudios(blueprint: Blueprint): Promise { } async function assignSystemBlueprint(methodContext: MethodContext, blueprintId: BlueprintId | null): Promise { - await SystemWriteAccess.coreSystem(methodContext) + assertConnectionHasOneOfPermissions(methodContext.connection, ...PERMISSIONS_FOR_MANAGE_BLUEPRINTS) if (blueprintId !== undefined && blueprintId !== null) { check(blueprintId, String) @@ -371,9 +370,6 @@ async function assignSystemBlueprint(methodContext: MethodContext, blueprintId: const blueprint = await fetchBlueprintLight(blueprintId) if (!blueprint) throw new Meteor.Error(404, 'Blueprint not found') - if (blueprint.organizationId) - await OrganizationReadAccess.organizationContent(blueprint.organizationId, { userId: methodContext.userId }) - if (blueprint.blueprintType !== BlueprintManifestType.SYSTEM) throw new Meteor.Error(404, 'Blueprint not of type SYSTEM') @@ -393,7 +389,7 @@ async function assignSystemBlueprint(methodContext: MethodContext, blueprintId: class ServerBlueprintAPI extends MethodContextAPI implements ReplaceOptionalWithNullInMethodArguments { async insertBlueprint() { - return insertBlueprint(this) + return insertBlueprint(this.connection) } async removeBlueprint(blueprintId: BlueprintId) { return removeBlueprint(this, blueprintId) diff --git a/meteor/server/api/blueprints/http.ts b/meteor/server/api/blueprints/http.ts index 70a0bb520f..ae364dd81f 100644 --- a/meteor/server/api/blueprints/http.ts +++ b/meteor/server/api/blueprints/http.ts @@ -38,20 +38,12 @@ blueprintsRouter.post( check(blueprintId, String) check(blueprintName, Match.Maybe(String)) - const userId = ctx.headers.authorization ? ctx.headers.authorization.split(' ')[1] : '' - const body = ctx.request.body || ctx.req.body if (!body) throw new Meteor.Error(400, 'Restore Blueprint: Missing request body') if (typeof body !== 'string' || body.length < 10) throw new Meteor.Error(400, 'Restore Blueprint: Invalid request body') - await uploadBlueprint( - { userId: protectString(userId) }, - protectString(blueprintId), - body, - blueprintName, - force - ) + await uploadBlueprint(ctx, protectString(blueprintId), body, blueprintName, force) ctx.response.status = 200 ctx.body = '' @@ -89,13 +81,7 @@ blueprintsRouter.post( const errors: any[] = [] for (const id of _.keys(collection.blueprints)) { try { - const userId = ctx.headers.authorization ? ctx.headers.authorization.split(' ')[1] : '' - await uploadBlueprint( - { userId: protectString(userId) }, - protectString(id), - collection.blueprints[id], - id - ) + await uploadBlueprint(ctx, protectString(id), collection.blueprints[id], id) } catch (e) { logger.error('Blueprint restore failed: ' + e) errors.push(e) @@ -104,8 +90,7 @@ blueprintsRouter.post( if (collection.assets) { for (const id of _.keys(collection.assets)) { try { - const userId = ctx.headers.authorization ? ctx.headers.authorization.split(' ')[1] : '' - await uploadBlueprintAsset({ userId: protectString(userId) }, id, collection.assets[id]) + await uploadBlueprintAsset(ctx, id, collection.assets[id]) } catch (e) { logger.error('Blueprint assets upload failed: ' + e) errors.push(e) @@ -157,8 +142,7 @@ blueprintsRouter.post( const errors: any[] = [] for (const id of _.keys(collection)) { try { - const userId = ctx.headers.authorization ? ctx.headers.authorization.split(' ')[1] : '' - await uploadBlueprintAsset({ userId: protectString(userId) }, id, collection[id]) + await uploadBlueprintAsset(ctx, id, collection[id]) } catch (e) { logger.error('Blueprint assets upload failed: ' + e) errors.push(e) @@ -192,9 +176,8 @@ blueprintsRouter.get('/assets/(.*)', async (ctx) => { const filePath = ctx.params[0] if (filePath.match(/\.(png|svg|gif)?$/)) { - const userId = ctx.headers.authorization ? ctx.headers.authorization.split(' ')[1] : '' try { - const dataStream = retrieveBlueprintAsset({ userId: protectString(userId) }, filePath) + const dataStream = retrieveBlueprintAsset(ctx, filePath) const extension = path.extname(filePath) if (extension === '.svg') { ctx.response.type = 'image/svg+xml' diff --git a/meteor/server/api/buckets.ts b/meteor/server/api/buckets.ts index ac2ad69cbe..109ae82ba9 100644 --- a/meteor/server/api/buckets.ts +++ b/meteor/server/api/buckets.ts @@ -2,18 +2,23 @@ import * as _ from 'underscore' import { Meteor } from 'meteor/meteor' import { Bucket } from '@sofie-automation/meteor-lib/dist/collections/Buckets' import { getRandomId, getRandomString, literal } from '../lib/tempLib' -import { BucketSecurity } from '../security/buckets' import { BucketAdLib } from '@sofie-automation/corelib/dist/dataModel/BucketAdLibPiece' import { AdLibAction, AdLibActionCommon } from '@sofie-automation/corelib/dist/dataModel/AdlibAction' import { BucketAdLibAction } from '@sofie-automation/corelib/dist/dataModel/BucketAdLibAction' -import { BucketAdLibActions, Buckets, Rundowns, ShowStyleVariants, Studios } from '../collections' +import { BucketAdLibActions, BucketAdLibs, Buckets, Rundowns, ShowStyleVariants, Studios } from '../collections' import { runIngestOperation } from './ingest/lib' import { IngestJobs } from '@sofie-automation/corelib/dist/worker/ingest' -import { StudioContentAccess } from '../security/studio' -import { Settings } from '../Settings' import { IngestAdlib } from '@sofie-automation/blueprints-integration' import { getShowStyleCompound } from './showStyles' -import { ShowStyleBaseId, ShowStyleVariantId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { + BucketAdLibActionId, + BucketAdLibId, + BucketId, + ShowStyleBaseId, + ShowStyleVariantId, + StudioId, +} from '@sofie-automation/corelib/dist/dataModel/Ids' +import { fetchStudioLight } from '../optimizations' const DEFAULT_BUCKET_WIDTH = undefined @@ -25,18 +30,28 @@ function isBucketAdLibAction(action: AdLibActionCommon | BucketAdLibAction): act } export namespace BucketsAPI { - export async function removeBucketAdLib(access: BucketSecurity.BucketAdlibPieceContentAccess): Promise { - const adlib = access.adlib + export async function removeBucketAdLib(adLibId: BucketAdLibId): Promise { + const adlib = (await BucketAdLibs.findOneAsync(adLibId, { + projection: { + _id: 1, + studioId: 1, + }, + })) as Pick | undefined + if (!adlib) throw new Meteor.Error(404, `BucketAdLib "${adLibId}" not found`) await runIngestOperation(adlib.studioId, IngestJobs.BucketRemoveAdlibPiece, { pieceId: adlib._id, }) } - export async function removeBucketAdLibAction( - access: BucketSecurity.BucketAdlibActionContentAccess - ): Promise { - const adlib = access.action + export async function removeBucketAdLibAction(adLibActionId: BucketAdLibActionId): Promise { + const adlib = (await BucketAdLibActions.findOneAsync(adLibActionId, { + projection: { + _id: 1, + studioId: 1, + }, + })) as Pick | undefined + if (!adlib) throw new Meteor.Error(404, `BucketAdLibAction "${adLibActionId}" not found`) await runIngestOperation(adlib.studioId, IngestJobs.BucketRemoveAdlibAction, { actionId: adlib._id, @@ -44,22 +59,26 @@ export namespace BucketsAPI { } export async function modifyBucket( - access: BucketSecurity.BucketContentAccess, + bucketId: BucketId, bucketProps: Partial> ): Promise { - await Buckets.updateAsync(access.bucket._id, { + await Buckets.updateAsync(bucketId, { $set: _.omit(bucketProps, ['_id', 'studioId']), }) } - export async function emptyBucket(access: BucketSecurity.BucketContentAccess): Promise { - await runIngestOperation(access.studioId, IngestJobs.BucketEmpty, { - bucketId: access.bucket._id, + export async function emptyBucket(bucketId: BucketId): Promise { + const bucket = await Buckets.findOneAsync(bucketId) + if (!bucket) throw new Meteor.Error(404, `Bucket "${bucketId}" not found`) + + await runIngestOperation(bucket.studioId, IngestJobs.BucketEmpty, { + bucketId: bucket._id, }) } - export async function createNewBucket(access: StudioContentAccess, name: string): Promise { - const { studio } = access + export async function createNewBucket(studioId: StudioId, name: string): Promise { + const studio = await fetchStudioLight(studioId) + if (!studio) throw new Meteor.Error(404, `Studio "${studioId}" not found`) const heaviestBucket = ( await Buckets.findFetchAsync( @@ -99,28 +118,20 @@ export namespace BucketsAPI { } export async function modifyBucketAdLibAction( - access: BucketSecurity.BucketAdlibActionContentAccess, + adLibActionId: BucketAdLibActionId, actionProps: Partial> ): Promise { - const oldAction = access.action + const oldAction = await BucketAdLibActions.findOneAsync(adLibActionId) + if (!oldAction) throw new Meteor.Error(404, `BucketAdLibAction "${adLibActionId}" not found`) - let moveIntoBucket: Bucket | undefined if (actionProps.bucketId && actionProps.bucketId !== oldAction.bucketId) { - moveIntoBucket = await Buckets.findOneAsync(actionProps.bucketId) - if (!moveIntoBucket) throw new Meteor.Error(`Could not find bucket: "${actionProps.bucketId}"`) + const moveIntoBucket = await Buckets.countDocuments(actionProps.bucketId) + if (moveIntoBucket === 0) throw new Meteor.Error(`Could not find bucket: "${actionProps.bucketId}"`) } - // Check we are allowed to move into the new bucket - if (Settings.enableUserAccounts && moveIntoBucket) { - // Shouldn't be moved across orgs - const newBucketStudio = await Studios.findOneAsync(moveIntoBucket.studioId, { - fields: { organizationId: 1 }, - }) - if (!newBucketStudio) throw new Meteor.Error(`Could not find studio: "${moveIntoBucket.studioId}"`) - - if (newBucketStudio.organizationId !== access.studio.organizationId) { - throw new Meteor.Error(403, 'Access denied') - } + if (actionProps.studioId && actionProps.studioId !== oldAction.studioId) { + const newStudioCount = await Studios.countDocuments(actionProps.studioId) + if (newStudioCount === 0) throw new Meteor.Error(`Could not find studio: "${actionProps.studioId}"`) } await runIngestOperation(oldAction.studioId, IngestJobs.BucketActionModify, { @@ -130,25 +141,30 @@ export namespace BucketsAPI { } export async function saveAdLibActionIntoBucket( - access: BucketSecurity.BucketContentAccess, + bucketId: BucketId, action: AdLibActionCommon | BucketAdLibAction ): Promise { + const targetBucket = (await Buckets.findOneAsync(bucketId, { projection: { _id: 1, studioId: 1 } })) as + | Pick + | undefined + if (!targetBucket) throw new Meteor.Error(404, `Bucket "${bucketId}" not found`) + let adLibAction: BucketAdLibAction if (isBucketAdLibAction(action)) { if (action.showStyleVariantId && !(await ShowStyleVariants.findOneAsync(action.showStyleVariantId))) { throw new Meteor.Error(`Could not find show style variant: "${action.showStyleVariantId}"`) } - if (access.studioId !== action.studioId) { + if (targetBucket.studioId !== action.studioId) { throw new Meteor.Error( - `studioId is different than Action's studioId: "${access.studioId}" - "${action.studioId}"` + `studioId is different than Action's studioId: "${targetBucket.studioId}" - "${action.studioId}"` ) } adLibAction = { ...action, _id: getRandomId(), - bucketId: access.bucket._id, + bucketId: targetBucket._id, } } else { const rundown = await Rundowns.findOneAsync(action.rundownId) @@ -156,9 +172,9 @@ export namespace BucketsAPI { throw new Meteor.Error(`Could not find rundown: "${action.rundownId}"`) } - if (access.studioId !== rundown.studioId) { + if (targetBucket.studioId !== rundown.studioId) { throw new Meteor.Error( - `studioId is different than Rundown's studioId: "${access.studioId}" - "${rundown.studioId}"` + `studioId is different than Rundown's studioId: "${targetBucket.studioId}" - "${rundown.studioId}"` ) } @@ -166,8 +182,8 @@ export namespace BucketsAPI { ...(_.omit(action, ['partId', 'rundownId']) as Omit), _id: getRandomId(), externalId: getRandomString(), // This needs to be something unique, so that the regenerate logic doesn't get it mixed up with something else - bucketId: access.bucket._id, - studioId: access.studioId, + bucketId: targetBucket._id, + studioId: targetBucket.studioId, showStyleBaseId: rundown.showStyleBaseId, showStyleVariantId: action.allVariants ? null : rundown.showStyleVariantId, importVersions: rundown.importVersions, @@ -178,7 +194,7 @@ export namespace BucketsAPI { // We can insert it here, as it is a creation with a new id, so the only race risk we have is the bucket being deleted await BucketAdLibActions.insertAsync(adLibAction) - await runIngestOperation(access.studioId, IngestJobs.BucketActionRegenerateExpectedPackages, { + await runIngestOperation(targetBucket.studioId, IngestJobs.BucketActionRegenerateExpectedPackages, { actionId: adLibAction._id, }) @@ -186,28 +202,20 @@ export namespace BucketsAPI { } export async function modifyBucketAdLib( - access: BucketSecurity.BucketAdlibPieceContentAccess, + adLibId: BucketAdLibId, adlibProps: Partial> ): Promise { - const oldAdLib = access.adlib + const oldAdLib = await BucketAdLibs.findOneAsync(adLibId) + if (!oldAdLib) throw new Meteor.Error(404, `BucketAdLib "${adLibId}" not found`) - let moveIntoBucket: Bucket | undefined if (adlibProps.bucketId && adlibProps.bucketId !== oldAdLib.bucketId) { - moveIntoBucket = await Buckets.findOneAsync(adlibProps.bucketId) - if (!moveIntoBucket) throw new Meteor.Error(`Could not find bucket: "${adlibProps.bucketId}"`) + const moveIntoBucket = await Buckets.countDocuments(adlibProps.bucketId) + if (moveIntoBucket === 0) throw new Meteor.Error(`Could not find bucket: "${adlibProps.bucketId}"`) } - // Check we are allowed to move into the new bucket - if (Settings.enableUserAccounts && moveIntoBucket) { - // Shouldn't be moved across orgs - const newBucketStudio = await Studios.findOneAsync(moveIntoBucket.studioId, { - fields: { organizationId: 1 }, - }) - if (!newBucketStudio) throw new Meteor.Error(`Could not find studio: "${moveIntoBucket.studioId}"`) - - if (newBucketStudio.organizationId !== access.studio.organizationId) { - throw new Meteor.Error(403, 'Access denied') - } + if (adlibProps.studioId && adlibProps.studioId !== oldAdLib.studioId) { + const newStudioCount = await Studios.countDocuments(adlibProps.studioId) + if (newStudioCount === 0) throw new Meteor.Error(`Could not find studio: "${adlibProps.studioId}"`) } await runIngestOperation(oldAdLib.studioId, IngestJobs.BucketPieceModify, { @@ -216,8 +224,10 @@ export namespace BucketsAPI { }) } - export async function removeBucket(access: BucketSecurity.BucketContentAccess): Promise { - const bucket = access.bucket + export async function removeBucket(bucketId: BucketId): Promise { + const bucket = await Buckets.findOneAsync(bucketId) + if (!bucket) throw new Meteor.Error(404, `Bucket "${bucketId}" not found`) + await Promise.all([ Buckets.removeAsync(bucket._id), await runIngestOperation(bucket.studioId, IngestJobs.BucketEmpty, { @@ -227,13 +237,17 @@ export namespace BucketsAPI { } export async function importAdlibToBucket( - access: BucketSecurity.BucketContentAccess, + bucketId: BucketId, showStyleBaseId: ShowStyleBaseId, /** Optional: if set, only create adlib for this variant (otherwise: for all variants in ShowStyleBase)*/ showStyleVariantId: ShowStyleVariantId | undefined, ingestItem: IngestAdlib ): Promise { - const studioLight = access.studio + const bucket = await Buckets.findOneAsync(bucketId) + if (!bucket) throw new Meteor.Error(404, `Bucket "${bucketId}" not found`) + + const studioLight = await fetchStudioLight(bucket.studioId) + if (!studioLight) throw new Meteor.Error(404, `Studio "${bucket.studioId}" not found`) if (showStyleVariantId) { const showStyleCompound = await getShowStyleCompound(showStyleVariantId) @@ -249,12 +263,12 @@ export namespace BucketsAPI { if (studioLight.supportedShowStyleBase.indexOf(showStyleBaseId) === -1) { throw new Meteor.Error( 500, - `ShowStyle base "${showStyleBaseId}" not supported by studio "${access.studioId}"` + `ShowStyle base "${showStyleBaseId}" not supported by studio "${bucket.studioId}"` ) } - await runIngestOperation(access.studioId, IngestJobs.BucketItemImport, { - bucketId: access.bucket._id, + await runIngestOperation(bucket.studioId, IngestJobs.BucketItemImport, { + bucketId: bucket._id, showStyleBaseId: showStyleBaseId, showStyleVariantIds: showStyleVariantId ? [showStyleVariantId] : undefined, payload: ingestItem, diff --git a/meteor/server/api/cleanup.ts b/meteor/server/api/cleanup.ts index 2f733c9b8e..5324208bcf 100644 --- a/meteor/server/api/cleanup.ts +++ b/meteor/server/api/cleanup.ts @@ -127,10 +127,6 @@ export async function cleanupOldDataInner(actuallyCleanup = false): Promise { + async (userActionMetadata) => { checkArgs() - const access = await checkAccessToPlaylist(context, playlistId) - return runStudioJob(access.playlist.studioId, jobName, jobArguments, userActionMetadata) + const playlist = await checkAccessToPlaylist(context.connection, playlistId) + return runStudioJob(playlist.studioId, jobName, jobArguments, userActionMetadata) } ) } @@ -92,11 +87,11 @@ export namespace ServerClientAPI { eventTime, `worker.${jobName}`, jobArguments as any, - async (_credentials, userActionMetadata) => { + async (userActionMetadata) => { checkArgs() - const access = await checkAccessToRundown(context, rundownId) - return runStudioJob(access.rundown.studioId, jobName, jobArguments, userActionMetadata) + const rundown = await checkAccessToRundown(context.connection, rundownId) + return runStudioJob(rundown.studioId, jobName, jobArguments, userActionMetadata) } ) } @@ -112,13 +107,13 @@ export namespace ServerClientAPI { checkArgs: () => void, methodName: string, args: Record, - fcn: (access: VerifiedRundownPlaylistContentAccess) => Promise + fcn: (playlist: VerifiedRundownPlaylistForUserAction) => Promise ): Promise> { return runUserActionInLog(context, userEvent, eventTime, methodName, args, async () => { checkArgs() - const access = await checkAccessToPlaylist(context, playlistId) - return fcn(access) + const playlist = await checkAccessToPlaylist(context.connection, playlistId) + return fcn(playlist) }) } @@ -133,13 +128,13 @@ export namespace ServerClientAPI { checkArgs: () => void, methodName: string, args: Record, - fcn: (access: VerifiedRundownContentAccess) => Promise + fcn: (rundown: VerifiedRundownForUserAction) => Promise ): Promise> { return runUserActionInLog(context, userEvent, eventTime, methodName, args, async () => { checkArgs() - const access = await checkAccessToRundown(context, rundownId) - return fcn(access) + const rundown = await checkAccessToRundown(context.connection, rundownId) + return fcn(rundown) }) } @@ -185,11 +180,11 @@ export namespace ServerClientAPI { eventTime: Time, methodName: string, methodArgs: Record, - fcn: (credentials: BasicAccessContext, userActionMetadata: UserActionMetadata) => Promise + fcn: (userActionMetadata: UserActionMetadata) => Promise ): Promise> { // If we are in the test write auth check mode, then bypass all special logic to ensure errors dont get mangled if (isInTestWrite()) { - const result = await fcn({ organizationId: null, userId: null }, {}) + const result = await fcn({}) return ClientAPI.responseSuccess(result) } @@ -203,23 +198,21 @@ export namespace ServerClientAPI { // Called internally from server-side. // Just run and return right away: try { - const result = await fcn({ organizationId: null, userId: null }, {}) + const result = await fcn({}) return ClientAPI.responseSuccess(result) } catch (e) { return rewrapError(methodName, e) } } else { - const credentials = await getLoggedInCredentials(context) - // Start the db entry, but don't wait for it const actionId: UserActionsLogItemId = getRandomId() const pInitialInsert = UserActionsLog.insertAsync( literal({ _id: actionId, clientAddress: context.connection.clientAddress, - organizationId: credentials.organizationId, - userId: credentials.userId, + organizationId: null, + userId: null, context: userEvent, method: methodName, args: JSON.stringify(methodArgs), @@ -233,7 +226,7 @@ export namespace ServerClientAPI { const userActionMetadata: UserActionMetadata = {} try { - const result = await fcn(credentials, userActionMetadata) + const result = await fcn(userActionMetadata) const completeTime = Date.now() pInitialInsert @@ -325,14 +318,15 @@ export namespace ServerClientAPI { }) } - const access = await PeripheralDeviceContentWriteAccess.executeFunction(methodContext, deviceId) + // TODO - check this. This probably needs to be moved out of this method, with the client using more targetted methods + assertConnectionHasOneOfPermissions(methodContext.connection, 'studio', 'configure', 'service') await UserActionsLog.insertAsync( literal({ _id: actionId, clientAddress: methodContext.connection ? methodContext.connection.clientAddress : '', - organizationId: access.organizationId, - userId: access.userId, + organizationId: null, + userId: null, context: context, method: `${deviceId}: ${method}`, args: JSON.stringify(args), @@ -395,7 +389,8 @@ export namespace ServerClientAPI { }) } - await PeripheralDeviceContentWriteAccess.executeFunction(methodContext, deviceId) + // TODO - check this. This probably needs to be moved out of this method, with the client using more targetted methods + assertConnectionHasOneOfPermissions(methodContext.connection, 'studio', 'configure', 'service') return executePeripheralDeviceFunctionWithCustomTimeout(deviceId, timeoutTime, { functionName, @@ -407,17 +402,6 @@ export namespace ServerClientAPI { return Promise.reject(err) }) } - - async function getLoggedInCredentials(methodContext: MethodContext): Promise { - let userId: UserId | null = null - let organizationId: OrganizationId | null = null - if (Settings.enableUserAccounts) { - const cred = await resolveCredentials({ userId: methodContext.userId }) - if (cred.user) userId = cred.user._id - organizationId = cred.organizationId - } - return { userId, organizationId } - } } class ServerClientAPIClass extends MethodContextAPI implements NewClientAPI { diff --git a/meteor/server/api/deviceTriggers/observer.ts b/meteor/server/api/deviceTriggers/observer.ts index aa6bc4bc86..c155bcb600 100644 --- a/meteor/server/api/deviceTriggers/observer.ts +++ b/meteor/server/api/deviceTriggers/observer.ts @@ -10,7 +10,7 @@ import { PreviewWrappedAdLib, } from '@sofie-automation/meteor-lib/dist/api/MountedTriggers' import { logger } from '../../logging' -import { checkAccessAndGetPeripheralDevice } from '../ingest/lib' +import { checkAccessAndGetPeripheralDevice } from '../../security/check' import { StudioActionManagers } from './StudioActionManagers' import { JobQueueWithClasses } from '@sofie-automation/shared-lib/dist/lib/JobQueueWithClasses' import { StudioDeviceTriggerManager } from './StudioDeviceTriggerManager' diff --git a/meteor/server/api/evaluations.ts b/meteor/server/api/evaluations.ts index 3034110a65..386acba33d 100644 --- a/meteor/server/api/evaluations.ts +++ b/meteor/server/api/evaluations.ts @@ -7,22 +7,19 @@ import { Meteor } from 'meteor/meteor' import * as _ from 'underscore' import { fetchStudioLight } from '../optimizations' import { sendSlackMessageToWebhook } from './integration/slack' -import { OrganizationId, UserId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { Evaluations, RundownPlaylists } from '../collections' +import { VerifiedRundownPlaylistForUserAction } from '../security/check' export async function saveEvaluation( - credentials: { - userId: UserId | null - organizationId: OrganizationId | null - }, + _playlist: VerifiedRundownPlaylistForUserAction, evaluation: EvaluationBase ): Promise { await Evaluations.insertAsync({ ...evaluation, _id: getRandomId(), - organizationId: credentials.organizationId, - userId: credentials.userId, + organizationId: null, + userId: null, timestamp: getCurrentTime(), }) logger.info({ diff --git a/meteor/server/api/heapSnapshot.ts b/meteor/server/api/heapSnapshot.ts index 1b9cb142a2..876b60be7a 100644 --- a/meteor/server/api/heapSnapshot.ts +++ b/meteor/server/api/heapSnapshot.ts @@ -7,14 +7,11 @@ import { fixValidPath } from '../lib/lib' import { sleep } from '../lib/lib' import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' import { logger } from '../logging' -import { Settings } from '../Settings' -import { Credentials } from '../security/lib/credentials' -import { SystemWriteAccess } from '../security/system' +import { assertConnectionHasOneOfPermissions, RequestCredentials } from '../security/auth' + +async function retrieveHeapSnapshot(cred: RequestCredentials): Promise { + assertConnectionHasOneOfPermissions(cred, 'developer') -async function retrieveHeapSnapshot(cred0: Credentials): Promise { - if (Settings.enableUserAccounts) { - await SystemWriteAccess.coreSystem(cred0) - } logger.warn('Taking heap snapshot, expect system to be unresponsive for a few seconds..') await sleep(100) // Allow the logger to catch up before continuing.. @@ -51,19 +48,9 @@ async function handleKoaResponse(ctx: Koa.ParameterizedContext, snapshotFcn: () } } -// For backwards compatibility: -if (!Settings.enableUserAccounts) { - // Retrieve heap snapshot: - heapSnapshotPrivateApiRouter.get('/retrieve', async (ctx) => { - return handleKoaResponse(ctx, async () => { - return retrieveHeapSnapshot({ userId: null }) - }) - }) -} - // Retrieve heap snapshot: -heapSnapshotPrivateApiRouter.get('/:token/retrieve', async (ctx) => { +heapSnapshotPrivateApiRouter.get('/retrieve', async (ctx) => { return handleKoaResponse(ctx, async () => { - return retrieveHeapSnapshot({ userId: null, token: ctx.params.token }) + return retrieveHeapSnapshot(ctx) }) }) diff --git a/meteor/server/api/ingest/actions.ts b/meteor/server/api/ingest/actions.ts index dc46d4a3fa..6a6cf852bf 100644 --- a/meteor/server/api/ingest/actions.ts +++ b/meteor/server/api/ingest/actions.ts @@ -1,12 +1,12 @@ import { getPeripheralDeviceFromRundown, runIngestOperation } from './lib' import { MOSDeviceActions } from './mosDevice/actions' import { Meteor } from 'meteor/meteor' -import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { TriggerReloadDataResponse } from '@sofie-automation/meteor-lib/dist/api/userActions' import { GenericDeviceActions } from './genericDevice/actions' import { PeripheralDeviceType } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' import { IngestJobs } from '@sofie-automation/corelib/dist/worker/ingest' import { assertNever } from '@sofie-automation/corelib/dist/lib' +import { VerifiedRundownForUserAction } from '../../security/check' /* This file contains actions that can be performed on an ingest-device @@ -15,9 +15,7 @@ export namespace IngestActions { /** * Trigger a reload of a rundown */ - export async function reloadRundown( - rundown: Pick - ): Promise { + export async function reloadRundown(rundown: VerifiedRundownForUserAction): Promise { const rundownSourceType = rundown.source.type switch (rundown.source.type) { case 'snapshot': diff --git a/meteor/server/api/ingest/lib.ts b/meteor/server/api/ingest/lib.ts index 15cd36b428..f3e761b887 100644 --- a/meteor/server/api/ingest/lib.ts +++ b/meteor/server/api/ingest/lib.ts @@ -5,9 +5,6 @@ import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyE import { PeripheralDevice, PeripheralDeviceCategory } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' import { Rundown, RundownSourceNrcs } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { logger } from '../../logging' -import { PeripheralDeviceContentWriteAccess } from '../../security/peripheralDevice' -import { MethodContext } from '../methodContext' -import { Credentials } from '../../security/lib/credentials' import { profiler } from '../profiler' import { IngestJobFunc } from '@sofie-automation/corelib/dist/worker/ingest' import { QueueIngestJob } from '../../worker/worker' @@ -64,26 +61,6 @@ export async function runIngestOperation( } } -/** Check Access and return PeripheralDevice, throws otherwise */ -export async function checkAccessAndGetPeripheralDevice( - deviceId: PeripheralDeviceId, - token: string | undefined, - context: Credentials | MethodContext -): Promise { - const span = profiler.startSpan('lib.checkAccessAndGetPeripheralDevice') - - const { device: peripheralDevice } = await PeripheralDeviceContentWriteAccess.peripheralDevice( - { userId: context.userId, token }, - deviceId - ) - if (!peripheralDevice) { - throw new Meteor.Error(404, `PeripheralDevice "${deviceId}" not found`) - } - - span?.end() - return peripheralDevice -} - export function getRundownId(studioId: StudioId, rundownExternalId: string): RundownId { if (!studioId) throw new Meteor.Error(500, 'getRundownId: studio not set!') if (!rundownExternalId) throw new Meteor.Error(401, 'getRundownId: rundownExternalId must be set!') diff --git a/meteor/server/api/ingest/mosDevice/mosIntegration.ts b/meteor/server/api/ingest/mosDevice/mosIntegration.ts index ad226d6d84..6969159e89 100644 --- a/meteor/server/api/ingest/mosDevice/mosIntegration.ts +++ b/meteor/server/api/ingest/mosDevice/mosIntegration.ts @@ -1,16 +1,12 @@ import { MOS } from '@sofie-automation/corelib' import { logger } from '../../../logging' -import { - checkAccessAndGetPeripheralDevice, - fetchStudioIdFromDevice, - generateRundownSource, - runIngestOperation, -} from '../lib' +import { fetchStudioIdFromDevice, generateRundownSource, runIngestOperation } from '../lib' import { parseMosString } from './lib' import { MethodContext } from '../../methodContext' import { profiler } from '../../profiler' import { IngestJobs } from '@sofie-automation/corelib/dist/worker/ingest' import { PeripheralDeviceId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { checkAccessAndGetPeripheralDevice } from '../../../security/check' const apmNamespace = 'mosIntegration' diff --git a/meteor/server/api/ingest/rundownInput.ts b/meteor/server/api/ingest/rundownInput.ts index 8b05552eba..4013b465ef 100644 --- a/meteor/server/api/ingest/rundownInput.ts +++ b/meteor/server/api/ingest/rundownInput.ts @@ -7,18 +7,14 @@ import { lazyIgnore } from '../../lib/lib' import { IngestRundown, IngestSegment, IngestPart, IngestPlaylist } from '@sofie-automation/blueprints-integration' import { logger } from '../../logging' import { RundownIngestDataCache } from './ingestCache' -import { - checkAccessAndGetPeripheralDevice, - fetchStudioIdFromDevice, - generateRundownSource, - runIngestOperation, -} from './lib' +import { fetchStudioIdFromDevice, generateRundownSource, runIngestOperation } from './lib' import { MethodContext } from '../methodContext' import { IngestJobs } from '@sofie-automation/corelib/dist/worker/ingest' import { MediaObject } from '@sofie-automation/shared-lib/dist/core/model/MediaObjects' import { PeripheralDeviceId, RundownId, SegmentId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { NrcsIngestCacheType } from '@sofie-automation/corelib/dist/dataModel/NrcsIngestDataCache' import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' +import { checkAccessAndGetPeripheralDevice } from '../../security/check' export namespace RundownInput { export async function dataPlaylistGet( diff --git a/meteor/server/api/integration/expectedPackages.ts b/meteor/server/api/integration/expectedPackages.ts index 1c3d489b9a..8e823b968f 100644 --- a/meteor/server/api/integration/expectedPackages.ts +++ b/meteor/server/api/integration/expectedPackages.ts @@ -1,7 +1,7 @@ import { check } from '../../lib/check' import { Meteor } from 'meteor/meteor' import { MethodContext } from '../methodContext' -import { checkAccessAndGetPeripheralDevice } from '../ingest/lib' +import { checkAccessAndGetPeripheralDevice } from '../../security/check' import { ExpectedPackageStatusAPI, PackageInfo } from '@sofie-automation/blueprints-integration' import { ExpectedPackageWorkStatus } from '@sofie-automation/corelib/dist/dataModel/ExpectedPackageWorkStatuses' import { assertNever, literal, protectString } from '../../lib/tempLib' diff --git a/meteor/server/api/integration/media-scanner.ts b/meteor/server/api/integration/media-scanner.ts index 4af257f01a..cda12b162e 100644 --- a/meteor/server/api/integration/media-scanner.ts +++ b/meteor/server/api/integration/media-scanner.ts @@ -1,6 +1,6 @@ import { Meteor } from 'meteor/meteor' import { protectString } from '../../lib/tempLib' -import { checkAccessAndGetPeripheralDevice } from '../ingest/lib' +import { checkAccessAndGetPeripheralDevice } from '../../security/check' import { MethodContext } from '../methodContext' import { MediaObject } from '@sofie-automation/shared-lib/dist/core/model/MediaObjects' import { MediaObjId, PeripheralDeviceId } from '@sofie-automation/corelib/dist/dataModel/Ids' diff --git a/meteor/server/api/integration/mediaWorkFlows.ts b/meteor/server/api/integration/mediaWorkFlows.ts index 20c1a65bf6..36fb3e2461 100644 --- a/meteor/server/api/integration/mediaWorkFlows.ts +++ b/meteor/server/api/integration/mediaWorkFlows.ts @@ -9,7 +9,7 @@ import { } from '@sofie-automation/shared-lib/dist/peripheralDevice/mediaManager' import { PeripheralDeviceType } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' import { MethodContext } from '../methodContext' -import { checkAccessAndGetPeripheralDevice } from '../ingest/lib' +import { checkAccessAndGetPeripheralDevice } from '../../security/check' import { MediaWorkFlowId, MediaWorkFlowStepId, PeripheralDeviceId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { MediaWorkFlows, MediaWorkFlowSteps } from '../../collections' diff --git a/meteor/server/api/lib.ts b/meteor/server/api/lib.ts deleted file mode 100644 index e0d4ed7dea..0000000000 --- a/meteor/server/api/lib.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { RundownId, RundownPlaylistId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { Meteor } from 'meteor/meteor' -import { MethodContext } from './methodContext' -import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' -import { - RundownContentAccess, - RundownPlaylistContentAccess, - RundownPlaylistContentWriteAccess, -} from '../security/rundownPlaylist' - -/** - * This is returned from a check of access to a playlist, when access is granted. - * Fields will be populated about the user. - * It is identical to RundownPlaylistContentAccess, except for confirming access is allowed - */ -export interface VerifiedRundownPlaylistContentAccess extends RundownPlaylistContentAccess { - playlist: DBRundownPlaylist - studioId: StudioId -} -/** - * This is returned from a check of access to a rundown, when access is granted. - * Fields will be populated about the user. - * It is identical to RundownContentAccess, except for confirming access is allowed - */ -export interface VerifiedRundownContentAccess extends RundownContentAccess { - rundown: Rundown - studioId: StudioId -} - -/** - * Check that the current user has write access to the specified playlist, and ensure that the playlist exists - * @param context - * @param playlistId Id of the playlist - */ -export async function checkAccessToPlaylist( - context: MethodContext, - playlistId: RundownPlaylistId -): Promise { - const access = await RundownPlaylistContentWriteAccess.playout(context, playlistId) - const playlist = access.playlist - if (!playlist) throw new Meteor.Error(404, `Rundown Playlist "${playlistId}" not found!`) - return { - ...access, - playlist, - studioId: playlist.studioId, - } -} - -/** - * Check that the current user has write access to the specified rundown, and ensure that the rundown exists - * @param context - * @param rundownId Id of the rundown - */ -export async function checkAccessToRundown( - context: MethodContext, - rundownId: RundownId -): Promise { - const access = await RundownPlaylistContentWriteAccess.rundown(context, rundownId) - const rundown = access.rundown - if (!rundown) throw new Meteor.Error(404, `Rundown "${rundownId}" not found!`) - return { - ...access, - rundown, - studioId: rundown.studioId, - } -} diff --git a/meteor/server/api/mediaManager.ts b/meteor/server/api/mediaManager.ts index 3370b85944..ea9caa8113 100644 --- a/meteor/server/api/mediaManager.ts +++ b/meteor/server/api/mediaManager.ts @@ -1,74 +1,77 @@ import { MediaWorkFlow } from '@sofie-automation/shared-lib/dist/core/model/MediaWorkFlows' import { PeripheralDevice } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' -import { MediaWorkFlowContentAccess } from '../security/peripheralDevice' -import { BasicAccessContext } from '../security/organization' import { MediaWorkFlows, PeripheralDevices } from '../collections' import { executePeripheralDeviceFunction } from './peripheralDevice/executeFunction' +import { MediaWorkFlowId, OrganizationId, PeripheralDeviceId } from '@sofie-automation/corelib/dist/dataModel/Ids' -export namespace MediaManagerAPI { - export async function restartAllWorkflows(access: BasicAccessContext): Promise { - const devices: Array> = await PeripheralDevices.findFetchAsync( - access.organizationId ? { organizationId: access.organizationId } : {}, - { - fields: { - _id: 1, - }, - } - ) - const workflows: Array> = await MediaWorkFlows.findFetchAsync( - { - deviceId: { $in: devices.map((d) => d._id) }, +export async function restartAllWorkflows(organizationId: OrganizationId | null): Promise { + const devices: Array> = await PeripheralDevices.findFetchAsync( + organizationId ? { organizationId: organizationId } : {}, + { + fields: { + _id: 1, }, - { - fields: { - deviceId: 1, - }, - } - ) + } + ) + const workflows: Array> = await MediaWorkFlows.findFetchAsync( + { + deviceId: { $in: devices.map((d) => d._id) }, + }, + { + fields: { + deviceId: 1, + }, + } + ) - const deviceIds = Array.from(new Set(workflows.map((w) => w.deviceId))) + const deviceIds = Array.from(new Set(workflows.map((w) => w.deviceId))) - await Promise.all( - deviceIds.map(async (deviceId) => executePeripheralDeviceFunction(deviceId, 'restartAllWorkflows')) - ) - } - export async function abortAllWorkflows(access: BasicAccessContext): Promise { - const devices: Array> = await PeripheralDevices.findFetchAsync( - access.organizationId ? { organizationId: access.organizationId } : {}, - { - fields: { - _id: 1, - }, - } - ) - const workflows: Array> = await MediaWorkFlows.findFetchAsync( - { - deviceId: { $in: devices.map((d) => d._id) }, + await Promise.all( + deviceIds.map(async (deviceId) => executePeripheralDeviceFunction(deviceId, 'restartAllWorkflows')) + ) +} +export async function abortAllWorkflows(organizationId: OrganizationId | null): Promise { + const devices: Array> = await PeripheralDevices.findFetchAsync( + organizationId ? { organizationId: organizationId } : {}, + { + fields: { + _id: 1, + }, + } + ) + const workflows: Array> = await MediaWorkFlows.findFetchAsync( + { + deviceId: { $in: devices.map((d) => d._id) }, + }, + { + fields: { + deviceId: 1, }, - { - fields: { - deviceId: 1, - }, - } - ) + } + ) + + const deviceIds = Array.from(new Set(workflows.map((w) => w.deviceId))) + + await Promise.all(deviceIds.map(async (deviceId) => executePeripheralDeviceFunction(deviceId, 'abortAllWorkflows'))) +} - const deviceIds = Array.from(new Set(workflows.map((w) => w.deviceId))) +export async function restartWorkflow(deviceId: PeripheralDeviceId, workflowId: MediaWorkFlowId): Promise { + await ensureWorkflowExists(workflowId) - await Promise.all( - deviceIds.map(async (deviceId) => executePeripheralDeviceFunction(deviceId, 'abortAllWorkflows')) - ) - } + await executePeripheralDeviceFunction(deviceId, 'restartWorkflow', workflowId) +} +export async function abortWorkflow(deviceId: PeripheralDeviceId, workflowId: MediaWorkFlowId): Promise { + await ensureWorkflowExists(workflowId) + + await executePeripheralDeviceFunction(deviceId, 'abortWorkflow', workflowId) +} +export async function prioritizeWorkflow(deviceId: PeripheralDeviceId, workflowId: MediaWorkFlowId): Promise { + await ensureWorkflowExists(workflowId) + + await executePeripheralDeviceFunction(deviceId, 'prioritizeWorkflow', workflowId) +} - export async function restartWorkflow(access: MediaWorkFlowContentAccess): Promise { - const workflow = access.mediaWorkFlow - await executePeripheralDeviceFunction(workflow.deviceId, 'restartWorkflow', workflow._id) - } - export async function abortWorkflow(access: MediaWorkFlowContentAccess): Promise { - const workflow = access.mediaWorkFlow - await executePeripheralDeviceFunction(workflow.deviceId, 'abortWorkflow', workflow._id) - } - export async function prioritizeWorkflow(access: MediaWorkFlowContentAccess): Promise { - const workflow = access.mediaWorkFlow - await executePeripheralDeviceFunction(workflow.deviceId, 'prioritizeWorkflow', workflow._id) - } +async function ensureWorkflowExists(workflowId: MediaWorkFlowId): Promise { + const doc = await MediaWorkFlows.findOneAsync(workflowId, { projection: { _id: 1 } }) + if (!doc) throw new Error(`Workflow "${workflowId}" not found`) } diff --git a/meteor/server/api/methodContext.ts b/meteor/server/api/methodContext.ts index cd8e962aae..4c564f75c4 100644 --- a/meteor/server/api/methodContext.ts +++ b/meteor/server/api/methodContext.ts @@ -1,21 +1,10 @@ import { Meteor } from 'meteor/meteor' -import { UserId } from '@sofie-automation/corelib/dist/dataModel/Ids' -export interface MethodContext extends Omit { - userId: UserId | null -} +export type MethodContext = Omit /** Abstarct class to be used when defining Mehod-classes */ export abstract class MethodContextAPI implements MethodContext { // These properties are added by Meteor to the `this` context when calling methods - public userId!: UserId | null - public isSimulation!: boolean - public setUserId(_userId: string | null): void { - throw new Meteor.Error( - 500, - `This shoulc never be called, there's something wrong in with 'this' in the calling method` - ) - } public unblock(): void { throw new Meteor.Error( 500, diff --git a/meteor/server/api/organizations.ts b/meteor/server/api/organizations.ts index df8e0cedfd..e56b615163 100644 --- a/meteor/server/api/organizations.ts +++ b/meteor/server/api/organizations.ts @@ -4,14 +4,15 @@ import { MethodContextAPI, MethodContext } from './methodContext' import { NewOrganizationAPI, OrganizationAPIMethods } from '@sofie-automation/meteor-lib/dist/api/organization' import { registerClassToMeteorMethods } from '../methods' import { DBOrganization, DBOrganizationBase } from '@sofie-automation/meteor-lib/dist/collections/Organization' -import { OrganizationContentWriteAccess } from '../security/organization' -import { triggerWriteAccessBecauseNoCheckNecessary } from '../security/lib/securityVerify' import { insertStudioInner } from './studio/api' import { insertShowStyleBaseInner } from './showStyles' -import { resetCredentials } from '../security/lib/credentials' import { BlueprintId, OrganizationId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { Blueprints, CoreSystem, Organizations, ShowStyleBases, Studios, Users } from '../collections' +import { Blueprints, CoreSystem, Organizations, ShowStyleBases, Studios } from '../collections' import { getCoreSystemAsync } from '../coreSystem/collection' +import { UserPermissions } from '@sofie-automation/meteor-lib/dist/userPermissions' +import { assertConnectionHasOneOfPermissions } from '../security/auth' + +const PERMISSIONS_FOR_MANAGE_ORGANIZATIONS: Array = ['configure'] async function createDefaultEnvironmentForOrg(orgId: OrganizationId) { let systemBlueprintId: BlueprintId | undefined @@ -43,8 +44,11 @@ async function createDefaultEnvironmentForOrg(orgId: OrganizationId) { await ShowStyleBases.updateAsync(showStyleId, { $set: { blueprintId: showStyleBlueprintId } }) } -export async function createOrganization(organization: DBOrganizationBase): Promise { - triggerWriteAccessBecauseNoCheckNecessary() +export async function createOrganization( + context: MethodContext, + organization: DBOrganizationBase +): Promise { + assertConnectionHasOneOfPermissions(context.connection, ...PERMISSIONS_FOR_MANAGE_ORGANIZATIONS) const orgId = await Organizations.insertAsync( literal({ @@ -61,12 +65,8 @@ export async function createOrganization(organization: DBOrganizationBase): Prom } async function removeOrganization(context: MethodContext, organizationId: OrganizationId) { - await OrganizationContentWriteAccess.organization(context, organizationId) + assertConnectionHasOneOfPermissions(context.connection, ...PERMISSIONS_FOR_MANAGE_ORGANIZATIONS) - const users = await Users.findFetchAsync({ organizationId }) - users.forEach((user) => { - resetCredentials({ userId: user._id }) - }) await Organizations.removeAsync(organizationId) } diff --git a/meteor/server/api/packageManager.ts b/meteor/server/api/packageManager.ts index 0988826393..c446852f25 100644 --- a/meteor/server/api/packageManager.ts +++ b/meteor/server/api/packageManager.ts @@ -3,43 +3,31 @@ import { PeripheralDeviceType, PERIPHERAL_SUBTYPE_PROCESS, } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' -import { PeripheralDeviceContentWriteAccess } from '../security/peripheralDevice' -import { StudioContentAccess } from '../security/studio' import { PeripheralDevices } from '../collections' import { executePeripheralDeviceFunction } from './peripheralDevice/executeFunction' +import { PeripheralDeviceId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' -export namespace PackageManagerAPI { - export async function restartExpectation( - access: PeripheralDeviceContentWriteAccess.ContentAccess, - workId: string - ): Promise { - await executePeripheralDeviceFunction(access.deviceId, 'restartExpectation', workId) - } - export async function abortExpectation( - access: PeripheralDeviceContentWriteAccess.ContentAccess, - workId: string - ): Promise { - await executePeripheralDeviceFunction(access.deviceId, 'abortExpectation', workId) - } +export async function restartExpectation(deviceId: PeripheralDeviceId, workId: string): Promise { + await executePeripheralDeviceFunction(deviceId, 'restartExpectation', workId) +} +export async function abortExpectation(deviceId: PeripheralDeviceId, workId: string): Promise { + await executePeripheralDeviceFunction(deviceId, 'abortExpectation', workId) +} - export async function restartAllExpectationsInStudio(access: StudioContentAccess): Promise { - const packageManagerDevices = await PeripheralDevices.findFetchAsync({ - studioId: access.studioId, - category: PeripheralDeviceCategory.PACKAGE_MANAGER, - type: PeripheralDeviceType.PACKAGE_MANAGER, - subType: PERIPHERAL_SUBTYPE_PROCESS, - }) +export async function restartAllExpectationsInStudio(studioId: StudioId): Promise { + const packageManagerDevices = await PeripheralDevices.findFetchAsync({ + studioId: studioId, + category: PeripheralDeviceCategory.PACKAGE_MANAGER, + type: PeripheralDeviceType.PACKAGE_MANAGER, + subType: PERIPHERAL_SUBTYPE_PROCESS, + }) - await Promise.all( - packageManagerDevices.map(async (packageManagerDevice) => { - return executePeripheralDeviceFunction(packageManagerDevice._id, 'restartAllExpectations') - }) - ) - } - export async function restartPackageContainer( - access: PeripheralDeviceContentWriteAccess.ContentAccess, - containerId: string - ): Promise { - await executePeripheralDeviceFunction(access.deviceId, 'restartPackageContainer', containerId) - } + await Promise.all( + packageManagerDevices.map(async (packageManagerDevice) => { + return executePeripheralDeviceFunction(packageManagerDevice._id, 'restartAllExpectations') + }) + ) +} +export async function restartPackageContainer(deviceId: PeripheralDeviceId, containerId: string): Promise { + await executePeripheralDeviceFunction(deviceId, 'restartPackageContainer', containerId) } diff --git a/meteor/server/api/peripheralDevice.ts b/meteor/server/api/peripheralDevice.ts index 0c6bed8857..90604fb61a 100644 --- a/meteor/server/api/peripheralDevice.ts +++ b/meteor/server/api/peripheralDevice.ts @@ -26,10 +26,9 @@ import { MediaWorkFlowStep } from '@sofie-automation/shared-lib/dist/core/model/ import { MOS } from '@sofie-automation/corelib' import { determineDiffTime } from './systemTime/systemTime' import { getTimeDiff } from './systemTime/api' -import { PeripheralDeviceContentWriteAccess } from '../security/peripheralDevice' import { MethodContextAPI, MethodContext } from './methodContext' -import { triggerWriteAccess, triggerWriteAccessBecauseNoCheckNecessary } from '../security/lib/securityVerify' -import { checkAccessAndGetPeripheralDevice } from './ingest/lib' +import { triggerWriteAccess, triggerWriteAccessBecauseNoCheckNecessary } from '../security/securityVerify' +import { checkAccessAndGetPeripheralDevice } from '../security/check' import { UserActionsLogItem } from '@sofie-automation/meteor-lib/dist/collections/UserActionsLog' import { PackageManagerIntegration } from './integration/expectedPackages' import { profiler } from './profiler' @@ -81,7 +80,9 @@ export namespace ServerPeripheralDeviceAPI { check(deviceId, String) const existingDevice = await PeripheralDevices.findOneAsync(deviceId) if (existingDevice) { - await PeripheralDeviceContentWriteAccess.peripheralDevice({ userId: context.userId, token }, deviceId) + await checkAccessAndGetPeripheralDevice(deviceId, token, context) + } else { + triggerWriteAccessBecauseNoCheckNecessary() } check(token, String) @@ -356,12 +357,12 @@ export namespace ServerPeripheralDeviceAPI { return false } export async function disableSubDevice( - access: PeripheralDeviceContentWriteAccess.ContentAccess, + deviceId: PeripheralDeviceId, subDeviceId: string, disable: boolean ): Promise { - const peripheralDevice = access.device - const deviceId = access.deviceId + const peripheralDevice = await PeripheralDevices.findOneAsync(deviceId) + if (!peripheralDevice) throw new Meteor.Error(404, `PeripheralDevice "${deviceId}" not found`) // check that the peripheralDevice has subDevices if (peripheralDevice.type !== PeripheralDeviceType.PLAYOUT) @@ -432,18 +433,21 @@ export namespace ServerPeripheralDeviceAPI { }) } } - export async function getDebugStates(access: PeripheralDeviceContentWriteAccess.ContentAccess): Promise { + export async function getDebugStates(peripheralDeviceId: PeripheralDeviceId): Promise { + const peripheralDevice = await PeripheralDevices.findOneAsync(peripheralDeviceId) + if (!peripheralDevice) return {} + if ( // Debug states are only valid for Playout devices and must be enabled with the `debugState` option - access.device.type !== PeripheralDeviceType.PLAYOUT || - !access.device.settings || - !(access.device.settings as any)['debugState'] + peripheralDevice.type !== PeripheralDeviceType.PLAYOUT || + !peripheralDevice.settings || + !(peripheralDevice.settings as any)['debugState'] ) { return {} } try { - return await executePeripheralDeviceFunction(access.deviceId, 'getDebugStates') + return await executePeripheralDeviceFunction(peripheralDevice._id, 'getDebugStates') } catch (e) { logger.error(e) return {} diff --git a/meteor/server/api/playout/api.ts b/meteor/server/api/playout/api.ts index d290abc6d7..8b18fed8aa 100644 --- a/meteor/server/api/playout/api.ts +++ b/meteor/server/api/playout/api.ts @@ -6,20 +6,29 @@ import { logger } from '../../logging' import { MethodContextAPI } from '../methodContext' import { QueueStudioJob } from '../../worker/worker' import { StudioJobs } from '@sofie-automation/corelib/dist/worker/studio' -import { StudioContentWriteAccess } from '../../security/studio' + import { StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { UserPermissions } from '@sofie-automation/meteor-lib/dist/userPermissions' +import { assertConnectionHasOneOfPermissions } from '../../security/auth' +import { Studios } from '../../collections' +import { Meteor } from 'meteor/meteor' + +const PERMISSIONS_FOR_STUDIO_BASELINE: Array = ['configure', 'studio'] class ServerPlayoutAPIClass extends MethodContextAPI implements NewPlayoutAPI { async updateStudioBaseline(studioId: StudioId): Promise { - await StudioContentWriteAccess.baseline(this, studioId) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_STUDIO_BASELINE) const res = await QueueStudioJob(StudioJobs.UpdateStudioBaseline, studioId, undefined) return res.complete } async shouldUpdateStudioBaseline(studioId: StudioId) { - const access = await StudioContentWriteAccess.baseline(this, studioId) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_STUDIO_BASELINE) + + const studio = await Studios.findOneAsync(studioId) + if (!studio) throw new Meteor.Error(404, `Studio "${studioId}" not found`) - return ServerPlayoutAPI.shouldUpdateStudioBaseline(access) + return ServerPlayoutAPI.shouldUpdateStudioBaseline(studio) } } registerClassToMeteorMethods(PlayoutAPIMethods, ServerPlayoutAPIClass, false) diff --git a/meteor/server/api/playout/playout.ts b/meteor/server/api/playout/playout.ts index 20fb5e40c3..a9d36df0a9 100644 --- a/meteor/server/api/playout/playout.ts +++ b/meteor/server/api/playout/playout.ts @@ -1,15 +1,14 @@ /* tslint:disable:no-use-before-declare */ import { PackageInfo } from '../../coreSystem' -import { StudioContentAccess } from '../../security/studio' import { shouldUpdateStudioBaselineInner } from '@sofie-automation/corelib/dist/studio/baseline' import { Blueprints, RundownPlaylists, Timeline } from '../../collections' import { StudioJobs } from '@sofie-automation/corelib/dist/worker/studio' import { QueueStudioJob } from '../../worker/worker' +import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio' +import { StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' export namespace ServerPlayoutAPI { - export async function shouldUpdateStudioBaseline(access: StudioContentAccess): Promise { - const { studio } = access - + export async function shouldUpdateStudioBaseline(studio: DBStudio): Promise { // This is intentionally not in a lock/queue, as doing so will cause it to block playout performance, and being wrong is harmless if (studio) { @@ -34,11 +33,11 @@ export namespace ServerPlayoutAPI { } export async function switchRouteSet( - access: StudioContentAccess, + studioId: StudioId, routeSetId: string, state: boolean | 'toggle' ): Promise { - const queuedJob = await QueueStudioJob(StudioJobs.SwitchRouteSet, access.studioId, { + const queuedJob = await QueueStudioJob(StudioJobs.SwitchRouteSet, studioId, { routeSetId, state, }) diff --git a/meteor/server/api/rest/v0/__tests__/rest.test.ts b/meteor/server/api/rest/v0/__tests__/rest.test.ts index 41d9e876c7..10d3ed7204 100644 --- a/meteor/server/api/rest/v0/__tests__/rest.test.ts +++ b/meteor/server/api/rest/v0/__tests__/rest.test.ts @@ -20,23 +20,6 @@ describe('REST API', () => { const legacyApiRouter = createLegacyApiRouter() - test('registers endpoints for all UserActionAPI methods', async () => { - for (const [methodName, methodValue] of Object.entries(UserActionAPIMethods)) { - const signature = MeteorMethodSignatures[methodValue] - - let resource = `/action/${methodName}` - for (const paramName of signature || []) { - resource += `/${paramName}` - } - - const ctx = await callKoaRoute(legacyApiRouter, { - method: 'POST', - url: resource, - }) - expect(ctx.response.status).not.toBe(404) - } - }) - test('calls the UserActionAPI methods, when doing a POST to the endpoint', async () => { for (const [methodName, methodValue] of Object.entries(UserActionAPIMethods)) { const signature = MeteorMethodSignatures[methodValue] diff --git a/meteor/server/api/rest/v1/buckets.ts b/meteor/server/api/rest/v1/buckets.ts index 5a4b764267..8086ea5159 100644 --- a/meteor/server/api/rest/v1/buckets.ts +++ b/meteor/server/api/rest/v1/buckets.ts @@ -8,13 +8,15 @@ import { ServerClientAPI } from '../../client' import { protectString } from '@sofie-automation/shared-lib/dist/lib/protectedString' import { getCurrentTime } from '../../../lib/lib' import { check } from 'meteor/check' -import { StudioContentWriteAccess } from '../../../security/studio' import { BucketsAPI } from '../../buckets' -import { BucketSecurity } from '../../../security/buckets' import { APIFactory, APIRegisterHook, ServerAPIContext } from './types' import { logger } from '../../../logging' import { UserError, UserErrorMessage } from '@sofie-automation/corelib/dist/error' import { IngestAdlib } from '@sofie-automation/blueprints-integration' +import { assertConnectionHasOneOfPermissions } from '../../../security/auth' +import { UserPermissions } from '@sofie-automation/meteor-lib/dist/userPermissions' + +const PERMISSIONS_FOR_BUCKET_MODIFICATION: Array = ['studio'] export class BucketsServerAPI implements BucketsRestAPI { constructor(private context: ServerAPIContext) {} @@ -57,11 +59,9 @@ export class BucketsServerAPI implements BucketsRestAPI { check(bucket.studioId, String) check(bucket.name, String) - const access = await StudioContentWriteAccess.bucket( - this.context.getCredentials(), - protectString(bucket.studioId) - ) - return BucketsAPI.createNewBucket(access, bucket.name) + assertConnectionHasOneOfPermissions(connection, ...PERMISSIONS_FOR_BUCKET_MODIFICATION) + + return BucketsAPI.createNewBucket(protectString(bucket.studioId), bucket.name) } ) if (ClientAPI.isClientResponseSuccess(createdBucketResponse)) { @@ -84,8 +84,9 @@ export class BucketsServerAPI implements BucketsRestAPI { async () => { check(bucketId, String) - const access = await BucketSecurity.allowWriteAccess(this.context.getCredentials(), bucketId) - return BucketsAPI.removeBucket(access) + assertConnectionHasOneOfPermissions(connection, ...PERMISSIONS_FOR_BUCKET_MODIFICATION) + + return BucketsAPI.removeBucket(bucketId) } ) } @@ -104,8 +105,9 @@ export class BucketsServerAPI implements BucketsRestAPI { async () => { check(bucketId, String) - const access = await BucketSecurity.allowWriteAccess(this.context.getCredentials(), bucketId) - return BucketsAPI.emptyBucket(access) + assertConnectionHasOneOfPermissions(connection, ...PERMISSIONS_FOR_BUCKET_MODIFICATION) + + return BucketsAPI.emptyBucket(bucketId) } ) } @@ -122,6 +124,8 @@ export class BucketsServerAPI implements BucketsRestAPI { 'bucketsRemoveBucketAdLib', { externalId }, async () => { + assertConnectionHasOneOfPermissions(connection, ...PERMISSIONS_FOR_BUCKET_MODIFICATION) + const bucketAdLibPiecePromise = BucketAdLibs.findOneAsync( { externalId }, { @@ -139,17 +143,9 @@ export class BucketsServerAPI implements BucketsRestAPI { bucketAdLibActionPromise, ]) if (bucketAdLibPiece) { - const access = await BucketSecurity.allowWriteAccessPiece( - this.context.getCredentials(), - bucketAdLibPiece._id - ) - return BucketsAPI.removeBucketAdLib(access) + return BucketsAPI.removeBucketAdLib(bucketAdLibPiece._id) } else if (bucketAdLibAction) { - const access = await BucketSecurity.allowWriteAccessAction( - this.context.getCredentials(), - bucketAdLibAction._id - ) - return BucketsAPI.removeBucketAdLibAction(access) + return BucketsAPI.removeBucketAdLibAction(bucketAdLibAction._id) } } ) @@ -173,8 +169,9 @@ export class BucketsServerAPI implements BucketsRestAPI { check(showStyleBaseId, String) check(ingestItem, Object) - const access = await BucketSecurity.allowWriteAccess(this.context.getCredentials(), bucketId) - return BucketsAPI.importAdlibToBucket(access, showStyleBaseId, undefined, ingestItem) + assertConnectionHasOneOfPermissions(connection, ...PERMISSIONS_FOR_BUCKET_MODIFICATION) + + return BucketsAPI.importAdlibToBucket(bucketId, showStyleBaseId, undefined, ingestItem) } ) } diff --git a/meteor/server/api/rest/v1/index.ts b/meteor/server/api/rest/v1/index.ts index c1fbb091b0..9132ea8100 100644 --- a/meteor/server/api/rest/v1/index.ts +++ b/meteor/server/api/rest/v1/index.ts @@ -8,8 +8,7 @@ import { ClientAPI } from '@sofie-automation/meteor-lib/dist/api/client' import { MethodContextAPI } from '../../methodContext' import { logger } from '../../../logging' import { CURRENT_SYSTEM_VERSION } from '../../../migration/currentSystemVersion' -import { Credentials } from '../../../security/lib/credentials' -import { triggerWriteAccess } from '../../../security/lib/securityVerify' +import { triggerWriteAccess } from '../../../security/securityVerify' import { makeMeteorConnectionFromKoa } from '../koa' import { registerRoutes as registerBlueprintsRoutes } from './blueprints' import { registerRoutes as registerDevicesRoutes } from './devices' @@ -34,21 +33,12 @@ function restAPIUserEvent( class APIContext implements ServerAPIContext { public getMethodContext(connection: Meteor.Connection): MethodContextAPI { return { - userId: null, connection, - isSimulation: false, - setUserId: () => { - /* no-op */ - }, unblock: () => { /* no-op */ }, } } - - public getCredentials(): Credentials { - return { userId: null } - } } export const koaRouter = new KoaRouter() diff --git a/meteor/server/api/rest/v1/playlists.ts b/meteor/server/api/rest/v1/playlists.ts index 93c621e224..a705241d1c 100644 --- a/meteor/server/api/rest/v1/playlists.ts +++ b/meteor/server/api/rest/v1/playlists.ts @@ -33,7 +33,7 @@ import { QueueNextSegmentResult, StudioJobs } from '@sofie-automation/corelib/di import { getCurrentTime } from '../../../lib/lib' import { TriggerReloadDataResponse } from '@sofie-automation/meteor-lib/dist/api/userActions' import { ServerRundownAPI } from '../../rundown' -import { triggerWriteAccess } from '../../../security/lib/securityVerify' +import { triggerWriteAccess } from '../../../security/securityVerify' class PlaylistsServerAPI implements PlaylistsRestAPI { constructor(private context: ServerAPIContext) {} diff --git a/meteor/server/api/rest/v1/studios.ts b/meteor/server/api/rest/v1/studios.ts index 798892966a..24f7568897 100644 --- a/meteor/server/api/rest/v1/studios.ts +++ b/meteor/server/api/rest/v1/studios.ts @@ -18,8 +18,11 @@ import { getCurrentTime } from '../../../lib/lib' import { StudioJobs } from '@sofie-automation/corelib/dist/worker/studio' import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio' import { PeripheralDevice } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' -import { StudioContentWriteAccess } from '../../../security/studio' import { ServerPlayoutAPI } from '../../playout/playout' +import { assertConnectionHasOneOfPermissions } from '../../../security/auth' +import { UserPermissions } from '@sofie-automation/meteor-lib/dist/userPermissions' + +const PERMISSIONS_FOR_PLAYOUT_USERACTION: Array = ['studio'] class StudiosServerAPI implements StudiosRestAPI { constructor(private context: ServerAPIContext) {} @@ -182,8 +185,9 @@ class StudiosServerAPI implements StudiosRestAPI { check(routeSetId, String) check(state, Boolean) - const access = await StudioContentWriteAccess.routeSet(this.context.getCredentials(), studioId) - return ServerPlayoutAPI.switchRouteSet(access, routeSetId, state) + assertConnectionHasOneOfPermissions(connection, ...PERMISSIONS_FOR_PLAYOUT_USERACTION) + + return ServerPlayoutAPI.switchRouteSet(studioId, routeSetId, state) } ) } diff --git a/meteor/server/api/rest/v1/types.ts b/meteor/server/api/rest/v1/types.ts index f7bb0e7a43..a5a3a6316d 100644 --- a/meteor/server/api/rest/v1/types.ts +++ b/meteor/server/api/rest/v1/types.ts @@ -1,7 +1,6 @@ import { UserErrorMessage } from '@sofie-automation/corelib/dist/error' import { Meteor } from 'meteor/meteor' import { ClientAPI } from '@sofie-automation/meteor-lib/dist/api/client' -import { Credentials } from '../../../security/lib/credentials' import { MethodContextAPI } from '../../methodContext' export type APIRegisterHook = ( @@ -24,5 +23,4 @@ export interface APIFactory { export interface ServerAPIContext { getMethodContext(connection: Meteor.Connection): MethodContextAPI - getCredentials(): Credentials } diff --git a/meteor/server/api/rundown.ts b/meteor/server/api/rundown.ts index 51b9ec5a9d..b3dfb75734 100644 --- a/meteor/server/api/rundown.ts +++ b/meteor/server/api/rundown.ts @@ -12,36 +12,36 @@ import { TriggerReloadDataResponse, } from '@sofie-automation/meteor-lib/dist/api/userActions' import { MethodContextAPI, MethodContext } from './methodContext' -import { StudioContentWriteAccess } from '../security/studio' import { runIngestOperation } from './ingest/lib' import { IngestJobs } from '@sofie-automation/corelib/dist/worker/ingest' -import { VerifiedRundownContentAccess, VerifiedRundownPlaylistContentAccess } from './lib' +import { VerifiedRundownForUserAction, VerifiedRundownPlaylistForUserAction } from '../security/check' import { Blueprint } from '@sofie-automation/corelib/dist/dataModel/Blueprint' import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio' import { RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { Blueprints, Rundowns, ShowStyleBases, ShowStyleVariants, Studios } from '../collections' +import { triggerWriteAccessBecauseNoCheckNecessary } from '../security/securityVerify' export namespace ServerRundownAPI { /** Remove an individual rundown */ - export async function removeRundown(access: VerifiedRundownContentAccess): Promise { - await runIngestOperation(access.rundown.studioId, IngestJobs.UserRemoveRundown, { - rundownId: access.rundown._id, + export async function removeRundown(rundown: VerifiedRundownForUserAction): Promise { + await runIngestOperation(rundown.studioId, IngestJobs.UserRemoveRundown, { + rundownId: rundown._id, force: true, }) } - export async function unsyncRundown(access: VerifiedRundownContentAccess): Promise { - await runIngestOperation(access.rundown.studioId, IngestJobs.UserUnsyncRundown, { - rundownId: access.rundown._id, + export async function unsyncRundown(rundown: VerifiedRundownForUserAction): Promise { + await runIngestOperation(rundown.studioId, IngestJobs.UserUnsyncRundown, { + rundownId: rundown._id, }) } /** Resync all rundowns in a rundownPlaylist */ export async function resyncRundownPlaylist( - access: VerifiedRundownPlaylistContentAccess + playlist: VerifiedRundownPlaylistForUserAction ): Promise { - logger.info('resyncRundownPlaylist ' + access.playlist._id) + logger.info('resyncRundownPlaylist ' + playlist._id) - const rundowns = await Rundowns.findFetchAsync({ playlistId: access.playlist._id }) + const rundowns = await Rundowns.findFetchAsync({ playlistId: playlist._id }) const responses = await Promise.all( rundowns.map(async (rundown) => { return { @@ -56,23 +56,22 @@ export namespace ServerRundownAPI { } } - export async function resyncRundown(access: VerifiedRundownContentAccess): Promise { - return IngestActions.reloadRundown(access.rundown) + export async function resyncRundown(rundown: VerifiedRundownForUserAction): Promise { + return IngestActions.reloadRundown(rundown) } } export namespace ClientRundownAPI { export async function rundownPlaylistNeedsResync( - context: MethodContext, + _context: MethodContext, playlistId: RundownPlaylistId ): Promise { check(playlistId, String) - const access = await StudioContentWriteAccess.rundownPlaylist(context, playlistId) - const playlist = access.playlist + triggerWriteAccessBecauseNoCheckNecessary() const rundowns = await Rundowns.findFetchAsync( { - playlistId: playlist._id, + playlistId: playlistId, }, { sort: { _id: 1 }, diff --git a/meteor/server/api/rundownLayouts.ts b/meteor/server/api/rundownLayouts.ts index db9eb108e0..62f4596762 100644 --- a/meteor/server/api/rundownLayouts.ts +++ b/meteor/server/api/rundownLayouts.ts @@ -10,12 +10,15 @@ import { import { literal, getRandomId, protectString } from '../lib/tempLib' import { logger } from '../logging' import { MethodContext, MethodContextAPI } from './methodContext' -import { ShowStyleContentWriteAccess } from '../security/showStyle' import { fetchShowStyleBaseLight } from '../optimizations' import { BlueprintId, RundownLayoutId, ShowStyleBaseId, UserId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { RundownLayouts } from '../collections' import KoaRouter from '@koa/router' import bodyParser from 'koa-bodyparser' +import { UserPermissions } from '@sofie-automation/meteor-lib/dist/userPermissions' +import { assertConnectionHasOneOfPermissions } from '../security/auth' + +const PERMISSIONS_FOR_MANAGE_RUNDOWN_LAYOUTS: Array = ['configure'] export async function createRundownLayout( name: string, @@ -57,6 +60,8 @@ shelfLayoutsRouter.post( async (ctx) => { ctx.response.type = 'text/plain' + assertConnectionHasOneOfPermissions(ctx, ...PERMISSIONS_FOR_MANAGE_RUNDOWN_LAYOUTS) + const showStyleBaseId: ShowStyleBaseId = protectString(ctx.params.showStyleBaseId) check(showStyleBaseId, String) @@ -129,15 +134,16 @@ async function apiCreateRundownLayout( check(showStyleBaseId, String) check(regionId, String) - const access = await ShowStyleContentWriteAccess.anyContent(context, showStyleBaseId) + assertConnectionHasOneOfPermissions(context.connection, ...PERMISSIONS_FOR_MANAGE_RUNDOWN_LAYOUTS) - return createRundownLayout(name, type, showStyleBaseId, regionId, undefined, access.userId || undefined) + return createRundownLayout(name, type, showStyleBaseId, regionId, undefined, undefined) } async function apiRemoveRundownLayout(context: MethodContext, id: RundownLayoutId) { check(id, String) - const access = await ShowStyleContentWriteAccess.rundownLayout(context, id) - const rundownLayout = access.rundownLayout + assertConnectionHasOneOfPermissions(context.connection, ...PERMISSIONS_FOR_MANAGE_RUNDOWN_LAYOUTS) + + const rundownLayout = await RundownLayouts.findOneAsync(id) if (!rundownLayout) throw new Meteor.Error(404, `RundownLayout "${id}" not found`) await removeRundownLayout(id) diff --git a/meteor/server/api/showStyles.ts b/meteor/server/api/showStyles.ts index 8275c81836..fdf42e6e5a 100644 --- a/meteor/server/api/showStyles.ts +++ b/meteor/server/api/showStyles.ts @@ -10,9 +10,6 @@ import { DBShowStyleBase } from '@sofie-automation/corelib/dist/dataModel/ShowSt import { DBShowStyleVariant } from '@sofie-automation/corelib/dist/dataModel/ShowStyleVariant' import { protectString, getRandomId, omit } from '../lib/tempLib' import { MethodContextAPI, MethodContext } from './methodContext' -import { OrganizationContentWriteAccess } from '../security/organization' -import { ShowStyleContentWriteAccess } from '../security/showStyle' -import { Credentials } from '../security/lib/credentials' import deepmerge from 'deepmerge' import { applyAndValidateOverrides, @@ -23,6 +20,10 @@ import { IBlueprintConfig } from '@sofie-automation/blueprints-integration' import { OrganizationId, ShowStyleBaseId, ShowStyleVariantId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { RundownLayouts, ShowStyleBases, ShowStyleVariants, Studios } from '../collections' import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio' +import { UserPermissions } from '@sofie-automation/meteor-lib/dist/userPermissions' +import { assertConnectionHasOneOfPermissions } from '../security/auth' + +const PERMISSIONS_FOR_MANAGE_SHOWSTYLES: Array = ['configure'] export interface ShowStyleCompound extends Omit { showStyleVariantId: ShowStyleVariantId @@ -74,9 +75,10 @@ export function createShowStyleCompound( } } -export async function insertShowStyleBase(context: MethodContext | Credentials): Promise { - const access = await OrganizationContentWriteAccess.showStyleBase(context) - return insertShowStyleBaseInner(access.organizationId) +export async function insertShowStyleBase(context: MethodContext): Promise { + assertConnectionHasOneOfPermissions(context.connection, ...PERMISSIONS_FOR_MANAGE_SHOWSTYLES) + + return insertShowStyleBaseInner(null) } export async function insertShowStyleBaseInner(organizationId: OrganizationId | null): Promise { @@ -97,20 +99,14 @@ export async function insertShowStyleBaseInner(organizationId: OrganizationId | await insertShowStyleVariantInner(showStyleBase._id, 'Default') return showStyleBase._id } -async function assertShowStyleBaseAccess(context: MethodContext | Credentials, showStyleBaseId: ShowStyleBaseId) { - check(showStyleBaseId, String) - - const access = await ShowStyleContentWriteAccess.anyContent(context, showStyleBaseId) - const showStyleBase = access.showStyleBase - if (!showStyleBase) throw new Meteor.Error(404, `showStyleBase "${showStyleBaseId}" not found`) -} export async function insertShowStyleVariant( - context: MethodContext | Credentials, + context: MethodContext, showStyleBaseId: ShowStyleBaseId, name?: string ): Promise { - await assertShowStyleBaseAccess(context, showStyleBaseId) + assertConnectionHasOneOfPermissions(context.connection, ...PERMISSIONS_FOR_MANAGE_SHOWSTYLES) + return insertShowStyleVariantInner(showStyleBaseId, name) } @@ -150,19 +146,19 @@ async function insertShowStyleVariantInner( } export async function importShowStyleVariant( - context: MethodContext | Credentials, + context: MethodContext, showStyleVariant: DBShowStyleVariant ): Promise { - await assertShowStyleBaseAccess(context, showStyleVariant.showStyleBaseId) + assertConnectionHasOneOfPermissions(context.connection, ...PERMISSIONS_FOR_MANAGE_SHOWSTYLES) return ShowStyleVariants.insertAsync(showStyleVariant) } export async function importShowStyleVariantAsNew( - context: MethodContext | Credentials, + context: MethodContext, showStyleVariant: Omit ): Promise { - await assertShowStyleBaseAccess(context, showStyleVariant.showStyleBaseId) + assertConnectionHasOneOfPermissions(context.connection, ...PERMISSIONS_FOR_MANAGE_SHOWSTYLES) const newShowStyleVariant: DBShowStyleVariant = { ...showStyleVariant, @@ -173,7 +169,7 @@ export async function importShowStyleVariantAsNew( } export async function removeShowStyleBase(context: MethodContext, showStyleBaseId: ShowStyleBaseId): Promise { - await assertShowStyleBaseAccess(context, showStyleBaseId) + assertConnectionHasOneOfPermissions(context.connection, ...PERMISSIONS_FOR_MANAGE_SHOWSTYLES) await Promise.allSettled([ ShowStyleBases.removeAsync(showStyleBaseId), @@ -192,8 +188,9 @@ export async function removeShowStyleVariant( ): Promise { check(showStyleVariantId, String) - const access = await ShowStyleContentWriteAccess.showStyleVariant(context, showStyleVariantId) - const showStyleVariant = access.showStyleVariant + assertConnectionHasOneOfPermissions(context.connection, ...PERMISSIONS_FOR_MANAGE_SHOWSTYLES) + + const showStyleVariant = await ShowStyleVariants.findOneAsync(showStyleVariantId) if (!showStyleVariant) throw new Meteor.Error(404, `showStyleVariant "${showStyleVariantId}" not found`) await ShowStyleVariants.removeAsync(showStyleVariant._id) @@ -207,8 +204,9 @@ export async function reorderShowStyleVariant( check(showStyleVariantId, String) check(rank, Number) - const access = await ShowStyleContentWriteAccess.showStyleVariant(context, showStyleVariantId) - const showStyleVariant = access.showStyleVariant + assertConnectionHasOneOfPermissions(context.connection, ...PERMISSIONS_FOR_MANAGE_SHOWSTYLES) + + const showStyleVariant = await ShowStyleVariants.findOneAsync(showStyleVariantId) if (!showStyleVariant) throw new Meteor.Error(404, `showStyleVariant "${showStyleVariantId}" not found`) await ShowStyleVariants.updateAsync(showStyleVariantId, { @@ -218,7 +216,9 @@ export async function reorderShowStyleVariant( }) } -async function getCreateAdlibTestingRundownOptions(): Promise { +async function getCreateAdlibTestingRundownOptions(context: MethodContext): Promise { + assertConnectionHasOneOfPermissions(context.connection, 'studio') + const [studios, showStyleBases, showStyleVariants] = await Promise.all([ Studios.findFetchAsync( {}, @@ -306,7 +306,7 @@ class ServerShowStylesAPI extends MethodContextAPI implements NewShowStylesAPI { } async getCreateAdlibTestingRundownOptions() { - return getCreateAdlibTestingRundownOptions() + return getCreateAdlibTestingRundownOptions(this) } } registerClassToMeteorMethods(ShowStylesAPIMethods, ServerShowStylesAPI, false) diff --git a/meteor/server/api/singleUseTokens.ts b/meteor/server/api/singleUseTokens.ts index d775a5e760..88fc57a45f 100644 --- a/meteor/server/api/singleUseTokens.ts +++ b/meteor/server/api/singleUseTokens.ts @@ -3,7 +3,7 @@ import { Time } from '@sofie-automation/blueprints-integration' import { getHash } from '@sofie-automation/corelib/dist/hash' import { getCurrentTime } from '../lib/lib' import { SINGLE_USE_TOKEN_SALT } from '@sofie-automation/meteor-lib/dist/api/userActions' -import { isInTestWrite } from '../security/lib/securityVerify' +import { isInTestWrite } from '../security/securityVerify' // The following code is taken from an NPM pacakage called "@sunknudsen/totp", but copied here, instead // of used as a dependency so that it's not vulnerable to a supply chain attack diff --git a/meteor/server/api/snapshot.ts b/meteor/server/api/snapshot.ts index a07dbdb7d9..3ca719a2d4 100644 --- a/meteor/server/api/snapshot.ts +++ b/meteor/server/api/snapshot.ts @@ -39,12 +39,7 @@ import { importIngestRundown } from './ingest/http' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { RundownLayoutBase } from '@sofie-automation/meteor-lib/dist/collections/RundownLayouts' import { DBTriggeredActions } from '@sofie-automation/meteor-lib/dist/collections/TriggeredActions' -import { Settings } from '../Settings' import { MethodContext, MethodContextAPI } from './methodContext' -import { Credentials, isResolvedCredentials } from '../security/lib/credentials' -import { OrganizationContentWriteAccess } from '../security/organization' -import { StudioContentWriteAccess } from '../security/studio' -import { SystemWriteAccess } from '../security/system' import { saveIntoDb, sumChanges } from '../lib/database' import * as fs from 'fs' import { ExpectedPackageWorkStatus } from '@sofie-automation/corelib/dist/dataModel/ExpectedPackageWorkStatuses' @@ -57,8 +52,7 @@ import { checkStudioExists } from '../optimizations' import { CoreRundownPlaylistSnapshot } from '@sofie-automation/corelib/dist/snapshots' import { QueueStudioJob } from '../worker/worker' import { StudioJobs } from '@sofie-automation/corelib/dist/worker/studio' -import { ReadonlyDeep } from 'type-fest' -import { checkAccessToPlaylist, VerifiedRundownPlaylistContentAccess } from './lib' +import { checkAccessToPlaylist, VerifiedRundownPlaylistForUserAction } from '../security/check' import { getSystemStorePath, PackageInfo } from '../coreSystem' import { JSONBlobParse, JSONBlobStringify } from '@sofie-automation/shared-lib/dist/lib/JSONBlob' import { @@ -93,6 +87,10 @@ import { import { getCoreSystemAsync } from '../coreSystem/collection' import { executePeripheralDeviceFunction } from './peripheralDevice/executeFunction' import { verifyHashedToken } from './singleUseTokens' +import { UserPermissions } from '@sofie-automation/meteor-lib/dist/userPermissions' +import { assertConnectionHasOneOfPermissions, RequestCredentials } from '../security/auth' + +const PERMISSIONS_FOR_SNAPSHOT_MANAGEMENT: Array = ['configure'] interface RundownPlaylistSnapshot extends CoreRundownPlaylistSnapshot { versionExtended: string | undefined @@ -155,9 +153,6 @@ async function createSystemSnapshot( const coreSystem = await getCoreSystemAsync() if (!coreSystem) throw new Meteor.Error(500, `coreSystem not set up`) - if (Settings.enableUserAccounts && !organizationId) - throw new Meteor.Error(500, 'Not able to create a systemSnaphost without organizationId') - let queryStudio: MongoQuery = {} let queryShowStyleBases: MongoQuery = {} let queryShowStyleVariants: MongoQuery = {} @@ -320,7 +315,7 @@ function getPiecesMediaObjects(pieces: PieceGeneric[]): string[] { } async function createRundownPlaylistSnapshot( - playlist: ReadonlyDeep, + playlist: VerifiedRundownPlaylistForUserAction, full = false ): Promise { /** Max count of one type of items to include in the snapshot */ @@ -452,24 +447,12 @@ async function storeSnaphot( return id } -async function retreiveSnapshot(snapshotId: SnapshotId, cred0: Credentials): Promise { +async function retreiveSnapshot(snapshotId: SnapshotId, cred: RequestCredentials | null): Promise { + assertConnectionHasOneOfPermissions(cred, ...PERMISSIONS_FOR_SNAPSHOT_MANAGEMENT) + const snapshot = await Snapshots.findOneAsync(snapshotId) if (!snapshot) throw new Meteor.Error(404, `Snapshot not found!`) - if (Settings.enableUserAccounts) { - if (snapshot.type === SnapshotType.RUNDOWNPLAYLIST) { - if (!snapshot.studioId) - throw new Meteor.Error(500, `Snapshot is of type "${snapshot.type}" but hase no studioId`) - await StudioContentWriteAccess.dataFromSnapshot(cred0, snapshot.studioId) - } else if (snapshot.type === SnapshotType.SYSTEM) { - if (!snapshot.organizationId) - throw new Meteor.Error(500, `Snapshot is of type "${snapshot.type}" but has no organizationId`) - await OrganizationContentWriteAccess.dataFromSnapshot(cred0, snapshot.organizationId) - } else { - await SystemWriteAccess.coreSystem(cred0) - } - } - const storePath = getSystemStorePath() const filePath = Path.join(storePath, snapshot.fileName) @@ -663,11 +646,9 @@ export async function storeSystemSnapshot( throw new Meteor.Error(401, `Restart token is invalid or has expired`) } - const { organizationId, cred } = await OrganizationContentWriteAccess.snapshot(context) - if (Settings.enableUserAccounts && isResolvedCredentials(cred)) { - if (cred.user && !cred.user.superAdmin) throw new Meteor.Error(401, 'Only Super Admins can store Snapshots') - } - return internalStoreSystemSnapshot(organizationId, studioId, reason) + assertConnectionHasOneOfPermissions(context.connection, ...PERMISSIONS_FOR_SNAPSHOT_MANAGEMENT) + + return internalStoreSystemSnapshot(null, studioId, reason) } /** Take and store a system snapshot. For internal use only, performs no access control. */ export async function internalStoreSystemSnapshot( @@ -681,7 +662,7 @@ export async function internalStoreSystemSnapshot( return storeSnaphot(s, organizationId, reason) } export async function storeRundownPlaylistSnapshot( - access: VerifiedRundownPlaylistContentAccess, + playlist: VerifiedRundownPlaylistForUserAction, hashedToken: string, reason: string, full?: boolean @@ -691,8 +672,8 @@ export async function storeRundownPlaylistSnapshot( throw new Meteor.Error(401, `Restart token is invalid or has expired`) } - const s = await createRundownPlaylistSnapshot(access.playlist, full) - return storeSnaphot(s, access.organizationId, reason) + const s = await createRundownPlaylistSnapshot(playlist, full) + return storeSnaphot(s, playlist.organizationId ?? null, reason) } export async function internalStoreRundownPlaylistSnapshot( playlist: DBRundownPlaylist, @@ -714,12 +695,10 @@ export async function storeDebugSnapshot( throw new Meteor.Error(401, `Restart token is invalid or has expired`) } - const { organizationId, cred } = await OrganizationContentWriteAccess.snapshot(context) - if (Settings.enableUserAccounts && isResolvedCredentials(cred)) { - if (cred.user && !cred.user.superAdmin) throw new Meteor.Error(401, 'Only Super Admins can store Snapshots') - } - const s = await createDebugSnapshot(studioId, organizationId) - return storeSnaphot(s, organizationId, reason) + assertConnectionHasOneOfPermissions(context.connection, ...PERMISSIONS_FOR_SNAPSHOT_MANAGEMENT) + + const s = await createDebugSnapshot(studioId, null) + return storeSnaphot(s, null, reason) } export async function restoreSnapshot( context: MethodContext, @@ -727,21 +706,18 @@ export async function restoreSnapshot( restoreDebugData: boolean ): Promise { check(snapshotId, String) - const { cred } = await OrganizationContentWriteAccess.snapshot(context) - if (Settings.enableUserAccounts && isResolvedCredentials(cred)) { - if (cred.user && !cred.user.superAdmin) throw new Meteor.Error(401, 'Only Super Admins can store Snapshots') - } - const snapshot = await retreiveSnapshot(snapshotId, context) + + const snapshot = await retreiveSnapshot(snapshotId, context.connection) return restoreFromSnapshot(snapshot, restoreDebugData) } export async function removeSnapshot(context: MethodContext, snapshotId: SnapshotId): Promise { check(snapshotId, String) - const { snapshot, cred } = await OrganizationContentWriteAccess.snapshot(context, snapshotId) - if (Settings.enableUserAccounts && isResolvedCredentials(cred)) { - if (cred.user && !cred.user.superAdmin) throw new Meteor.Error(401, 'Only Super Admins can store Snapshots') - } + + assertConnectionHasOneOfPermissions(context.connection, ...PERMISSIONS_FOR_SNAPSHOT_MANAGEMENT) + logger.info(`Removing snapshot ${snapshotId}`) + const snapshot = await Snapshots.findOneAsync(snapshotId) if (!snapshot) throw new Meteor.Error(404, `Snapshot "${snapshotId}" not found!`) if (snapshot.fileName) { @@ -789,58 +765,48 @@ async function handleKoaResponse( } } -// For backwards compatibility: -if (!Settings.enableUserAccounts) { - snapshotPrivateApiRouter.post( - '/restore', - bodyParser({ - jsonLimit: '200mb', // Arbitrary limit - }), - async (ctx) => { - const content = 'ok' - try { - ctx.response.type = 'text/plain' - - if (ctx.request.type !== 'application/json') - throw new Meteor.Error(400, 'Restore Snapshot: Invalid content-type') - - const snapshot = ctx.request.body as any - if (!snapshot) throw new Meteor.Error(400, 'Restore Snapshot: Missing request body') - - const restoreDebugData = ctx.headers['restore-debug-data'] === '1' - - await restoreFromSnapshot(snapshot, restoreDebugData) - - ctx.response.status = 200 - ctx.response.body = content - } catch (e) { - ctx.response.type = 'text/plain' - ctx.response.status = e instanceof Meteor.Error && typeof e.error === 'number' ? e.error : 500 - ctx.response.body = 'Error: ' + stringifyError(e) - - if (ctx.response.status !== 404) { - logger.error(stringifyError(e)) - } +snapshotPrivateApiRouter.post( + '/restore', + bodyParser({ + jsonLimit: '200mb', // Arbitrary limit + }), + async (ctx) => { + assertConnectionHasOneOfPermissions(ctx, ...PERMISSIONS_FOR_SNAPSHOT_MANAGEMENT) + + const content = 'ok' + try { + ctx.response.type = 'text/plain' + + if (ctx.request.type !== 'application/json') + throw new Meteor.Error(400, 'Restore Snapshot: Invalid content-type') + + const snapshot = ctx.request.body as any + if (!snapshot) throw new Meteor.Error(400, 'Restore Snapshot: Missing request body') + + const restoreDebugData = ctx.headers['restore-debug-data'] === '1' + + await restoreFromSnapshot(snapshot, restoreDebugData) + + ctx.response.status = 200 + ctx.response.body = content + } catch (e) { + ctx.response.type = 'text/plain' + ctx.response.status = e instanceof Meteor.Error && typeof e.error === 'number' ? e.error : 500 + ctx.response.body = 'Error: ' + stringifyError(e) + + if (ctx.response.status !== 404) { + logger.error(stringifyError(e)) } } - ) - - // Retrieve snapshot: - snapshotPrivateApiRouter.get('/retrieve/:snapshotId', async (ctx) => { - return handleKoaResponse(ctx, async () => { - const snapshotId = ctx.params.snapshotId - check(snapshotId, String) - return retreiveSnapshot(protectString(snapshotId), { userId: null }) - }) - }) -} + } +) // Retrieve snapshot: -snapshotPrivateApiRouter.get('/:token/retrieve/:snapshotId', async (ctx) => { +snapshotPrivateApiRouter.get('/retrieve/:snapshotId', async (ctx) => { return handleKoaResponse(ctx, async () => { const snapshotId = ctx.params.snapshotId check(snapshotId, String) - return retreiveSnapshot(protectString(snapshotId), { userId: null, token: ctx.params.token }) + return retreiveSnapshot(protectString(snapshotId), ctx) }) }) @@ -850,8 +816,8 @@ class ServerSnapshotAPI extends MethodContextAPI implements NewSnapshotAPI { } async storeRundownPlaylist(hashedToken: string, playlistId: RundownPlaylistId, reason: string) { check(playlistId, String) - const access = await checkAccessToPlaylist(this, playlistId) - return storeRundownPlaylistSnapshot(access, hashedToken, reason) + const playlist = await checkAccessToPlaylist(this.connection, playlistId) + return storeRundownPlaylistSnapshot(playlist, hashedToken, reason) } async storeDebugSnapshot(hashedToken: string, studioId: StudioId, reason: string) { return storeDebugSnapshot(this, hashedToken, studioId, reason) diff --git a/meteor/server/api/studio/api.ts b/meteor/server/api/studio/api.ts index 94ac811d40..a465e1fdd9 100644 --- a/meteor/server/api/studio/api.ts +++ b/meteor/server/api/studio/api.ts @@ -21,18 +21,21 @@ import { Timeline, } from '../../collections' import { MethodContextAPI, MethodContext } from '../methodContext' -import { OrganizationContentWriteAccess } from '../../security/organization' -import { Credentials } from '../../security/lib/credentials' import { wrapDefaultObject } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' import { OrganizationId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { logger } from '../../logging' import { DEFAULT_MINIMUM_TAKE_SPAN } from '@sofie-automation/shared-lib/dist/core/constants' +import { UserPermissions } from '@sofie-automation/meteor-lib/dist/userPermissions' +import { assertConnectionHasOneOfPermissions } from '../../security/auth' -async function insertStudio(context: MethodContext | Credentials, newId?: StudioId): Promise { +const PERMISSIONS_FOR_MANAGE_STUDIOS: Array = ['configure'] + +async function insertStudio(context: MethodContext, newId?: StudioId): Promise { if (newId) check(newId, String) - const access = await OrganizationContentWriteAccess.studio(context) - return insertStudioInner(access.organizationId, newId) + assertConnectionHasOneOfPermissions(context.connection, ...PERMISSIONS_FOR_MANAGE_STUDIOS) + + return insertStudioInner(null, newId) } export async function insertStudioInner(organizationId: OrganizationId | null, newId?: StudioId): Promise { return Studios.insertAsync( @@ -69,8 +72,9 @@ export async function insertStudioInner(organizationId: OrganizationId | null, n async function removeStudio(context: MethodContext, studioId: StudioId): Promise { check(studioId, String) - const access = await OrganizationContentWriteAccess.studio(context, studioId) - const studio = access.studio + assertConnectionHasOneOfPermissions(context.connection, ...PERMISSIONS_FOR_MANAGE_STUDIOS) + + const studio = await Studios.findOneAsync(studioId) if (!studio) throw new Meteor.Error(404, `Studio "${studioId}" not found`) // allowed to remove? diff --git a/meteor/server/api/system.ts b/meteor/server/api/system.ts index 1668f76720..3c8bd9c4b8 100644 --- a/meteor/server/api/system.ts +++ b/meteor/server/api/system.ts @@ -13,12 +13,10 @@ import { import { CollectionIndexes, getTargetRegisteredIndexes } from '../collections/indices' import { Meteor } from 'meteor/meteor' import { logger } from '../logging' -import { SystemWriteAccess } from '../security/system' import { check } from '../lib/check' import { IndexSpecifier } from '@sofie-automation/meteor-lib/dist/collections/lib' import { getBundle as getTranslationBundleInner } from './translationsBundles' import { TranslationsBundle } from '@sofie-automation/meteor-lib/dist/collections/TranslationsBundles' -import { OrganizationContentWriteAccess } from '../security/organization' import { ClientAPI } from '@sofie-automation/meteor-lib/dist/api/client' import { cleanupOldDataInner } from './cleanup' import { IndexSpecification } from 'mongodb' @@ -26,8 +24,12 @@ import { nightlyCronjobInner } from '../cronjobs' import { TranslationsBundleId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { createAsyncOnlyMongoCollection, AsyncOnlyMongoCollection } from '../collections/collection' import { generateToken } from './singleUseTokens' -import { triggerWriteAccessBecauseNoCheckNecessary } from '../security/lib/securityVerify' +import { triggerWriteAccessBecauseNoCheckNecessary } from '../security/securityVerify' import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' +import { assertConnectionHasOneOfPermissions } from '../security/auth' +import { UserPermissions } from '@sofie-automation/meteor-lib/dist/userPermissions' + +const PERMISSIONS_FOR_SYSTEM_CLEANUP: Array = ['configure'] async function setupIndexes(removeOldIndexes = false): Promise> { // Note: This function should NOT run on Meteor.startup, due to getCollectionIndexes failing if run before indexes have been created. @@ -95,7 +97,7 @@ async function cleanupIndexes( actuallyRemoveOldIndexes: boolean ): Promise> { check(actuallyRemoveOldIndexes, Boolean) - await SystemWriteAccess.coreSystem(context) + assertConnectionHasOneOfPermissions(context.connection, ...PERMISSIONS_FOR_SYSTEM_CLEANUP) return setupIndexes(actuallyRemoveOldIndexes) } @@ -104,12 +106,13 @@ async function cleanupOldData( actuallyRemoveOldData: boolean ): Promise { check(actuallyRemoveOldData, Boolean) - await SystemWriteAccess.coreSystem(context) + + assertConnectionHasOneOfPermissions(context.connection, ...PERMISSIONS_FOR_SYSTEM_CLEANUP) return cleanupOldDataInner(actuallyRemoveOldData) } async function runCronjob(context: MethodContext): Promise { - await SystemWriteAccess.coreSystem(context) + assertConnectionHasOneOfPermissions(context.connection, ...PERMISSIONS_FOR_SYSTEM_CLEANUP) return nightlyCronjobInner() } @@ -293,7 +296,7 @@ async function doSystemBenchmarkInner() { return result } async function doSystemBenchmark(context: MethodContext, runCount = 1): Promise { - await SystemWriteAccess.coreSystem(context) + assertConnectionHasOneOfPermissions(context.connection, 'developer') if (runCount < 1) throw new Error(`runCount must be >= 1`) @@ -361,10 +364,11 @@ CPU JSON stringifying: ${avg.cpuStringifying} ms (${comparison.cpuStringif } } -async function getTranslationBundle(context: MethodContext, bundleId: TranslationsBundleId) { +async function getTranslationBundle(_context: MethodContext, bundleId: TranslationsBundleId) { check(bundleId, String) - await OrganizationContentWriteAccess.translationBundle(context) + triggerWriteAccessBecauseNoCheckNecessary() + return ClientAPI.responseSuccess(await getTranslationBundleInner(bundleId)) } diff --git a/meteor/server/api/triggeredActions.ts b/meteor/server/api/triggeredActions.ts index f172786207..0f29b780d0 100644 --- a/meteor/server/api/triggeredActions.ts +++ b/meteor/server/api/triggeredActions.ts @@ -4,14 +4,12 @@ import { registerClassToMeteorMethods, ReplaceOptionalWithNullInMethodArguments import { literal, getRandomId, protectString, Complete } from '../lib/tempLib' import { logger } from '../logging' import { MethodContext, MethodContextAPI } from './methodContext' -import { ShowStyleContentWriteAccess } from '../security/showStyle' import { DBTriggeredActions, TriggeredActionsObj } from '@sofie-automation/meteor-lib/dist/collections/TriggeredActions' import { CreateTriggeredActionsContent, NewTriggeredActionsAPI, TriggeredActionsAPIMethods, } from '@sofie-automation/meteor-lib/dist/api/triggeredActions' -import { SystemWriteAccess } from '../security/system' import { fetchShowStyleBaseLight } from '../optimizations' import { convertObjectIntoOverrides, @@ -21,6 +19,10 @@ import { ShowStyleBaseId, TriggeredActionId } from '@sofie-automation/corelib/di import { TriggeredActions } from '../collections' import KoaRouter from '@koa/router' import bodyParser from 'koa-bodyparser' +import { UserPermissions } from '@sofie-automation/meteor-lib/dist/userPermissions' +import { assertConnectionHasOneOfPermissions } from '../security/auth' + +const PERMISSIONS_FOR_TRIGGERED_ACTIONS: Array = ['configure'] export async function createTriggeredActions( showStyleBaseId: ShowStyleBaseId | null, @@ -58,6 +60,8 @@ actionTriggersRouter.post( async (ctx) => { ctx.response.type = 'text/plain' + assertConnectionHasOneOfPermissions(ctx, ...PERMISSIONS_FOR_TRIGGERED_ACTIONS) + const showStyleBaseId: ShowStyleBaseId | undefined = protectString(ctx.params.showStyleBaseId) check(showStyleBaseId, Match.Optional(String)) @@ -161,22 +165,14 @@ async function apiCreateTriggeredActions( check(showStyleBaseId, Match.Maybe(String)) check(base, Match.Maybe(Object)) - if (!showStyleBaseId) { - const access = await SystemWriteAccess.coreSystem(context) - if (!access) throw new Meteor.Error(403, `Core System settings not writable`) - } else { - const access = await ShowStyleContentWriteAccess.anyContent(context, showStyleBaseId) - if (!access) throw new Meteor.Error(404, `ShowStyleBase "${showStyleBaseId}" not found`) - } + assertConnectionHasOneOfPermissions(context.connection, ...PERMISSIONS_FOR_TRIGGERED_ACTIONS) return createTriggeredActions(showStyleBaseId, base || undefined) } async function apiRemoveTriggeredActions(context: MethodContext, id: TriggeredActionId) { check(id, String) - const access = await ShowStyleContentWriteAccess.triggeredActions(context, id) - const triggeredActions = typeof access === 'boolean' ? access : access.triggeredActions - if (!triggeredActions) throw new Meteor.Error(404, `Action Trigger "${id}" not found`) + assertConnectionHasOneOfPermissions(context.connection, ...PERMISSIONS_FOR_TRIGGERED_ACTIONS) await removeTriggeredActions(id) } diff --git a/meteor/server/api/user.ts b/meteor/server/api/user.ts index 9b3649abdd..e626071dd3 100644 --- a/meteor/server/api/user.ts +++ b/meteor/server/api/user.ts @@ -1,132 +1,14 @@ -import { Meteor } from 'meteor/meteor' -import { Accounts } from 'meteor/accounts-base' -import { unprotectString, protectString } from '../lib/tempLib' -import { sleep, deferAsync } from '../lib/lib' -import { MethodContextAPI, MethodContext } from './methodContext' -import { NewUserAPI, UserAPIMethods, createUser, CreateNewUserData } from '@sofie-automation/meteor-lib/dist/api/user' +import { MethodContextAPI } from './methodContext' +import { NewUserAPI, UserAPIMethods } from '@sofie-automation/meteor-lib/dist/api/user' import { registerClassToMeteorMethods } from '../methods' -import { SystemWriteAccess } from '../security/system' -import { triggerWriteAccess, triggerWriteAccessBecauseNoCheckNecessary } from '../security/lib/securityVerify' -import { logNotAllowed } from '../../server/security/lib/lib' -import { User } from '@sofie-automation/meteor-lib/dist/collections/Users' -import { createOrganization } from './organizations' -import { DBOrganizationBase } from '@sofie-automation/meteor-lib/dist/collections/Organization' -import { resetCredentials } from '../security/lib/credentials' -import { OrganizationId, UserId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { Organizations, Users } from '../collections' -import { logger } from '../logging' - -async function enrollUser(email: string, name: string): Promise { - triggerWriteAccessBecauseNoCheckNecessary() - - const id = await createUser({ - email: email, - profile: { name: name }, - }) - try { - await Accounts.sendEnrollmentEmail(unprotectString(id), email) - } catch (error) { - logger.error('Accounts.sendEnrollmentEmail') - logger.error(error) - } - - return id -} - -async function afterCreateNewUser(userId: UserId, organization: DBOrganizationBase): Promise { - triggerWriteAccessBecauseNoCheckNecessary() - - await sendVerificationEmail(userId) - - // Create an organization for the user: - const orgId = await createOrganization(organization) - // Add user to organization: - await Users.updateAsync(userId, { $set: { organizationId: orgId } }) - await Organizations.updateAsync(orgId, { - $set: { - userRoles: { - [unprotectString(userId)]: { - admin: true, - studio: true, - configurator: true, - }, - }, - }, - }) - - resetCredentials({ userId }) - - return orgId -} -async function sendVerificationEmail(userId: UserId) { - const user = await Users.findOneAsync(userId) - if (!user) throw new Meteor.Error(404, `User "${userId}" not found!`) - try { - await Promise.all( - user.emails.map(async (email) => { - if (!email.verified) { - await Accounts.sendVerificationEmail(unprotectString(user._id), email.address) - } - }) - ) - } catch (error) { - logger.error('ERROR sending email verification') - logger.error(error) - } -} - -async function requestResetPassword(email: string): Promise { - triggerWriteAccessBecauseNoCheckNecessary() - const meteorUser = Accounts.findUserByEmail(email) as unknown - const user = meteorUser as User - if (!user) return false - await Accounts.sendResetPasswordEmail(unprotectString(user._id)) - return true -} - -async function removeUser(context: MethodContext) { - triggerWriteAccess() - if (!context.userId) throw new Meteor.Error(403, `Not logged in`) - const access = await SystemWriteAccess.currentUser(context.userId, context) - if (!access) return logNotAllowed('Current user', 'Invalid user id or permissions') - await Users.removeAsync(context.userId) - return true -} +import { triggerWriteAccessBecauseNoCheckNecessary } from '../security/securityVerify' +import { parseUserPermissions, USER_PERMISSIONS_HEADER } from '@sofie-automation/meteor-lib/dist/userPermissions' class ServerUserAPI extends MethodContextAPI implements NewUserAPI { - async enrollUser(email: string, name: string) { - return enrollUser(email, name) - } - async requestPasswordReset(email: string) { - return requestResetPassword(email) - } - async removeUser() { - return removeUser(this) + async getUserPermissions() { + triggerWriteAccessBecauseNoCheckNecessary() + return parseUserPermissions(this.connection?.httpHeaders?.[USER_PERMISSIONS_HEADER]) } } registerClassToMeteorMethods(UserAPIMethods, ServerUserAPI, false) - -Accounts.onCreateUser((options0, user) => { - const options = options0 as Partial - user.profile = options.profile - - const createOrganization = options.createOrganization - if (createOrganization) { - deferAsync(async () => { - // To be run after the user has been inserted: - for (let t = 10; t < 200; t *= 1.5) { - const dbUser = await Users.findOneAsync(protectString(user._id)) - if (dbUser) { - await afterCreateNewUser(dbUser._id, createOrganization) - return - } else { - // User has not been inserted into db (yet), wait - await sleep(t) - } - } - }) - } - // The user to-be-inserted: - return user -}) diff --git a/meteor/server/api/userActions.ts b/meteor/server/api/userActions.ts index 1f4935625e..a15aaeb5ca 100644 --- a/meteor/server/api/userActions.ts +++ b/meteor/server/api/userActions.ts @@ -10,25 +10,20 @@ import { storeRundownPlaylistSnapshot } from './snapshot' import { registerClassToMeteorMethods, ReplaceOptionalWithNullInMethodArguments } from '../methods' import { ServerRundownAPI } from './rundown' import { saveEvaluation } from './evaluations' -import { MediaManagerAPI } from './mediaManager' +import * as MediaManagerAPI from './mediaManager' import { MOSDeviceActions } from './ingest/mosDevice/actions' import { MethodContextAPI } from './methodContext' import { ServerClientAPI } from './client' -import { OrganizationContentWriteAccess } from '../security/organization' -import { SystemWriteAccess } from '../security/system' -import { triggerWriteAccessBecauseNoCheckNecessary } from '../security/lib/securityVerify' +import { triggerWriteAccessBecauseNoCheckNecessary } from '../security/securityVerify' import { Bucket } from '@sofie-automation/meteor-lib/dist/collections/Buckets' import { BucketsAPI } from './buckets' import { BucketAdLib } from '@sofie-automation/corelib/dist/dataModel/BucketAdLibPiece' import { AdLibActionCommon } from '@sofie-automation/corelib/dist/dataModel/AdlibAction' import { BucketAdLibAction } from '@sofie-automation/corelib/dist/dataModel/BucketAdLibAction' -import { VerifiedRundownPlaylistContentAccess } from './lib' -import { PackageManagerAPI } from './packageManager' +import { checkAccessToRundown } from '../security/check' +import * as PackageManagerAPI from './packageManager' import { ServerPeripheralDeviceAPI } from './peripheralDevice' import { StudioJobs } from '@sofie-automation/corelib/dist/worker/studio' -import { PeripheralDeviceContentWriteAccess } from '../security/peripheralDevice' -import { StudioContentWriteAccess } from '../security/studio' -import { BucketSecurity } from '../security/buckets' import { AdLibActionId, BucketId, @@ -51,11 +46,17 @@ import { NrcsIngestCacheType } from '@sofie-automation/corelib/dist/dataModel/Nr import { verifyHashedToken } from './singleUseTokens' import { QuickLoopMarker } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { runIngestOperation } from './ingest/lib' -import { RundownPlaylistContentWriteAccess } from '../security/rundownPlaylist' import { IngestJobs } from '@sofie-automation/corelib/dist/worker/ingest' +import { UserPermissions } from '@sofie-automation/meteor-lib/dist/userPermissions' +import { assertConnectionHasOneOfPermissions } from '../security/auth' + +const PERMISSIONS_FOR_PLAYOUT_USERACTION: Array = ['studio'] +const PERMISSIONS_FOR_BUCKET_MODIFICATION: Array = ['studio'] +const PERMISSIONS_FOR_MEDIA_MANAGEMENT: Array = ['studio', 'service', 'configure'] +const PERMISSIONS_FOR_SYSTEM_ACTION: Array = ['service', 'configure'] async function pieceSetInOutPoints( - access: VerifiedRundownPlaylistContentAccess, + playlistId: RundownPlaylistId, partId: PartId, pieceId: PieceId, inPoint: number, @@ -66,7 +67,7 @@ async function pieceSetInOutPoints( const rundown = await Rundowns.findOneAsync({ _id: part.rundownId, - playlistId: access.playlist._id, + playlistId: playlistId, }) if (!rundown) throw new Meteor.Error(501, `Rundown "${part.rundownId}" not found!`) @@ -387,8 +388,8 @@ class ServerUserActionAPI }, 'pieceSetInOutPoints', { rundownPlaylistId, partId, pieceId, inPoint, duration }, - async (access) => { - return pieceSetInOutPoints(access, partId, pieceId, inPoint, duration) + async (playlist) => { + return pieceSetInOutPoints(playlist._id, partId, pieceId, inPoint, duration) } ) } @@ -546,8 +547,8 @@ class ServerUserActionAPI check(showStyleBaseId, String) check(ingestItem, Object) - const access = await BucketSecurity.allowWriteAccess(this, bucketId) - return BucketsAPI.importAdlibToBucket(access, showStyleBaseId, undefined, ingestItem) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_BUCKET_MODIFICATION) + return BucketsAPI.importAdlibToBucket(bucketId, showStyleBaseId, undefined, ingestItem) } ) } @@ -622,8 +623,8 @@ class ServerUserActionAPI }, 'saveEvaluation', { evaluation }, - async (access) => { - return saveEvaluation(access, evaluation) + async (playlist) => { + return saveEvaluation(playlist, evaluation) } ) } @@ -646,8 +647,8 @@ class ServerUserActionAPI }, 'storeRundownSnapshot', { playlistId, reason, full }, - async (access) => { - return storeRundownPlaylistSnapshot(access, hashedToken, reason, full) + async (playlist) => { + return storeRundownPlaylistSnapshot(playlist, hashedToken, reason, full) } ) } @@ -695,8 +696,8 @@ class ServerUserActionAPI }, 'resyncRundownPlaylist', { playlistId }, - async (access) => { - return ServerRundownAPI.resyncRundownPlaylist(access) + async (playlist) => { + return ServerRundownAPI.resyncRundownPlaylist(playlist) } ) } @@ -711,8 +712,8 @@ class ServerUserActionAPI }, 'unsyncRundown', { rundownId }, - async (access) => { - return ServerRundownAPI.unsyncRundown(access) + async (rundown) => { + return ServerRundownAPI.unsyncRundown(rundown) } ) } @@ -727,8 +728,8 @@ class ServerUserActionAPI }, 'removeRundown', { rundownId }, - async (access) => { - return ServerRundownAPI.removeRundown(access) + async (rundown) => { + return ServerRundownAPI.removeRundown(rundown) } ) } @@ -743,53 +744,71 @@ class ServerUserActionAPI }, 'resyncRundown', { rundownId }, - async (access) => { - return ServerRundownAPI.resyncRundown(access) + async (rundown) => { + return ServerRundownAPI.resyncRundown(rundown) } ) } - async mediaRestartWorkflow(userEvent: string, eventTime: Time, workflowId: MediaWorkFlowId) { + async mediaRestartWorkflow( + userEvent: string, + eventTime: Time, + deviceId: PeripheralDeviceId, + workflowId: MediaWorkFlowId + ) { return ServerClientAPI.runUserActionInLog( this, userEvent, eventTime, 'mediaRestartWorkflow', - { workflowId }, + { deviceId, workflowId }, async () => { check(workflowId, String) - const access = await PeripheralDeviceContentWriteAccess.mediaWorkFlow(this, workflowId) - return MediaManagerAPI.restartWorkflow(access) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_MEDIA_MANAGEMENT) + + return MediaManagerAPI.restartWorkflow(deviceId, workflowId) } ) } - async mediaAbortWorkflow(userEvent: string, eventTime: Time, workflowId: MediaWorkFlowId) { + async mediaAbortWorkflow( + userEvent: string, + eventTime: Time, + deviceId: PeripheralDeviceId, + workflowId: MediaWorkFlowId + ) { return ServerClientAPI.runUserActionInLog( this, userEvent, eventTime, 'mediaAbortWorkflow', - { workflowId }, + { deviceId, workflowId }, async () => { check(workflowId, String) - const access = await PeripheralDeviceContentWriteAccess.mediaWorkFlow(this, workflowId) - return MediaManagerAPI.abortWorkflow(access) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_MEDIA_MANAGEMENT) + + return MediaManagerAPI.abortWorkflow(deviceId, workflowId) } ) } - async mediaPrioritizeWorkflow(userEvent: string, eventTime: Time, workflowId: MediaWorkFlowId) { + async mediaPrioritizeWorkflow( + userEvent: string, + eventTime: Time, + deviceId: PeripheralDeviceId, + workflowId: MediaWorkFlowId + ) { return ServerClientAPI.runUserActionInLog( this, userEvent, eventTime, 'mediaPrioritizeWorkflow', - { workflowId }, + { deviceId, workflowId }, async () => { check(workflowId, String) - const access = await PeripheralDeviceContentWriteAccess.mediaWorkFlow(this, workflowId) - return MediaManagerAPI.prioritizeWorkflow(access) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_MEDIA_MANAGEMENT) + + return MediaManagerAPI.prioritizeWorkflow(deviceId, workflowId) } ) } @@ -801,8 +820,9 @@ class ServerUserActionAPI 'mediaRestartAllWorkflows', {}, async () => { - const access = await OrganizationContentWriteAccess.mediaWorkFlows(this) - return MediaManagerAPI.restartAllWorkflows(access) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_MEDIA_MANAGEMENT) + + return MediaManagerAPI.restartAllWorkflows(null) } ) } @@ -814,8 +834,9 @@ class ServerUserActionAPI 'mediaAbortAllWorkflows', {}, async () => { - const access = await OrganizationContentWriteAccess.mediaWorkFlows(this) - return MediaManagerAPI.abortAllWorkflows(access) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_MEDIA_MANAGEMENT) + + return MediaManagerAPI.abortAllWorkflows(null) } ) } @@ -835,8 +856,9 @@ class ServerUserActionAPI check(deviceId, String) check(workId, String) - const access = await PeripheralDeviceContentWriteAccess.executeFunction(this, deviceId) - return PackageManagerAPI.restartExpectation(access, workId) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_MEDIA_MANAGEMENT) + + return PackageManagerAPI.restartExpectation(deviceId, workId) } ) } @@ -850,8 +872,9 @@ class ServerUserActionAPI async () => { check(studioId, String) - const access = await StudioContentWriteAccess.executeFunction(this, studioId) - return PackageManagerAPI.restartAllExpectationsInStudio(access) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_MEDIA_MANAGEMENT) + + return PackageManagerAPI.restartAllExpectationsInStudio(studioId) } ) } @@ -871,8 +894,9 @@ class ServerUserActionAPI check(deviceId, String) check(workId, String) - const access = await PeripheralDeviceContentWriteAccess.executeFunction(this, deviceId) - return PackageManagerAPI.abortExpectation(access, workId) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_MEDIA_MANAGEMENT) + + return PackageManagerAPI.abortExpectation(deviceId, workId) } ) } @@ -892,8 +916,9 @@ class ServerUserActionAPI check(deviceId, String) check(containerId, String) - const access = await PeripheralDeviceContentWriteAccess.executeFunction(this, deviceId) - return PackageManagerAPI.restartPackageContainer(access, containerId) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_MEDIA_MANAGEMENT) + + return PackageManagerAPI.restartPackageContainer(deviceId, containerId) } ) } @@ -922,7 +947,7 @@ class ServerUserActionAPI async () => { check(hashedToken, String) - await SystemWriteAccess.systemActions(this) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_SYSTEM_ACTION) if (!verifyHashedToken(hashedToken)) { throw new Meteor.Error(401, `Restart token is invalid or has expired`) @@ -958,8 +983,8 @@ class ServerUserActionAPI async () => { check(bucketId, String) - const access = await BucketSecurity.allowWriteAccess(this, bucketId) - return BucketsAPI.removeBucket(access) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_BUCKET_MODIFICATION) + return BucketsAPI.removeBucket(bucketId) } ) } @@ -979,8 +1004,8 @@ class ServerUserActionAPI check(bucketId, String) check(bucketProps, Object) - const access = await BucketSecurity.allowWriteAccess(this, bucketId) - return BucketsAPI.modifyBucket(access, bucketProps) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_BUCKET_MODIFICATION) + return BucketsAPI.modifyBucket(bucketId, bucketProps) } ) } @@ -994,8 +1019,8 @@ class ServerUserActionAPI async () => { check(bucketId, String) - const access = await BucketSecurity.allowWriteAccess(this, bucketId) - return BucketsAPI.emptyBucket(access) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_BUCKET_MODIFICATION) + return BucketsAPI.emptyBucket(bucketId) } ) } @@ -1010,8 +1035,8 @@ class ServerUserActionAPI check(studioId, String) check(name, String) - const access = await StudioContentWriteAccess.bucket(this, studioId) - return BucketsAPI.createNewBucket(access, name) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_BUCKET_MODIFICATION) + return BucketsAPI.createNewBucket(studioId, name) } ) } @@ -1025,8 +1050,8 @@ class ServerUserActionAPI 'bucketsRemoveBucketAdLib', { adlibId }, async () => { - const access = await BucketSecurity.allowWriteAccessPiece(this, adlibId) - return BucketsAPI.removeBucketAdLib(access) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_BUCKET_MODIFICATION) + return BucketsAPI.removeBucketAdLib(adlibId) } ) } @@ -1040,8 +1065,8 @@ class ServerUserActionAPI async () => { check(actionId, String) - const access = await BucketSecurity.allowWriteAccessAction(this, actionId) - return BucketsAPI.removeBucketAdLibAction(access) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_BUCKET_MODIFICATION) + return BucketsAPI.removeBucketAdLibAction(actionId) } ) } @@ -1061,8 +1086,8 @@ class ServerUserActionAPI check(adlibId, String) check(adlibProps, Object) - const access = await BucketSecurity.allowWriteAccessPiece(this, adlibId) - return BucketsAPI.modifyBucketAdLib(access, adlibProps) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_BUCKET_MODIFICATION) + return BucketsAPI.modifyBucketAdLib(adlibId, adlibProps) } ) } @@ -1082,8 +1107,8 @@ class ServerUserActionAPI check(actionId, String) check(actionProps, Object) - const access = await BucketSecurity.allowWriteAccessAction(this, actionId) - return BucketsAPI.modifyBucketAdLibAction(access, actionProps) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_BUCKET_MODIFICATION) + return BucketsAPI.modifyBucketAdLibAction(actionId, actionProps) } ) } @@ -1105,8 +1130,8 @@ class ServerUserActionAPI check(bucketId, String) check(action, Object) - const access = await BucketSecurity.allowWriteAccess(this, bucketId) - return BucketsAPI.saveAdLibActionIntoBucket(access, action) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_BUCKET_MODIFICATION) + return BucketsAPI.saveAdLibActionIntoBucket(bucketId, action) } ) } @@ -1121,15 +1146,16 @@ class ServerUserActionAPI this, userEvent, eventTime, - 'packageManagerRestartAllExpectations', + 'switchRouteSet', { studioId, routeSetId, state }, async () => { check(studioId, String) check(routeSetId, String) check(state, Match.OneOf('toggle', Boolean)) - const access = await StudioContentWriteAccess.routeSet(this, studioId) - return ServerPlayoutAPI.switchRouteSet(access, routeSetId, state) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_PLAYOUT_USERACTION) + + return ServerPlayoutAPI.switchRouteSet(studioId, routeSetId, state) } ) } @@ -1195,8 +1221,13 @@ class ServerUserActionAPI check(subDeviceId, String) check(disable, Boolean) - const access = await PeripheralDeviceContentWriteAccess.peripheralDevice(this, peripheralDeviceId) - return ServerPeripheralDeviceAPI.disableSubDevice(access, subDeviceId, disable) + assertConnectionHasOneOfPermissions( + this.connection, + ...PERMISSIONS_FOR_PLAYOUT_USERACTION, + ...PERMISSIONS_FOR_SYSTEM_ACTION + ) + + return ServerPeripheralDeviceAPI.disableSubDevice(peripheralDeviceId, subDeviceId, disable) } ) } @@ -1304,11 +1335,10 @@ class ServerUserActionAPI 'executeUserChangeOperation', { operationTarget, operation }, async () => { - const access = await RundownPlaylistContentWriteAccess.rundown(this, rundownId) - if (!access.rundown) throw new Error(`Rundown "${rundownId}" not found`) + const rundown = await checkAccessToRundown(this.connection, rundownId) - await runIngestOperation(access.rundown.studioId, IngestJobs.UserExecuteChangeOperation, { - rundownExternalId: access.rundown.externalId, + await runIngestOperation(rundown.studioId, IngestJobs.UserExecuteChangeOperation, { + rundownExternalId: rundown.externalId, operationTarget, operation, }) @@ -1333,7 +1363,8 @@ class ServerUserActionAPI check(studioId, String) check(showStyleVariantId, String) - // TODO - checkAccessToStudio? + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_PLAYOUT_USERACTION) + return runIngestOperation(studioId, IngestJobs.CreateAdlibTestingRundownForShowStyleVariant, { showStyleVariantId, }) diff --git a/meteor/server/collections/collection.ts b/meteor/server/collections/collection.ts index 5a81d597c2..700ceda53d 100644 --- a/meteor/server/collections/collection.ts +++ b/meteor/server/collections/collection.ts @@ -22,14 +22,14 @@ import { import { MinimalMongoCursor } from './implementations/asyncCollection' export interface MongoAllowRules { - insert?: (userId: UserId | null, doc: DBInterface) => Promise | boolean + // insert?: (userId: UserId | null, doc: DBInterface) => Promise | boolean update?: ( userId: UserId | null, doc: DBInterface, fieldNames: FieldNames, modifier: MongoModifier ) => Promise | boolean - remove?: (userId: UserId | null, doc: DBInterface) => Promise | boolean + // remove?: (userId: UserId | null, doc: DBInterface) => Promise | boolean } /** @@ -48,29 +48,6 @@ export function getOrCreateMongoCollection(name: string): Mongo.Collection return newCollection } -/** - * Wrap an existing Mongo.Collection to have async methods. Primarily to convert the built-in Users collection - * @param collection Collection to wrap - * @param name Name of the collection - * @param allowRules The 'allow' rules for publications. Set to `false` to make readonly - */ -export function wrapMongoCollection }>( - collection: Mongo.Collection, - name: CollectionName, - allowRules: MongoAllowRules | false -): AsyncOnlyMongoCollection { - if (collectionsCache.has(name)) throw new Meteor.Error(500, `Collection "${name}" has already been created`) - collectionsCache.set(name, collection) - - setupCollectionAllowRules(collection, allowRules) - - const wrapped = new WrappedAsyncMongoCollection(collection, name) - - registerCollection(name, wrapped as WrappedAsyncMongoCollection) - - return wrapped -} - /** * Create a fully featured MongoCollection * @param name Name of the collection in mongodb @@ -133,24 +110,16 @@ function setupCollectionAllowRules['allow']>[0]*/ = { - insert: () => false, - insertAsync: origInsert - ? (userId: string | null, doc: DBInterface) => origInsert(protectString(userId), doc) as any - : () => false, update: () => false, updateAsync: origUpdate ? (userId: string | null, doc: DBInterface, fieldNames: string[], modifier: any) => origUpdate(protectString(userId), doc, fieldNames as any, modifier) as any : () => false, - remove: () => false, - removeAsync: origRemove - ? (userId: string | null, doc: DBInterface) => origRemove(protectString(userId), doc) as any - : () => false, } collection.allow(options) diff --git a/meteor/server/collections/index.ts b/meteor/server/collections/index.ts index 303096db7b..a036f3f32b 100644 --- a/meteor/server/collections/index.ts +++ b/meteor/server/collections/index.ts @@ -24,35 +24,25 @@ import { DBTimelineDatastoreEntry } from '@sofie-automation/corelib/dist/dataMod import { TranslationsBundle } from '@sofie-automation/meteor-lib/dist/collections/TranslationsBundles' import { DBTriggeredActions } from '@sofie-automation/meteor-lib/dist/collections/TriggeredActions' import { UserActionsLogItem } from '@sofie-automation/meteor-lib/dist/collections/UserActionsLog' -import { DBUser } from '@sofie-automation/meteor-lib/dist/collections/Users' import { WorkerStatus } from '@sofie-automation/meteor-lib/dist/collections/Workers' import { registerIndex } from './indices' import { getCurrentTime } from '../lib/lib' import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' -import { - createAsyncOnlyMongoCollection, - createAsyncOnlyReadOnlyMongoCollection, - wrapMongoCollection, -} from './collection' +import { createAsyncOnlyMongoCollection, createAsyncOnlyReadOnlyMongoCollection } from './collection' import { ObserveChangesForHash } from './lib' import { logger } from '../logging' -import { resolveCredentials } from '../security/lib/credentials' -import { logNotAllowed, allowOnlyFields, rejectFields } from '../security/lib/lib' -import { - allowAccessToCoreSystem, - allowAccessToOrganization, - allowAccessToShowStyleBase, - allowAccessToStudio, -} from '../security/lib/security' -import { SystemWriteAccess } from '../security/system' +import { allowOnlyFields, rejectFields } from '../security/allowDeny' import type { DBNotificationObj } from '@sofie-automation/corelib/dist/dataModel/Notifications' +import { checkUserIdHasOneOfPermissions } from '../security/auth' export * from './bucket' export * from './packages-media' export * from './rundown' export const Blueprints = createAsyncOnlyMongoCollection(CollectionName.Blueprints, { - update(_userId, doc, fields, _modifier) { + update(userId, doc, fields, _modifier) { + if (!checkUserIdHasOneOfPermissions(userId, CollectionName.Blueprints, 'configure')) return false + return allowOnlyFields(doc, fields, ['name', 'disableVersionChecks']) }, }) @@ -62,9 +52,7 @@ registerIndex(Blueprints, { export const CoreSystem = createAsyncOnlyMongoCollection(CollectionName.CoreSystem, { async update(userId, doc, fields, _modifier) { - const cred = await resolveCredentials({ userId: userId }) - const access = await allowAccessToCoreSystem(cred) - if (!access.update) return logNotAllowed('CoreSystem', access.reason) + if (!checkUserIdHasOneOfPermissions(userId, CollectionName.CoreSystem, 'configure')) return false return allowOnlyFields(doc, fields, [ 'support', @@ -124,8 +112,8 @@ registerIndex(Notifications, { export const Organizations = createAsyncOnlyMongoCollection(CollectionName.Organizations, { async update(userId, doc, fields, _modifier) { - const access = await allowAccessToOrganization({ userId: userId }, doc._id) - if (!access.update) return logNotAllowed('Organization', access.reason) + if (!checkUserIdHasOneOfPermissions(userId, CollectionName.Organizations, 'configure')) return false + return allowOnlyFields(doc, fields, ['userRoles']) }, }) @@ -139,7 +127,9 @@ registerIndex(PeripheralDeviceCommands, { }) export const PeripheralDevices = createAsyncOnlyMongoCollection(CollectionName.PeripheralDevices, { - update(_userId, doc, fields, _modifier) { + update(userId, doc, fields, _modifier) { + if (!checkUserIdHasOneOfPermissions(userId, CollectionName.PeripheralDevices, 'configure')) return false + return rejectFields(doc, fields, [ 'type', 'parentDeviceId', @@ -168,8 +158,8 @@ registerIndex(PeripheralDevices, { export const RundownLayouts = createAsyncOnlyMongoCollection(CollectionName.RundownLayouts, { async update(userId, doc, fields) { - const access = await allowAccessToShowStyleBase({ userId: userId }, doc.showStyleBaseId) - if (!access.update) return logNotAllowed('ShowStyleBase', access.reason) + if (!checkUserIdHasOneOfPermissions(userId, CollectionName.RundownLayouts, 'configure')) return false + return rejectFields(doc, fields, ['_id', 'showStyleBaseId']) }, }) @@ -185,8 +175,8 @@ registerIndex(RundownLayouts, { export const ShowStyleBases = createAsyncOnlyMongoCollection(CollectionName.ShowStyleBases, { async update(userId, doc, fields) { - const access = await allowAccessToShowStyleBase({ userId: userId }, doc._id) - if (!access.update) return logNotAllowed('ShowStyleBase', access.reason) + if (!checkUserIdHasOneOfPermissions(userId, CollectionName.ShowStyleBases, 'configure')) return false + return rejectFields(doc, fields, ['_id']) }, }) @@ -196,8 +186,7 @@ registerIndex(ShowStyleBases, { export const ShowStyleVariants = createAsyncOnlyMongoCollection(CollectionName.ShowStyleVariants, { async update(userId, doc, fields) { - const access = await allowAccessToShowStyleBase({ userId: userId }, doc.showStyleBaseId) - if (!access.update) return logNotAllowed('ShowStyleBase', access.reason) + if (!checkUserIdHasOneOfPermissions(userId, CollectionName.ShowStyleVariants, 'configure')) return false return rejectFields(doc, fields, ['showStyleBaseId']) }, @@ -208,7 +197,9 @@ registerIndex(ShowStyleVariants, { }) export const Snapshots = createAsyncOnlyMongoCollection(CollectionName.Snapshots, { - update(_userId, doc, fields, _modifier) { + update(userId, doc, fields, _modifier) { + if (!checkUserIdHasOneOfPermissions(userId, CollectionName.Snapshots, 'configure')) return false + return allowOnlyFields(doc, fields, ['comment']) }, }) @@ -221,8 +212,8 @@ registerIndex(Snapshots, { export const Studios = createAsyncOnlyMongoCollection(CollectionName.Studios, { async update(userId, doc, fields, _modifier) { - const access = await allowAccessToStudio({ userId: userId }, doc._id) - if (!access.update) return logNotAllowed('Studio', access.reason) + if (!checkUserIdHasOneOfPermissions(userId, CollectionName.Studios, 'configure')) return false + return rejectFields(doc, fields, ['_id']) }, }) @@ -250,17 +241,9 @@ export const TranslationsBundles = createAsyncOnlyMongoCollection(CollectionName.TriggeredActions, { async update(userId, doc, fields) { - const cred = await resolveCredentials({ userId: userId }) - - if (doc.showStyleBaseId) { - const access = await allowAccessToShowStyleBase(cred, doc.showStyleBaseId) - if (!access.update) return logNotAllowed('ShowStyleBase', access.reason) - return rejectFields(doc, fields, ['_id']) - } else { - const access = await allowAccessToCoreSystem(cred) - if (!access.update) return logNotAllowed('CoreSystem', access.reason) - return rejectFields(doc, fields, ['_id']) - } + if (!checkUserIdHasOneOfPermissions(userId, CollectionName.TriggeredActions, 'configure')) return false + + return rejectFields(doc, fields, ['_id']) }, }) registerIndex(TriggeredActions, { @@ -276,26 +259,6 @@ registerIndex(UserActionsLog, { timelineHash: 1, }) -// This is a somewhat special collection, as it draws from the Meteor.users collection from the Accounts package -export const Users = wrapMongoCollection(Meteor.users as any, CollectionName.Users, { - async update(userId, doc, fields, _modifier) { - const access = await SystemWriteAccess.currentUser(userId, { userId }) - if (!access) return logNotAllowed('CurrentUser', '') - return rejectFields(doc, fields, [ - '_id', - 'createdAt', - 'services', - 'emails', - 'profile', - 'organizationId', - 'superAdmin', - ]) - }, -}) -registerIndex(Users, { - organizationId: 1, -}) - export const Workers = createAsyncOnlyMongoCollection(CollectionName.Workers, false) export const WorkerThreadStatuses = createAsyncOnlyMongoCollection( diff --git a/meteor/server/email.ts b/meteor/server/email.ts deleted file mode 100644 index 0dd0b2d795..0000000000 --- a/meteor/server/email.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Meteor } from 'meteor/meteor' -import { Accounts } from 'meteor/accounts-base' - -Meteor.startup(function () { - process.env.MAIL_URL = Meteor.settings.MAIL_URL - Accounts.urls.verifyEmail = function (token) { - return Meteor.absoluteUrl('login/verify-email/' + token) - } - Accounts.urls.resetPassword = function (token) { - return Meteor.absoluteUrl('reset/' + token) - } -}) diff --git a/meteor/server/lib/customPublication/__tests__/optimizedObserver.test.ts b/meteor/server/lib/customPublication/__tests__/optimizedObserver.test.ts index c77442a391..e2c4cafb1f 100644 --- a/meteor/server/lib/customPublication/__tests__/optimizedObserver.test.ts +++ b/meteor/server/lib/customPublication/__tests__/optimizedObserver.test.ts @@ -1,4 +1,3 @@ -import { UserId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { createManualPromise } from '@sofie-automation/corelib/dist/lib' import { ProtectedString } from '@sofie-automation/corelib/dist/protectedString' import { optimizedObserverCountSubscribers, setUpOptimizedObserverInner, TriggerUpdate } from '../optimizedObserverBase' @@ -20,9 +19,6 @@ class CustomPublishMock }> get isReady(): boolean { return false } - get userId(): UserId | null { - return null - } stop?: () => void diff --git a/meteor/server/lib/customPublication/publish.ts b/meteor/server/lib/customPublication/publish.ts index b9ac5fc402..fb8622b516 100644 --- a/meteor/server/lib/customPublication/publish.ts +++ b/meteor/server/lib/customPublication/publish.ts @@ -1,4 +1,3 @@ -import { UserId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { Meteor } from 'meteor/meteor' import { AllPubSubTypes } from '@sofie-automation/meteor-lib/dist/api/pubsub' import { ProtectedString, unprotectString } from '../tempLib' @@ -43,10 +42,6 @@ export class CustomPublishMeteor }> { return this.#isReady } - get userId(): UserId | null { - return this._meteorSubscription.userId - } - /** * Register a function to be called when the subscriber unsubscribes */ diff --git a/meteor/server/main.ts b/meteor/server/main.ts index 30c7678f5c..06cb00b026 100644 --- a/meteor/server/main.ts +++ b/meteor/server/main.ts @@ -46,7 +46,6 @@ import './api/rest/api' import './Connections' import './coreSystem' import './cronjobs' -import './email' import './prometheus' import './api/deviceTriggers/observer' import './logo' @@ -55,4 +54,4 @@ import './systemTime' // Setup publications and security: import './publications/_publications' -import './security/_security' +import './security/securityVerify' diff --git a/meteor/server/methods.ts b/meteor/server/methods.ts index 49bee70b1a..3a3c1da46b 100644 --- a/meteor/server/methods.ts +++ b/meteor/server/methods.ts @@ -4,8 +4,8 @@ import { logger } from './logging' import { extractFunctionSignature } from './lib' import { MethodContext, MethodContextAPI } from './api/methodContext' import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' -import { Settings } from './Settings' import { isPromise } from '@sofie-automation/shared-lib/dist/lib/lib' +import { assertConnectionHasOneOfPermissions } from './security/auth' type MeteorMethod = (this: MethodContext, ...args: any[]) => any @@ -142,25 +142,24 @@ function setMeteorMethods(orgMethods: MethodsInner, secret?: boolean): void { AllMeteorMethods.push(methodName) } }) - // @ts-expect-error: incompatible due to userId Meteor.methods(methods) } export type MeteorDebugMethod = (this: Meteor.MethodThisType, ...args: any[]) => Promise | any export function MeteorDebugMethods(methods: { [key: string]: MeteorDebugMethod }): void { - if (!Settings.enableUserAccounts) { - const fiberMethods: { [key: string]: (this: Meteor.MethodThisType, ...args: any[]) => any } = {} + const fiberMethods: { [key: string]: (this: Meteor.MethodThisType, ...args: any[]) => any } = {} - for (const [key, fn] of Object.entries(methods)) { - if (key && !!fn) { - fiberMethods[key] = function (this: Meteor.MethodThisType, ...args: any[]) { - return fn.call(this, ...args) - } + for (const [key, fn] of Object.entries(methods)) { + if (key && !!fn) { + fiberMethods[key] = function (this: Meteor.MethodThisType, ...args: any[]) { + assertConnectionHasOneOfPermissions(this.connection, 'developer') + + return fn.call(this, ...args) } } - - Meteor.methods(fiberMethods) } + + Meteor.methods(fiberMethods) } export function getRunningMethods(): RunningMethods { diff --git a/meteor/server/migration/api.ts b/meteor/server/migration/api.ts index fd4fac48e1..16d6cab3ab 100644 --- a/meteor/server/migration/api.ts +++ b/meteor/server/migration/api.ts @@ -9,7 +9,6 @@ import { import * as Migrations from './databaseMigration' import { MigrationStepInputResult } from '@sofie-automation/blueprints-integration' import { MethodContextAPI } from '../api/methodContext' -import { SystemWriteAccess } from '../security/system' import { fixupConfigForShowStyleBase, fixupConfigForStudio, @@ -22,10 +21,15 @@ import { } from './upgrades' import { ShowStyleBaseId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { BlueprintValidateConfigForStudioResult } from '@sofie-automation/corelib/dist/worker/studio' +import { assertConnectionHasOneOfPermissions } from '../security/auth' +import { UserPermissions } from '@sofie-automation/meteor-lib/dist/userPermissions' + +const PERMISSIONS_FOR_MIGRATIONS: Array = ['configure'] class ServerMigrationAPI extends MethodContextAPI implements NewMigrationAPI { async getMigrationStatus() { - await SystemWriteAccess.migrations(this) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_MIGRATIONS) + return Migrations.getMigrationStatus() } @@ -40,20 +44,21 @@ class ServerMigrationAPI extends MethodContextAPI implements NewMigrationAPI { check(inputResults, Array) check(isFirstOfPartialMigrations, Match.Maybe(Boolean)) - await SystemWriteAccess.migrations(this) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_MIGRATIONS) return Migrations.runMigration(chunks, hash, inputResults, isFirstOfPartialMigrations || false) } async forceMigration(chunks: Array) { check(chunks, Array) - await SystemWriteAccess.migrations(this) + + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_MIGRATIONS) return Migrations.forceMigration(chunks) } async resetDatabaseVersions() { - await SystemWriteAccess.migrations(this) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_MIGRATIONS) return Migrations.resetDatabaseVersions() } @@ -61,7 +66,7 @@ class ServerMigrationAPI extends MethodContextAPI implements NewMigrationAPI { async fixupConfigForStudio(studioId: StudioId): Promise { check(studioId, String) - await SystemWriteAccess.migrations(this) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_MIGRATIONS) return fixupConfigForStudio(studioId) } @@ -69,7 +74,7 @@ class ServerMigrationAPI extends MethodContextAPI implements NewMigrationAPI { async ignoreFixupConfigForStudio(studioId: StudioId): Promise { check(studioId, String) - await SystemWriteAccess.migrations(this) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_MIGRATIONS) return ignoreFixupConfigForStudio(studioId) } @@ -77,7 +82,7 @@ class ServerMigrationAPI extends MethodContextAPI implements NewMigrationAPI { async validateConfigForStudio(studioId: StudioId): Promise { check(studioId, String) - await SystemWriteAccess.migrations(this) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_MIGRATIONS) return validateConfigForStudio(studioId) } @@ -85,7 +90,7 @@ class ServerMigrationAPI extends MethodContextAPI implements NewMigrationAPI { async runUpgradeForStudio(studioId: StudioId): Promise { check(studioId, String) - await SystemWriteAccess.migrations(this) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_MIGRATIONS) return runUpgradeForStudio(studioId) } @@ -93,7 +98,7 @@ class ServerMigrationAPI extends MethodContextAPI implements NewMigrationAPI { async fixupConfigForShowStyleBase(showStyleBaseId: ShowStyleBaseId): Promise { check(showStyleBaseId, String) - await SystemWriteAccess.migrations(this) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_MIGRATIONS) return fixupConfigForShowStyleBase(showStyleBaseId) } @@ -101,7 +106,7 @@ class ServerMigrationAPI extends MethodContextAPI implements NewMigrationAPI { async ignoreFixupConfigForShowStyleBase(showStyleBaseId: ShowStyleBaseId): Promise { check(showStyleBaseId, String) - await SystemWriteAccess.migrations(this) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_MIGRATIONS) return ignoreFixupConfigForShowStyleBase(showStyleBaseId) } @@ -111,7 +116,7 @@ class ServerMigrationAPI extends MethodContextAPI implements NewMigrationAPI { ): Promise { check(showStyleBaseId, String) - await SystemWriteAccess.migrations(this) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_MIGRATIONS) return validateConfigForShowStyleBase(showStyleBaseId) } @@ -119,7 +124,7 @@ class ServerMigrationAPI extends MethodContextAPI implements NewMigrationAPI { async runUpgradeForShowStyleBase(showStyleBaseId: ShowStyleBaseId): Promise { check(showStyleBaseId, String) - await SystemWriteAccess.migrations(this) + assertConnectionHasOneOfPermissions(this.connection, ...PERMISSIONS_FOR_MIGRATIONS) return runUpgradeForShowStyleBase(showStyleBaseId) } diff --git a/meteor/server/publications/blueprintUpgradeStatus/publication.ts b/meteor/server/publications/blueprintUpgradeStatus/publication.ts index 568f9b0756..1bebd28c49 100644 --- a/meteor/server/publications/blueprintUpgradeStatus/publication.ts +++ b/meteor/server/publications/blueprintUpgradeStatus/publication.ts @@ -10,9 +10,6 @@ import { SetupObserversResult, TriggerUpdate, } from '../../lib/customPublication' -import { logger } from '../../logging' -import { resolveCredentials } from '../../security/lib/credentials' -import { NoSecurityReadAccess } from '../../security/noSecurity' import { ContentCache, createReactiveContentCache, ShowStyleBaseFields, StudioFields } from './reactiveContentCache' import { UpgradesContentObserver } from './upgradesContentObserver' import { BlueprintMapEntry, checkDocUpgradeStatus } from './checkStatus' @@ -23,6 +20,7 @@ import { UIBlueprintUpgradeStatus, UIBlueprintUpgradeStatusId, } from '@sofie-automation/meteor-lib/dist/api/upgradeStatus' +import { assertConnectionHasOneOfPermissions } from '../../security/auth' type BlueprintUpgradeStatusArgs = Record @@ -238,12 +236,8 @@ meteorCustomPublish( MeteorPubSub.uiBlueprintUpgradeStatuses, CustomCollectionName.UIBlueprintUpgradeStatuses, async function (pub) { - const cred = await resolveCredentials({ userId: this.userId, token: undefined }) + assertConnectionHasOneOfPermissions(this.connection, 'configure', 'service') - if (!cred || NoSecurityReadAccess.any()) { - await createBlueprintUpgradeStatusSubscriptionHandle(pub) - } else { - logger.warn(`Pub.${CustomCollectionName.UIBlueprintUpgradeStatuses}: Not allowed`) - } + await createBlueprintUpgradeStatusSubscriptionHandle(pub) } ) diff --git a/meteor/server/publications/buckets.ts b/meteor/server/publications/buckets.ts index 3801f8b467..589f63d3bb 100644 --- a/meteor/server/publications/buckets.ts +++ b/meteor/server/publications/buckets.ts @@ -1,14 +1,12 @@ import { FindOptions } from '@sofie-automation/meteor-lib/dist/collections/lib' -import { BucketSecurity } from '../security/buckets' import { meteorPublish } from './lib/lib' import { MeteorPubSub } from '@sofie-automation/meteor-lib/dist/api/pubsub' import { Bucket } from '@sofie-automation/meteor-lib/dist/collections/Buckets' -import { StudioReadAccess } from '../security/studio' -import { isProtectedString } from '@sofie-automation/corelib/dist/protectedString' import { BucketAdLibActions, BucketAdLibs, Buckets } from '../collections' import { check, Match } from 'meteor/check' import { StudioId, BucketId, ShowStyleVariantId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' +import { triggerWriteAccessBecauseNoCheckNecessary } from '../security/securityVerify' meteorPublish( MeteorPubSub.buckets, @@ -16,26 +14,23 @@ meteorPublish( check(studioId, String) check(bucketId, Match.Maybe(String)) + triggerWriteAccessBecauseNoCheckNecessary() + const modifier: FindOptions = { fields: {}, } - if ( - (await StudioReadAccess.studioContent(studioId, this)) || - (isProtectedString(bucketId) && bucketId && (await BucketSecurity.allowReadAccess(this, bucketId))) - ) { - return Buckets.findWithCursor( - bucketId - ? { - _id: bucketId, - studioId, - } - : { - studioId, - }, - modifier - ) - } - return null + + return Buckets.findWithCursor( + bucketId + ? { + _id: bucketId, + studioId, + } + : { + studioId, + }, + modifier + ) } ) @@ -46,23 +41,22 @@ meteorPublish( check(bucketId, String) check(showStyleVariantIds, Array) - if (isProtectedString(bucketId) && (await BucketSecurity.allowReadAccess(this, bucketId))) { - return BucketAdLibs.findWithCursor( - { - studioId: studioId, - bucketId: bucketId, - showStyleVariantId: { - $in: [null, ...showStyleVariantIds], // null = valid for all variants - }, + triggerWriteAccessBecauseNoCheckNecessary() + + return BucketAdLibs.findWithCursor( + { + studioId: studioId, + bucketId: bucketId, + showStyleVariantId: { + $in: [null, ...showStyleVariantIds], // null = valid for all variants }, - { - fields: { - ingestInfo: 0, // This is a large blob, and is not of interest to the UI - }, - } - ) - } - return null + }, + { + fields: { + ingestInfo: 0, // This is a large blob, and is not of interest to the UI + }, + } + ) } ) @@ -73,22 +67,21 @@ meteorPublish( check(bucketId, String) check(showStyleVariantIds, Array) - if (isProtectedString(bucketId) && (await BucketSecurity.allowReadAccess(this, bucketId))) { - return BucketAdLibActions.findWithCursor( - { - studioId: studioId, - bucketId: bucketId, - showStyleVariantId: { - $in: [null, ...showStyleVariantIds], // null = valid for all variants - }, + triggerWriteAccessBecauseNoCheckNecessary() + + return BucketAdLibActions.findWithCursor( + { + studioId: studioId, + bucketId: bucketId, + showStyleVariantId: { + $in: [null, ...showStyleVariantIds], // null = valid for all variants }, - { - fields: { - ingestInfo: 0, // This is a large blob, and is not of interest to the UI - }, - } - ) - } - return null + }, + { + fields: { + ingestInfo: 0, // This is a large blob, and is not of interest to the UI + }, + } + ) } ) diff --git a/meteor/server/publications/deviceTriggersPreview.ts b/meteor/server/publications/deviceTriggersPreview.ts index 67e9edbf03..c8352ba51f 100644 --- a/meteor/server/publications/deviceTriggersPreview.ts +++ b/meteor/server/publications/deviceTriggersPreview.ts @@ -9,8 +9,8 @@ import { DeviceTriggerArguments, UIDeviceTriggerPreview } from '@sofie-automatio import { getCurrentTime } from '../lib/lib' import { SetupObserversResult, setUpOptimizedObserverArray, TriggerUpdate } from '../lib/customPublication' import { CustomPublish, meteorCustomPublish } from '../lib/customPublication/publish' -import { StudioReadAccess } from '../security/studio' import { PeripheralDevices } from '../collections' +import { assertConnectionHasOneOfPermissions } from '../security/auth' /** IDEA: This could potentially be a Capped Collection, thus enabling scaling Core horizontally: * https://www.mongodb.com/docs/manual/core/capped-collections/ */ @@ -19,14 +19,12 @@ const lastTriggers: Record { - /** - * The id of the logged-in user, or `null` if no user is logged in. - * This is constant. However, if the logged-in user changes, the publish function - * is rerun with the new value, assuming it didn’t throw an error at the previous run. - */ - userId: UserId | null -} +export type SubscriptionContext = Omit /** * Unsafe wrapper around Meteor.publish @@ -80,90 +61,6 @@ export function meteorPublish( meteorPublishUnsafe(name, callback) } -export namespace AutoFillSelector { - /** Autofill an empty selector {} with organizationId of the current user */ - export async function organizationId( - userId: UserId | null, - selector: MongoQuery, - token: string | undefined - ): Promise<{ - cred: ResolvedCredentials | null - selector: MongoQuery - }> { - if (!selector) throw new Meteor.Error(400, 'selector argument missing') - - let cred: ResolvedCredentials | null = null - if (Settings.enableUserAccounts) { - if (!selector.organizationId) { - cred = await resolveCredentials({ userId: userId, token }) - if (cred.organizationId) selector.organizationId = cred.organizationId as any - // TODO - should this block all access if cred.organizationId is not set - } - } - return { cred, selector } - } - /** Autofill an empty selector {} with deviceId of the current user's peripheralDevices */ - export async function deviceId( - userId: UserId | null, - selector: MongoQuery, - token: string | undefined - ): Promise<{ - cred: ResolvedCredentials | null - selector: MongoQuery - }> { - if (!selector) throw new Meteor.Error(400, 'selector argument missing') - - let cred: ResolvedCredentials | null = null - if (Settings.enableUserAccounts) { - if (!selector.deviceId) { - cred = await resolveCredentials({ userId: userId, token }) - if (cred.organizationId) { - const devices = (await PeripheralDevices.findFetchAsync( - { - organizationId: cred.organizationId, - }, - { projection: { _id: 1 } } - )) as Array> - - selector.deviceId = { $in: devices.map((d) => d._id) } as any - } - // TODO - should this block all access if cred.organizationId is not set - } - } - return { cred, selector } - } - /** Autofill an empty selector {} with showStyleBaseId of the current user's showStyleBases */ - export async function showStyleBaseId( - userId: UserId | null, - selector: MongoQuery, - token: string | undefined - ): Promise<{ - cred: ResolvedCredentials | null - selector: MongoQuery - }> { - if (!selector) throw new Meteor.Error(400, 'selector argument missing') - - let cred: ResolvedCredentials | null = null - if (Settings.enableUserAccounts) { - if (!selector.showStyleBaseId) { - cred = await resolveCredentials({ userId: userId, token }) - if (cred.organizationId) { - const showStyleBases = (await ShowStyleBases.findFetchAsync( - { - organizationId: cred.organizationId, - }, - { projection: { _id: 1 } } - )) as Array> - - selector.showStyleBaseId = { $in: showStyleBases.map((d) => d._id) } as any - } - // TODO - should this block all access if cred.organizationId is not set - } - } - return { cred, selector } - } -} - /** * Await each observer, and return the handles * If an observer throws, this will make sure to stop all the ones that were successfully started, to avoid leaking memory diff --git a/meteor/server/publications/mountedTriggers.ts b/meteor/server/publications/mountedTriggers.ts index 9976677de6..13c520221b 100644 --- a/meteor/server/publications/mountedTriggers.ts +++ b/meteor/server/publications/mountedTriggers.ts @@ -1,19 +1,18 @@ import { Meteor } from 'meteor/meteor' import { CustomPublish, meteorCustomPublish } from '../lib/customPublication' import { PeripheralDeviceId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { PeripheralDeviceReadAccess } from '../security/peripheralDevice' import { logger } from '../logging' import { DeviceTriggerMountedActionAdlibsPreview, DeviceTriggerMountedActions } from '../api/deviceTriggers/observer' import { Mongo } from 'meteor/mongo' import { ProtectedString } from '@sofie-automation/corelib/dist/protectedString' import _ from 'underscore' -import { PeripheralDevices } from '../collections' import { check } from 'meteor/check' import { PeripheralDevicePubSub, PeripheralDevicePubSubCollectionsNames, } from '@sofie-automation/shared-lib/dist/pubsub/peripheralDevice' import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' +import { checkAccessAndGetPeripheralDevice } from '../security/check' const PUBLICATION_DEBOUNCE = 20 @@ -24,26 +23,20 @@ meteorCustomPublish( check(deviceId, String) check(deviceIds, [String]) - if (await PeripheralDeviceReadAccess.peripheralDeviceContent(deviceId, { userId: this.userId, token })) { - const peripheralDevice = await PeripheralDevices.findOneAsync(deviceId) - - if (!peripheralDevice) throw new Meteor.Error(404, `PeripheralDevice "${deviceId}" not found`) - - const studioId = peripheralDevice.studioId - if (!studioId) throw new Meteor.Error(400, `Peripheral Device "${deviceId}" not attached to a studio`) - - cursorCustomPublish( - pub, - DeviceTriggerMountedActions.find({ - studioId, - deviceId: { - $in: deviceIds, - }, - }) - ) - } else { - logger.warn(`Pub.mountedTriggersForDevice: Not allowed: "${deviceId}"`) - } + const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, token, this) + + const studioId = peripheralDevice.studioId + if (!studioId) throw new Meteor.Error(400, `Peripheral Device "${deviceId}" not attached to a studio`) + + cursorCustomPublish( + pub, + DeviceTriggerMountedActions.find({ + studioId, + deviceId: { + $in: deviceIds, + }, + }) + ) } ) @@ -53,23 +46,17 @@ meteorCustomPublish( async function (pub, deviceId: PeripheralDeviceId, token: string | undefined) { check(deviceId, String) - if (await PeripheralDeviceReadAccess.peripheralDeviceContent(deviceId, { userId: this.userId, token })) { - const peripheralDevice = await PeripheralDevices.findOneAsync(deviceId) + const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, token, this) - if (!peripheralDevice) throw new Meteor.Error(404, `PeripheralDevice "${deviceId}" not found`) + const studioId = peripheralDevice.studioId + if (!studioId) throw new Meteor.Error(400, `Peripheral Device "${deviceId}" not attached to a studio`) - const studioId = peripheralDevice.studioId - if (!studioId) throw new Meteor.Error(400, `Peripheral Device "${deviceId}" not attached to a studio`) - - cursorCustomPublish( - pub, - DeviceTriggerMountedActionAdlibsPreview.find({ - studioId, - }) - ) - } else { - logger.warn(`Pub.mountedTriggersForDevicePreview: Not allowed: "${deviceId}"`) - } + cursorCustomPublish( + pub, + DeviceTriggerMountedActionAdlibsPreview.find({ + studioId, + }) + ) } ) diff --git a/meteor/server/publications/organization.ts b/meteor/server/publications/organization.ts index f596d8b3c6..489f3edc46 100644 --- a/meteor/server/publications/organization.ts +++ b/meteor/server/publications/organization.ts @@ -1,26 +1,29 @@ -import { meteorPublish, AutoFillSelector } from './lib/lib' +import { meteorPublish } from './lib/lib' import { MeteorPubSub } from '@sofie-automation/meteor-lib/dist/api/pubsub' import { Blueprint } from '@sofie-automation/corelib/dist/dataModel/Blueprint' import { Evaluation } from '@sofie-automation/meteor-lib/dist/collections/Evaluations' import { SnapshotItem } from '@sofie-automation/meteor-lib/dist/collections/Snapshots' import { UserActionsLogItem } from '@sofie-automation/meteor-lib/dist/collections/UserActionsLog' -import { OrganizationReadAccess } from '../security/organization' import { FindOptions } from '@sofie-automation/meteor-lib/dist/collections/lib' import { DBOrganization } from '@sofie-automation/meteor-lib/dist/collections/Organization' -import { isProtectedString } from '@sofie-automation/corelib/dist/protectedString' import { Blueprints, Evaluations, Organizations, Snapshots, UserActionsLog } from '../collections' import { MongoQuery } from '@sofie-automation/corelib/dist/mongo' import { BlueprintId, OrganizationId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { check, Match } from '../lib/check' import { getCurrentTime } from '../lib/lib' +import { triggerWriteAccessBecauseNoCheckNecessary } from '../security/securityVerify' +import { assertConnectionHasOneOfPermissions } from '../security/auth' meteorPublish( MeteorPubSub.organization, - async function (organizationId: OrganizationId | null, token: string | undefined) { + async function (organizationId: OrganizationId | null, _token: string | undefined) { + triggerWriteAccessBecauseNoCheckNecessary() + if (!organizationId) return null - const { cred, selector } = await AutoFillSelector.organizationId(this.userId, { _id: organizationId }, token) + const selector: MongoQuery = { _id: organizationId } + const modifier: FindOptions = { fields: { name: 1, @@ -29,83 +32,69 @@ meteorPublish( userRoles: 1, // to not expose too much information consider [`userRoles.${this.userId}`]: 1, and a method/publication for getting all the roles, or limiting the returned roles based on requesting user's role }, } - if ( - isProtectedString(selector.organizationId) && - (!cred || (await OrganizationReadAccess.organizationContent(selector.organizationId, cred))) - ) { - return Organizations.findWithCursor({ _id: selector.organizationId }, modifier) - } - return null + + return Organizations.findWithCursor({ _id: selector.organizationId }, modifier) } ) -meteorPublish(CorelibPubSub.blueprints, async function (blueprintIds: BlueprintId[] | null, token: string | undefined) { - check(blueprintIds, Match.Maybe(Array)) +meteorPublish( + CorelibPubSub.blueprints, + async function (blueprintIds: BlueprintId[] | null, _token: string | undefined) { + assertConnectionHasOneOfPermissions(this.connection, 'configure') - // If values were provided, they must have values - if (blueprintIds && blueprintIds.length === 0) return null + check(blueprintIds, Match.Maybe(Array)) - const { cred, selector } = await AutoFillSelector.organizationId(this.userId, {}, token) + // If values were provided, they must have values + if (blueprintIds && blueprintIds.length === 0) return null - // Add the requested filter - if (blueprintIds) selector._id = { $in: blueprintIds } + // Add the requested filter + const selector: MongoQuery = {} + if (blueprintIds) selector._id = { $in: blueprintIds } - if (!cred || (await OrganizationReadAccess.organizationContent(selector.organizationId, cred))) { return Blueprints.findWithCursor(selector, { fields: { code: 0, }, }) } - return null -}) -meteorPublish(MeteorPubSub.evaluations, async function (dateFrom: number, dateTo: number, token: string | undefined) { - const selector0: MongoQuery = { +) +meteorPublish(MeteorPubSub.evaluations, async function (dateFrom: number, dateTo: number, _token: string | undefined) { + triggerWriteAccessBecauseNoCheckNecessary() + + const selector: MongoQuery = { timestamp: { $gte: dateFrom, $lt: dateTo, }, } - const { cred, selector } = await AutoFillSelector.organizationId(this.userId, selector0, token) - if (!cred || (await OrganizationReadAccess.organizationContent(selector.organizationId, cred))) { - return Evaluations.findWithCursor(selector) - } - return null + return Evaluations.findWithCursor(selector) }) -meteorPublish(MeteorPubSub.snapshots, async function (token: string | undefined) { - const selector0: MongoQuery = { +meteorPublish(MeteorPubSub.snapshots, async function (_token: string | undefined) { + assertConnectionHasOneOfPermissions(this.connection, 'configure') + + const selector: MongoQuery = { created: { $gt: getCurrentTime() - 30 * 24 * 3600 * 1000, // last 30 days }, } - const { cred, selector } = await AutoFillSelector.organizationId(this.userId, selector0, token) - if (!cred || (await OrganizationReadAccess.organizationContent(selector.organizationId, cred))) { - return Snapshots.findWithCursor(selector) - } - return null + return Snapshots.findWithCursor(selector) }) meteorPublish( MeteorPubSub.userActionsLog, - async function (dateFrom: number, dateTo: number, token: string | undefined) { - const selector0: MongoQuery = { + async function (dateFrom: number, dateTo: number, _token: string | undefined) { + triggerWriteAccessBecauseNoCheckNecessary() + + const selector: MongoQuery = { timestamp: { $gte: dateFrom, $lt: dateTo, }, } - const { cred, selector } = await AutoFillSelector.organizationId( - this.userId, - selector0, - token - ) - if (!cred || (await OrganizationReadAccess.organizationContent(selector.organizationId, cred))) { - return UserActionsLog.findWithCursor(selector, { - limit: 10_000, // this is to prevent having a publication that produces a very large array - }) - } - return null + return UserActionsLog.findWithCursor(selector, { + limit: 10_000, // this is to prevent having a publication that produces a very large array + }) } ) diff --git a/meteor/server/publications/packageManager/expectedPackages/publication.ts b/meteor/server/publications/packageManager/expectedPackages/publication.ts index 1952fb7057..66ee316ae7 100644 --- a/meteor/server/publications/packageManager/expectedPackages/publication.ts +++ b/meteor/server/publications/packageManager/expectedPackages/publication.ts @@ -1,5 +1,3 @@ -import { Meteor } from 'meteor/meteor' -import { PeripheralDeviceReadAccess } from '../../../security/peripheralDevice' import { DBStudio, StudioPackageContainer } from '@sofie-automation/corelib/dist/dataModel/Studio' import { TriggerUpdate, @@ -19,7 +17,7 @@ import { PieceInstanceId, StudioId, } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { PeripheralDevices, Studios } from '../../../collections' +import { Studios } from '../../../collections' import { check, Match } from 'meteor/check' import { PackageManagerExpectedPackage } from '@sofie-automation/shared-lib/dist/package-manager/publications' import { ExpectedPackagesContentObserver } from './contentObserver' @@ -30,6 +28,7 @@ import { PeripheralDevicePubSub, PeripheralDevicePubSubCollectionsNames, } from '@sofie-automation/shared-lib/dist/pubsub/peripheralDevice' +import { checkAccessAndGetPeripheralDevice } from '../../../security/check' interface ExpectedPackagesPublicationArgs { readonly studioId: StudioId @@ -206,34 +205,28 @@ meteorCustomPublish( check(deviceId, String) check(filterPlayoutDeviceIds, Match.Maybe([String])) - if (await PeripheralDeviceReadAccess.peripheralDeviceContent(deviceId, { userId: this.userId, token })) { - const peripheralDevice = await PeripheralDevices.findOneAsync(deviceId) + const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, token, this) - if (!peripheralDevice) throw new Meteor.Error('PeripheralDevice "' + deviceId + '" not found') - - const studioId = peripheralDevice.studioId - if (!studioId) { - logger.warn(`Pub.packageManagerExpectedPackages: device "${peripheralDevice._id}" has no studioId`) - return this.ready() - } - - await setUpCollectionOptimizedObserver< - PackageManagerExpectedPackage, - ExpectedPackagesPublicationArgs, - ExpectedPackagesPublicationState, - ExpectedPackagesPublicationUpdateProps - >( - `${PeripheralDevicePubSub.packageManagerExpectedPackages}_${studioId}_${deviceId}_${JSON.stringify( - (filterPlayoutDeviceIds || []).sort() - )}`, - { studioId, deviceId, filterPlayoutDeviceIds }, - setupExpectedPackagesPublicationObservers, - manipulateExpectedPackagesPublicationData, - pub, - 500 // ms, wait this time before sending an update - ) - } else { - logger.warn(`Pub.packageManagerExpectedPackages: Not allowed: "${deviceId}"`) + const studioId = peripheralDevice.studioId + if (!studioId) { + logger.warn(`Pub.packageManagerExpectedPackages: device "${peripheralDevice._id}" has no studioId`) + return this.ready() } + + await setUpCollectionOptimizedObserver< + PackageManagerExpectedPackage, + ExpectedPackagesPublicationArgs, + ExpectedPackagesPublicationState, + ExpectedPackagesPublicationUpdateProps + >( + `${PeripheralDevicePubSub.packageManagerExpectedPackages}_${studioId}_${deviceId}_${JSON.stringify( + (filterPlayoutDeviceIds || []).sort() + )}`, + { studioId, deviceId, filterPlayoutDeviceIds }, + setupExpectedPackagesPublicationObservers, + manipulateExpectedPackagesPublicationData, + pub, + 500 // ms, wait this time before sending an update + ) } ) diff --git a/meteor/server/publications/packageManager/packageContainers.ts b/meteor/server/publications/packageManager/packageContainers.ts index 0accf66181..133569a882 100644 --- a/meteor/server/publications/packageManager/packageContainers.ts +++ b/meteor/server/publications/packageManager/packageContainers.ts @@ -5,9 +5,8 @@ import { MongoFieldSpecifierOnesStrict } from '@sofie-automation/corelib/dist/mo import { PackageContainer } from '@sofie-automation/shared-lib/dist/package-manager/package' import { PackageManagerPackageContainers } from '@sofie-automation/shared-lib/dist/package-manager/publications' import { check } from 'meteor/check' -import { Meteor } from 'meteor/meteor' import { ReadonlyDeep } from 'type-fest' -import { PeripheralDevices, Studios } from '../../collections' +import { Studios } from '../../collections' import { meteorCustomPublish, SetupObserversResult, @@ -15,12 +14,12 @@ import { TriggerUpdate, } from '../../lib/customPublication' import { logger } from '../../logging' -import { PeripheralDeviceReadAccess } from '../../security/peripheralDevice' import { PeripheralDevicePubSub, PeripheralDevicePubSubCollectionsNames, } from '@sofie-automation/shared-lib/dist/pubsub/peripheralDevice' import { applyAndValidateOverrides } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' +import { checkAccessAndGetPeripheralDevice } from '../../security/check' type StudioFields = '_id' | 'packageContainersWithOverrides' const studioFieldSpecifier = literal>>({ @@ -96,32 +95,26 @@ meteorCustomPublish( async function (pub, deviceId: PeripheralDeviceId, token: string | undefined) { check(deviceId, String) - if (await PeripheralDeviceReadAccess.peripheralDeviceContent(deviceId, { userId: this.userId, token })) { - const peripheralDevice = await PeripheralDevices.findOneAsync(deviceId) + const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, token, this) - if (!peripheralDevice) throw new Meteor.Error('PeripheralDevice "' + deviceId + '" not found') - - const studioId = peripheralDevice.studioId - if (!studioId) { - logger.warn(`Pub.packageManagerPackageContainers: device "${peripheralDevice._id}" has no studioId`) - return this.ready() - } - - await setUpOptimizedObserverArray< - PackageManagerPackageContainers, - PackageManagerPackageContainersArgs, - PackageManagerPackageContainersState, - PackageManagerPackageContainersUpdateProps - >( - `${PeripheralDevicePubSub.packageManagerPackageContainers}_${studioId}_${deviceId}`, - { studioId, deviceId }, - setupExpectedPackagesPublicationObservers, - manipulateExpectedPackagesPublicationData, - pub, - 500 // ms, wait this time before sending an update - ) - } else { - logger.warn(`Pub.packageManagerPackageContainers: Not allowed: "${deviceId}"`) + const studioId = peripheralDevice.studioId + if (!studioId) { + logger.warn(`Pub.packageManagerPackageContainers: device "${peripheralDevice._id}" has no studioId`) + return this.ready() } + + await setUpOptimizedObserverArray< + PackageManagerPackageContainers, + PackageManagerPackageContainersArgs, + PackageManagerPackageContainersState, + PackageManagerPackageContainersUpdateProps + >( + `${PeripheralDevicePubSub.packageManagerPackageContainers}_${studioId}_${deviceId}`, + { studioId, deviceId }, + setupExpectedPackagesPublicationObservers, + manipulateExpectedPackagesPublicationData, + pub, + 500 // ms, wait this time before sending an update + ) } ) diff --git a/meteor/server/publications/packageManager/playoutContext.ts b/meteor/server/publications/packageManager/playoutContext.ts index 08c881fafe..70b55955ca 100644 --- a/meteor/server/publications/packageManager/playoutContext.ts +++ b/meteor/server/publications/packageManager/playoutContext.ts @@ -5,9 +5,8 @@ import { literal } from '@sofie-automation/corelib/dist/lib' import { MongoFieldSpecifierOnesStrict } from '@sofie-automation/corelib/dist/mongo' import { PackageManagerPlayoutContext } from '@sofie-automation/shared-lib/dist/package-manager/publications' import { check } from 'meteor/check' -import { Meteor } from 'meteor/meteor' import { ReadonlyDeep } from 'type-fest' -import { PeripheralDevices, RundownPlaylists, Rundowns } from '../../collections' +import { RundownPlaylists, Rundowns } from '../../collections' import { meteorCustomPublish, SetupObserversResult, @@ -15,11 +14,11 @@ import { TriggerUpdate, } from '../../lib/customPublication' import { logger } from '../../logging' -import { PeripheralDeviceReadAccess } from '../../security/peripheralDevice' import { PeripheralDevicePubSub, PeripheralDevicePubSubCollectionsNames, } from '@sofie-automation/shared-lib/dist/pubsub/peripheralDevice' +import { checkAccessAndGetPeripheralDevice } from '../../security/check' export type RundownPlaylistCompact = Pick const rundownPlaylistFieldSpecifier = literal>({ @@ -114,32 +113,26 @@ meteorCustomPublish( async function (pub, deviceId: PeripheralDeviceId, token: string | undefined) { check(deviceId, String) - if (await PeripheralDeviceReadAccess.peripheralDeviceContent(deviceId, { userId: this.userId, token })) { - const peripheralDevice = await PeripheralDevices.findOneAsync(deviceId) + const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, token, this) - if (!peripheralDevice) throw new Meteor.Error('PeripheralDevice "' + deviceId + '" not found') - - const studioId = peripheralDevice.studioId - if (!studioId) { - logger.warn(`Pub.packageManagerPlayoutContext: device "${peripheralDevice._id}" has no studioId`) - return this.ready() - } - - await setUpOptimizedObserverArray< - PackageManagerPlayoutContext, - PackageManagerPlayoutContextArgs, - PackageManagerPlayoutContextState, - PackageManagerPlayoutContextUpdateProps - >( - `${PeripheralDevicePubSub.packageManagerPlayoutContext}_${studioId}_${deviceId}`, - { studioId, deviceId }, - setupExpectedPackagesPublicationObservers, - manipulateExpectedPackagesPublicationData, - pub, - 500 // ms, wait this time before sending an update - ) - } else { - logger.warn(`Pub.packageManagerPlayoutContext: Not allowed: "${deviceId}"`) + const studioId = peripheralDevice.studioId + if (!studioId) { + logger.warn(`Pub.packageManagerPlayoutContext: device "${peripheralDevice._id}" has no studioId`) + return this.ready() } + + await setUpOptimizedObserverArray< + PackageManagerPlayoutContext, + PackageManagerPlayoutContextArgs, + PackageManagerPlayoutContextState, + PackageManagerPlayoutContextUpdateProps + >( + `${PeripheralDevicePubSub.packageManagerPlayoutContext}_${studioId}_${deviceId}`, + { studioId, deviceId }, + setupExpectedPackagesPublicationObservers, + manipulateExpectedPackagesPublicationData, + pub, + 500 // ms, wait this time before sending an update + ) } ) diff --git a/meteor/server/publications/partInstancesUI/publication.ts b/meteor/server/publications/partInstancesUI/publication.ts index 01a21711f2..f679a77c97 100644 --- a/meteor/server/publications/partInstancesUI/publication.ts +++ b/meteor/server/publications/partInstancesUI/publication.ts @@ -9,8 +9,6 @@ import { } from '../../lib/customPublication' import { logger } from '../../logging' import { CustomCollectionName, MeteorPubSub } from '@sofie-automation/meteor-lib/dist/api/pubsub' -import { resolveCredentials } from '../../security/lib/credentials' -import { NoSecurityReadAccess } from '../../security/noSecurity' import { ContentCache, PartInstanceOmitedFields, createReactiveContentCache } from './reactiveContentCache' import { ReadonlyDeep } from 'type-fest' import { RundownPlaylists } from '../../collections' @@ -28,6 +26,7 @@ import { modifyPartInstanceForQuickLoop, stringsToIndexLookup, } from '../lib/quickLoop' +import { triggerWriteAccessBecauseNoCheckNecessary } from '../../security/securityVerify' interface UIPartInstancesArgs { readonly playlistActivationId: RundownPlaylistActivationId @@ -206,23 +205,24 @@ meteorCustomPublish( async function (pub, playlistActivationId: RundownPlaylistActivationId | null) { check(playlistActivationId, Match.Maybe(String)) - const credentials = await resolveCredentials({ userId: this.userId, token: undefined }) - - if (playlistActivationId && (!credentials || NoSecurityReadAccess.any())) { - await setUpCollectionOptimizedObserver< - Omit, - UIPartInstancesArgs, - UIPartInstancesState, - UIPartInstancesUpdateProps - >( - `pub_${MeteorPubSub.uiPartInstances}_${playlistActivationId}`, - { playlistActivationId }, - setupUIPartInstancesPublicationObservers, - manipulateUIPartInstancesPublicationData, - pub - ) - } else { - logger.warn(`Pub.uiPartInstances: Not allowed:"${playlistActivationId}"`) + triggerWriteAccessBecauseNoCheckNecessary() + + if (!playlistActivationId) { + logger.info(`Pub.${CustomCollectionName.UISegmentPartNotes}: Not playlistActivationId`) + return } + + await setUpCollectionOptimizedObserver< + Omit, + UIPartInstancesArgs, + UIPartInstancesState, + UIPartInstancesUpdateProps + >( + `pub_${MeteorPubSub.uiPartInstances}_${playlistActivationId}`, + { playlistActivationId }, + setupUIPartInstancesPublicationObservers, + manipulateUIPartInstancesPublicationData, + pub + ) } ) diff --git a/meteor/server/publications/partsUI/publication.ts b/meteor/server/publications/partsUI/publication.ts index 31af1ed031..69bfc890be 100644 --- a/meteor/server/publications/partsUI/publication.ts +++ b/meteor/server/publications/partsUI/publication.ts @@ -9,9 +9,6 @@ import { } from '../../lib/customPublication' import { logger } from '../../logging' import { CustomCollectionName, MeteorPubSub } from '@sofie-automation/meteor-lib/dist/api/pubsub' -import { RundownPlaylistReadAccess } from '../../security/rundownPlaylist' -import { resolveCredentials } from '../../security/lib/credentials' -import { NoSecurityReadAccess } from '../../security/noSecurity' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import { ContentCache, PartOmitedFields, createReactiveContentCache } from './reactiveContentCache' import { ReadonlyDeep } from 'type-fest' @@ -23,6 +20,7 @@ import { RundownsObserver } from '../lib/rundownsObserver' import { RundownContentObserver } from './rundownContentObserver' import { protectString } from '@sofie-automation/corelib/dist/protectedString' import { extractRanks, findMarkerPosition, modifyPartForQuickLoop, stringsToIndexLookup } from '../lib/quickLoop' +import { triggerWriteAccessBecauseNoCheckNecessary } from '../../security/securityVerify' interface UIPartsArgs { readonly playlistId: RundownPlaylistId @@ -193,27 +191,24 @@ meteorCustomPublish( async function (pub, playlistId: RundownPlaylistId | null) { check(playlistId, String) - const credentials = await resolveCredentials({ userId: this.userId, token: undefined }) + triggerWriteAccessBecauseNoCheckNecessary() - if ( - !credentials || - NoSecurityReadAccess.any() || - (playlistId && (await RundownPlaylistReadAccess.rundownPlaylistContent(playlistId, credentials))) - ) { - await setUpCollectionOptimizedObserver< - Omit, - UIPartsArgs, - UIPartsState, - UIPartsUpdateProps - >( - `pub_${MeteorPubSub.uiParts}_${playlistId}`, - { playlistId }, - setupUIPartsPublicationObservers, - manipulateUIPartsPublicationData, - pub - ) - } else { + if (!playlistId) { logger.warn(`Pub.uiParts: Not allowed: "${playlistId}"`) + return } + + await setUpCollectionOptimizedObserver< + Omit, + UIPartsArgs, + UIPartsState, + UIPartsUpdateProps + >( + `pub_${MeteorPubSub.uiParts}_${playlistId}`, + { playlistId }, + setupUIPartsPublicationObservers, + manipulateUIPartsPublicationData, + pub + ) } ) diff --git a/meteor/server/publications/peripheralDevice.ts b/meteor/server/publications/peripheralDevice.ts index 1ead8e0e6d..a6add93fdc 100644 --- a/meteor/server/publications/peripheralDevice.ts +++ b/meteor/server/publications/peripheralDevice.ts @@ -1,38 +1,20 @@ -import { Meteor } from 'meteor/meteor' import { check, Match } from '../lib/check' -import { meteorPublish, AutoFillSelector } from './lib/lib' +import { meteorPublish } from './lib/lib' import { MeteorPubSub } from '@sofie-automation/meteor-lib/dist/api/pubsub' -import { PeripheralDeviceReadAccess } from '../security/peripheralDevice' import { PeripheralDevice } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' -import { OrganizationReadAccess } from '../security/organization' -import { StudioReadAccess } from '../security/studio' import { MongoFieldSpecifierZeroes, MongoQuery } from '@sofie-automation/corelib/dist/mongo' -import { Credentials, ResolvedCredentials } from '../security/lib/credentials' -import { NoSecurityReadAccess } from '../security/noSecurity' import { PeripheralDeviceId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { MediaWorkFlows, MediaWorkFlowSteps, PeripheralDeviceCommands, PeripheralDevices } from '../collections' -import { MediaWorkFlow } from '@sofie-automation/shared-lib/dist/core/model/MediaWorkFlows' -import { MediaWorkFlowStep } from '@sofie-automation/shared-lib/dist/core/model/MediaWorkFlowSteps' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { PeripheralDevicePubSub } from '@sofie-automation/shared-lib/dist/pubsub/peripheralDevice' import { clone } from '@sofie-automation/corelib/dist/lib' +import { triggerWriteAccessBecauseNoCheckNecessary } from '../security/securityVerify' +import { checkAccessAndGetPeripheralDevice } from '../security/check' /* * This file contains publications for the peripheralDevices, such as playout-gateway, mos-gateway and package-manager */ -async function checkAccess(cred: Credentials | ResolvedCredentials | null, selector: MongoQuery) { - if (!selector) throw new Meteor.Error(400, 'selector argument missing') - return ( - !cred || - NoSecurityReadAccess.any() || - (selector._id && (await PeripheralDeviceReadAccess.peripheralDevice(selector._id, cred))) || - (selector.organizationId && - (await OrganizationReadAccess.organizationContent(selector.organizationId, cred))) || - (selector.studioId && (await StudioReadAccess.studioContent(selector.studioId, cred))) - ) -} - const peripheralDeviceFields: MongoFieldSpecifierZeroes = { token: 0, secretSettings: 0, @@ -43,78 +25,67 @@ meteorPublish( async function (peripheralDeviceIds: PeripheralDeviceId[] | null, token: string | undefined) { check(peripheralDeviceIds, Match.Maybe(Array)) + triggerWriteAccessBecauseNoCheckNecessary() + // If values were provided, they must have values if (peripheralDeviceIds && peripheralDeviceIds.length === 0) return null - const { cred, selector } = await AutoFillSelector.organizationId(this.userId, {}, token) - // Add the requested filter + const selector: MongoQuery = {} if (peripheralDeviceIds) selector._id = { $in: peripheralDeviceIds } - if (await checkAccess(cred, selector)) { - const fields = clone(peripheralDeviceFields) - if (selector._id && token) { - // in this case, send the secretSettings: - delete fields.secretSettings - } - return PeripheralDevices.findWithCursor(selector, { - fields, - }) + const fields = clone(peripheralDeviceFields) + if (selector._id && token) { + // in this case, send the secretSettings: + delete fields.secretSettings } - return null + return PeripheralDevices.findWithCursor(selector, { + fields, + }) } ) meteorPublish(CorelibPubSub.peripheralDevicesAndSubDevices, async function (studioId: StudioId) { - const { cred, selector } = await AutoFillSelector.organizationId( - this.userId, - { studioId }, - undefined - ) - if (await checkAccess(cred, selector)) { - // TODO - this is not correctly reactive when changing the `studioId` property of a parent device - const parents = (await PeripheralDevices.findFetchAsync(selector, { projection: { _id: 1 } })) as Array< - Pick - > + triggerWriteAccessBecauseNoCheckNecessary() - return PeripheralDevices.findWithCursor( - { - $or: [ - { - parentDeviceId: { $in: parents.map((i) => i._id) }, - }, - selector, - ], - }, - { - fields: peripheralDeviceFields, - } - ) + const selector: MongoQuery = { + studioId, } - return null + + // TODO - this is not correctly reactive when changing the `studioId` property of a parent device + const parents = (await PeripheralDevices.findFetchAsync(selector, { projection: { _id: 1 } })) as Array< + Pick + > + + return PeripheralDevices.findWithCursor( + { + $or: [ + { + parentDeviceId: { $in: parents.map((i) => i._id) }, + }, + selector, + ], + }, + { + fields: peripheralDeviceFields, + } + ) }) meteorPublish( PeripheralDevicePubSub.peripheralDeviceCommands, async function (deviceId: PeripheralDeviceId, token: string | undefined) { - if (!deviceId) throw new Meteor.Error(400, 'deviceId argument missing') - check(deviceId, String) - if (await PeripheralDeviceReadAccess.peripheralDeviceContent(deviceId, { userId: this.userId, token })) { - return PeripheralDeviceCommands.findWithCursor({ deviceId: deviceId }) - } - return null + await checkAccessAndGetPeripheralDevice(deviceId, token, this) + + return PeripheralDeviceCommands.findWithCursor({ deviceId: deviceId }) } ) -meteorPublish(MeteorPubSub.mediaWorkFlows, async function (token: string | undefined) { - const { cred, selector } = await AutoFillSelector.deviceId(this.userId, {}, token) - if (!cred || (await PeripheralDeviceReadAccess.peripheralDeviceContent(selector.deviceId, cred))) { - return MediaWorkFlows.findWithCursor(selector) - } - return null +meteorPublish(MeteorPubSub.mediaWorkFlows, async function (_token: string | undefined) { + triggerWriteAccessBecauseNoCheckNecessary() + + return MediaWorkFlows.findWithCursor({}) }) -meteorPublish(MeteorPubSub.mediaWorkFlowSteps, async function (token: string | undefined) { - const { cred, selector } = await AutoFillSelector.deviceId(this.userId, {}, token) - if (!cred || (await PeripheralDeviceReadAccess.peripheralDeviceContent(selector.deviceId, cred))) { - return MediaWorkFlowSteps.findWithCursor(selector) - } - return null +meteorPublish(MeteorPubSub.mediaWorkFlowSteps, async function (_token: string | undefined) { + triggerWriteAccessBecauseNoCheckNecessary() + + return MediaWorkFlowSteps.findWithCursor({}) }) diff --git a/meteor/server/publications/peripheralDeviceForDevice.ts b/meteor/server/publications/peripheralDeviceForDevice.ts index f98b37e6ff..cb45ec57ee 100644 --- a/meteor/server/publications/peripheralDeviceForDevice.ts +++ b/meteor/server/publications/peripheralDeviceForDevice.ts @@ -1,5 +1,3 @@ -import { Meteor } from 'meteor/meteor' -import { PeripheralDeviceReadAccess } from '../security/peripheralDevice' import { PeripheralDevice, PeripheralDeviceCategory } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' import { PeripheralDeviceId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { PeripheralDevices, Studios } from '../collections' @@ -26,6 +24,7 @@ import { PeripheralDevicePubSub, PeripheralDevicePubSubCollectionsNames, } from '@sofie-automation/shared-lib/dist/pubsub/peripheralDevice' +import { checkAccessAndGetPeripheralDevice } from '../security/check' interface PeripheralDeviceForDeviceArgs { readonly deviceId: PeripheralDeviceId @@ -207,26 +206,22 @@ meteorCustomPublish( async function (pub, deviceId: PeripheralDeviceId, token: string | undefined) { check(deviceId, String) - if (await PeripheralDeviceReadAccess.peripheralDeviceContent(deviceId, { userId: this.userId, token })) { - const peripheralDevice = await PeripheralDevices.findOneAsync(deviceId) - - if (!peripheralDevice) throw new Meteor.Error('PeripheralDevice "' + deviceId + '" not found') - - const studioId = peripheralDevice.studioId - if (!studioId) return - - await setUpOptimizedObserverArray< - PeripheralDeviceForDevice, - PeripheralDeviceForDeviceArgs, - PeripheralDeviceForDeviceState, - PeripheralDeviceForDeviceUpdateProps - >( - `${PeripheralDevicePubSubCollectionsNames.peripheralDeviceForDevice}_${deviceId}`, - { deviceId }, - setupPeripheralDevicePublicationObservers, - manipulatePeripheralDevicePublicationData, - pub - ) - } + const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, token, this) + + const studioId = peripheralDevice.studioId + if (!studioId) return + + await setUpOptimizedObserverArray< + PeripheralDeviceForDevice, + PeripheralDeviceForDeviceArgs, + PeripheralDeviceForDeviceState, + PeripheralDeviceForDeviceUpdateProps + >( + `${PeripheralDevicePubSubCollectionsNames.peripheralDeviceForDevice}_${deviceId}`, + { deviceId }, + setupPeripheralDevicePublicationObservers, + manipulatePeripheralDevicePublicationData, + pub + ) } ) diff --git a/meteor/server/publications/pieceContentStatusUI/bucket/publication.ts b/meteor/server/publications/pieceContentStatusUI/bucket/publication.ts index 8661244883..8942d5f75d 100644 --- a/meteor/server/publications/pieceContentStatusUI/bucket/publication.ts +++ b/meteor/server/publications/pieceContentStatusUI/bucket/publication.ts @@ -20,11 +20,7 @@ import { TriggerUpdate, SetupObserversResult, } from '../../../lib/customPublication' -import { logger } from '../../../logging' -import { resolveCredentials } from '../../../security/lib/credentials' -import { NoSecurityReadAccess } from '../../../security/noSecurity' import { BucketContentCache, createReactiveContentCache } from './bucketContentCache' -import { StudioReadAccess } from '../../../security/studio' import { Bucket } from '@sofie-automation/meteor-lib/dist/collections/Buckets' import { addItemsWithDependenciesChangesToChangedSet, @@ -39,8 +35,8 @@ import { import { BucketContentObserver } from './bucketContentObserver' import { regenerateForBucketActionIds, regenerateForBucketAdLibIds } from './regenerateForItem' import { PieceContentStatusStudio } from '../checkPieceContentStatus' -import { BucketSecurity } from '../../../security/buckets' import { check } from 'meteor/check' +import { triggerWriteAccessBecauseNoCheckNecessary } from '../../../security/securityVerify' interface UIBucketContentStatusesArgs { readonly studioId: StudioId @@ -250,30 +246,20 @@ meteorCustomPublish( check(studioId, String) check(bucketId, String) - const cred = await resolveCredentials({ userId: this.userId, token: undefined }) + triggerWriteAccessBecauseNoCheckNecessary() - if ( - NoSecurityReadAccess.any() || - (studioId && - bucketId && - (await StudioReadAccess.studioContent(studioId, cred)) && - (await BucketSecurity.allowReadAccess(cred, bucketId))) - ) { - await setUpCollectionOptimizedObserver< - UIBucketContentStatus, - UIBucketContentStatusesArgs, - UIBucketContentStatusesState, - UIBucketContentStatusesUpdateProps - >( - `pub_${MeteorPubSub.uiBucketContentStatuses}_${studioId}_${bucketId}`, - { studioId, bucketId }, - setupUIBucketContentStatusesPublicationObservers, - manipulateUIBucketContentStatusesPublicationData, - pub, - 100 - ) - } else { - logger.warn(`Pub.${CustomCollectionName.UIBucketContentStatuses}: Not allowed: "${studioId}" "${bucketId}"`) - } + await setUpCollectionOptimizedObserver< + UIBucketContentStatus, + UIBucketContentStatusesArgs, + UIBucketContentStatusesState, + UIBucketContentStatusesUpdateProps + >( + `pub_${MeteorPubSub.uiBucketContentStatuses}_${studioId}_${bucketId}`, + { studioId, bucketId }, + setupUIBucketContentStatusesPublicationObservers, + manipulateUIBucketContentStatusesPublicationData, + pub, + 100 + ) } ) diff --git a/meteor/server/publications/pieceContentStatusUI/rundown/publication.ts b/meteor/server/publications/pieceContentStatusUI/rundown/publication.ts index 1b20d6de6a..a190378eac 100644 --- a/meteor/server/publications/pieceContentStatusUI/rundown/publication.ts +++ b/meteor/server/publications/pieceContentStatusUI/rundown/publication.ts @@ -32,9 +32,6 @@ import { TriggerUpdate, } from '../../../lib/customPublication' import { logger } from '../../../logging' -import { resolveCredentials } from '../../../security/lib/credentials' -import { NoSecurityReadAccess } from '../../../security/noSecurity' -import { RundownPlaylistReadAccess } from '../../../security/rundownPlaylist' import { ContentCache, PartInstanceFields, createReactiveContentCache } from './reactiveContentCache' import { RundownContentObserver } from './rundownContentObserver' import { RundownsObserver } from '../../lib/rundownsObserver' @@ -59,6 +56,7 @@ import { import { PieceContentStatusStudio } from '../checkPieceContentStatus' import { check, Match } from 'meteor/check' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' +import { triggerWriteAccessBecauseNoCheckNecessary } from '../../../security/securityVerify' interface UIPieceContentStatusesArgs { readonly rundownPlaylistId: RundownPlaylistId @@ -476,29 +474,25 @@ meteorCustomPublish( async function (pub, rundownPlaylistId: RundownPlaylistId | null) { check(rundownPlaylistId, Match.Maybe(String)) - const cred = await resolveCredentials({ userId: this.userId, token: undefined }) - - if ( - rundownPlaylistId && - (!cred || - NoSecurityReadAccess.any() || - (await RundownPlaylistReadAccess.rundownPlaylistContent(rundownPlaylistId, cred))) - ) { - await setUpCollectionOptimizedObserver< - UIPieceContentStatus, - UIPieceContentStatusesArgs, - UIPieceContentStatusesState, - UIPieceContentStatusesUpdateProps - >( - `pub_${MeteorPubSub.uiPieceContentStatuses}_${rundownPlaylistId}`, - { rundownPlaylistId }, - setupUIPieceContentStatusesPublicationObservers, - manipulateUIPieceContentStatusesPublicationData, - pub, - 100 - ) - } else { - logger.warn(`Pub.${CustomCollectionName.UIPieceContentStatuses}: Not allowed: "${rundownPlaylistId}"`) + triggerWriteAccessBecauseNoCheckNecessary() + + if (!rundownPlaylistId) { + logger.info(`Pub.${CustomCollectionName.UISegmentPartNotes}: Not playlistId`) + return } + + await setUpCollectionOptimizedObserver< + UIPieceContentStatus, + UIPieceContentStatusesArgs, + UIPieceContentStatusesState, + UIPieceContentStatusesUpdateProps + >( + `pub_${MeteorPubSub.uiPieceContentStatuses}_${rundownPlaylistId}`, + { rundownPlaylistId }, + setupUIPieceContentStatusesPublicationObservers, + manipulateUIPieceContentStatusesPublicationData, + pub, + 100 + ) } ) diff --git a/meteor/server/publications/rundown.ts b/meteor/server/publications/rundown.ts index f939a9baff..e4be7f6dac 100644 --- a/meteor/server/publications/rundown.ts +++ b/meteor/server/publications/rundown.ts @@ -1,16 +1,12 @@ import { Meteor } from 'meteor/meteor' -import { meteorPublish, AutoFillSelector } from './lib/lib' +import { meteorPublish } from './lib/lib' import { MeteorPubSub } from '@sofie-automation/meteor-lib/dist/api/pubsub' import { MongoFieldSpecifierZeroes, MongoQuery } from '@sofie-automation/corelib/dist/mongo' import { AdLibPiece } from '@sofie-automation/corelib/dist/dataModel/AdLibPiece' -import { RundownReadAccess } from '../security/rundown' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import { Piece } from '@sofie-automation/corelib/dist/dataModel/Piece' import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' -import { NoSecurityReadAccess } from '../security/noSecurity' -import { OrganizationReadAccess } from '../security/organization' -import { StudioReadAccess } from '../security/studio' import { check, Match } from 'meteor/check' import { FindOptions } from '@sofie-automation/meteor-lib/dist/collections/lib' import { @@ -20,7 +16,6 @@ import { NrcsIngestDataCache, PartInstances, Parts, - PeripheralDevices, PieceInstances, Pieces, RundownBaselineAdLibActions, @@ -44,58 +39,52 @@ import { import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { PeripheralDevicePubSub } from '@sofie-automation/shared-lib/dist/pubsub/peripheralDevice' -import { PeripheralDeviceReadAccess } from '../security/peripheralDevice' import { RundownBaselineAdLibAction } from '@sofie-automation/corelib/dist/dataModel/RundownBaselineAdLibAction' import { RundownBaselineAdLibItem } from '@sofie-automation/corelib/dist/dataModel/RundownBaselineAdLibPiece' import { AdLibAction } from '@sofie-automation/corelib/dist/dataModel/AdlibAction' import { PieceLifespan } from '@sofie-automation/blueprints-integration' -import { resolveCredentials } from '../security/lib/credentials' +import { triggerWriteAccessBecauseNoCheckNecessary } from '../security/securityVerify' +import { checkAccessAndGetPeripheralDevice } from '../security/check' -meteorPublish(PeripheralDevicePubSub.rundownsForDevice, async function (deviceId, token: string | undefined) { - check(deviceId, String) - check(token, String) - - const { cred, selector } = await AutoFillSelector.organizationId(this.userId, {}, token) - - // Future: this should be reactive to studioId changes, but this matches how the other *ForDevice publications behave - - // The above auth check may return nothing when security is disabled, but we need the return value - const resolvedCred = cred?.device ? cred : await resolveCredentials({ userId: this.userId, token }) - if (!resolvedCred || !resolvedCred.device) - throw new Meteor.Error(403, 'Publication can only be used by authorized PeripheralDevices') +meteorPublish( + PeripheralDevicePubSub.rundownsForDevice, + async function (deviceId: PeripheralDeviceId, token: string | undefined) { + check(deviceId, String) + check(token, String) - // No studio, then no rundowns - if (!resolvedCred.device.studioId) return null + // Future: this should be reactive to studioId changes, but this matches how the other *ForDevice publications behave - selector.studioId = resolvedCred.device.studioId + const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, token, this) - const modifier: FindOptions = { - fields: { - privateData: 0, - }, - } + // No studio, then no rundowns + if (!peripheralDevice.studioId) return null - if (NoSecurityReadAccess.any() || (await StudioReadAccess.studioContent(selector.studioId, resolvedCred))) { - return Rundowns.findWithCursor(selector, modifier) + return Rundowns.findWithCursor( + { + studioId: peripheralDevice.studioId, + }, + { + fields: { + privateData: 0, + }, + } + ) } - return null -}) +) meteorPublish( CorelibPubSub.rundownsInPlaylists, - async function (playlistIds: RundownPlaylistId[], token: string | undefined) { + async function (playlistIds: RundownPlaylistId[], _token: string | undefined) { check(playlistIds, Array) + triggerWriteAccessBecauseNoCheckNecessary() + // If values were provided, they must have values if (playlistIds.length === 0) return null - const { cred, selector } = await AutoFillSelector.organizationId( - this.userId, - { - playlistId: { $in: playlistIds }, - }, - token - ) + const selector: MongoQuery = { + playlistId: { $in: playlistIds }, + } const modifier: FindOptions = { fields: { @@ -103,33 +92,21 @@ meteorPublish( }, } - if ( - !cred || - NoSecurityReadAccess.any() || - (selector.organizationId && - (await OrganizationReadAccess.organizationContent(selector.organizationId, cred))) || - (selector.studioId && (await StudioReadAccess.studioContent(selector.studioId, cred))) || - (selector._id && (await RundownReadAccess.rundown(selector._id, cred))) - ) { - return Rundowns.findWithCursor(selector, modifier) - } - return null + return Rundowns.findWithCursor(selector, modifier) } ) meteorPublish( CorelibPubSub.rundownsWithShowStyleBases, - async function (showStyleBaseIds: ShowStyleBaseId[], token: string | undefined) { + async function (showStyleBaseIds: ShowStyleBaseId[], _token: string | undefined) { check(showStyleBaseIds, Array) + triggerWriteAccessBecauseNoCheckNecessary() + if (showStyleBaseIds.length === 0) return null - const { cred, selector } = await AutoFillSelector.organizationId( - this.userId, - { - showStyleBaseId: { $in: showStyleBaseIds }, - }, - token - ) + const selector: MongoQuery = { + showStyleBaseId: { $in: showStyleBaseIds }, + } const modifier: FindOptions = { fields: { @@ -137,25 +114,17 @@ meteorPublish( }, } - if ( - !cred || - NoSecurityReadAccess.any() || - (selector.organizationId && - (await OrganizationReadAccess.organizationContent(selector.organizationId, cred))) || - (selector.studioId && (await StudioReadAccess.studioContent(selector.studioId, cred))) || - (selector._id && (await RundownReadAccess.rundown(selector._id, cred))) - ) { - return Rundowns.findWithCursor(selector, modifier) - } - return null + return Rundowns.findWithCursor(selector, modifier) } ) meteorPublish( CorelibPubSub.segments, - async function (rundownIds: RundownId[], filter: { omitHidden?: boolean } | undefined, token: string | undefined) { + async function (rundownIds: RundownId[], filter: { omitHidden?: boolean } | undefined, _token: string | undefined) { check(rundownIds, Array) + triggerWriteAccessBecauseNoCheckNecessary() + if (rundownIds.length === 0) return null const selector: MongoQuery = { @@ -163,26 +132,22 @@ meteorPublish( } if (filter?.omitHidden) selector.isHidden = { $ne: true } - if ( - NoSecurityReadAccess.any() || - (await RundownReadAccess.rundownContent(selector.rundownId, { userId: this.userId, token })) - ) { - return Segments.findWithCursor(selector, { - fields: { - privateData: 0, - }, - }) - } - return null + return Segments.findWithCursor(selector, { + fields: { + privateData: 0, + }, + }) } ) meteorPublish( CorelibPubSub.parts, - async function (rundownIds: RundownId[], segmentIds: SegmentId[] | null, token: string | undefined) { + async function (rundownIds: RundownId[], segmentIds: SegmentId[] | null, _token: string | undefined) { check(rundownIds, Array) check(segmentIds, Match.Maybe(Array)) + triggerWriteAccessBecauseNoCheckNecessary() + if (rundownIds.length === 0) return null if (segmentIds && segmentIds.length === 0) return null @@ -198,15 +163,7 @@ meteorPublish( } if (segmentIds) selector.segmentId = { $in: segmentIds } - if ( - NoSecurityReadAccess.any() || - (selector.rundownId && - (await RundownReadAccess.rundownContent(selector.rundownId, { userId: this.userId, token }))) // || - // (selector._id && await RundownReadAccess.pieces(selector._id, { userId: this.userId, token })) // TODO - the types for this did not match - ) { - return Parts.findWithCursor(selector, modifier) - } - return null + return Parts.findWithCursor(selector, modifier) } ) meteorPublish( @@ -214,11 +171,13 @@ meteorPublish( async function ( rundownIds: RundownId[], playlistActivationId: RundownPlaylistActivationId | null, - token: string | undefined + _token: string | undefined ) { check(rundownIds, Array) check(playlistActivationId, Match.Maybe(String)) + triggerWriteAccessBecauseNoCheckNecessary() + if (rundownIds.length === 0 || !playlistActivationId) return null const modifier: FindOptions = { @@ -234,13 +193,7 @@ meteorPublish( } if (playlistActivationId) selector.playlistActivationId = playlistActivationId - if ( - NoSecurityReadAccess.any() || - (await RundownReadAccess.rundownContent(selector.rundownId, { userId: this.userId, token })) - ) { - return PartInstances.findWithCursor(selector, modifier) - } - return null + return PartInstances.findWithCursor(selector, modifier) } ) meteorPublish( @@ -248,10 +201,12 @@ meteorPublish( async function ( rundownIds: RundownId[], playlistActivationId: RundownPlaylistActivationId | null, - token: string | undefined + _token: string | undefined ) { check(rundownIds, Array) + triggerWriteAccessBecauseNoCheckNecessary() + if (rundownIds.length === 0) return null const selector: MongoQuery = { @@ -261,20 +216,14 @@ meteorPublish( } if (playlistActivationId) selector.playlistActivationId = playlistActivationId - if ( - NoSecurityReadAccess.any() || - (await RundownReadAccess.rundownContent(selector.rundownId, { userId: this.userId, token })) - ) { - return PartInstances.findWithCursor(selector, { - fields: literal>({ - // @ts-expect-error Mongo typings aren't clever enough yet - 'part.privateData': 0, - isTaken: 0, - timings: 0, - }), - }) - } - return null + return PartInstances.findWithCursor(selector, { + fields: literal>({ + // @ts-expect-error Mongo typings aren't clever enough yet + 'part.privateData': 0, + isTaken: 0, + timings: 0, + }), + }) } ) @@ -285,10 +234,12 @@ const piecesSubFields: MongoFieldSpecifierZeroes = { meteorPublish( CorelibPubSub.pieces, - async function (rundownIds: RundownId[], partIds: PartId[] | null, token: string | undefined) { + async function (rundownIds: RundownId[], partIds: PartId[] | null, _token: string | undefined) { check(rundownIds, Array) check(partIds, Match.Maybe(Array)) + triggerWriteAccessBecauseNoCheckNecessary() + // If values were provided, they must have values if (partIds && partIds.length === 0) return null @@ -297,15 +248,9 @@ meteorPublish( } if (partIds) selector.startPartId = { $in: partIds } - if ( - NoSecurityReadAccess.any() || - (await RundownReadAccess.rundownContent(selector.startRundownId, { userId: this.userId, token })) - ) { - return Pieces.findWithCursor(selector, { - fields: piecesSubFields, - }) - } - return null + return Pieces.findWithCursor(selector, { + fields: piecesSubFields, + }) } ) @@ -317,8 +262,7 @@ meteorPublish( rundownIdsBefore: RundownId[], _token: string | undefined ) { - // TODO - Fix this when security is enabled - if (!NoSecurityReadAccess.any()) return null + triggerWriteAccessBecauseNoCheckNecessary() const selector: MongoQuery = { invalid: { @@ -358,31 +302,26 @@ const adlibPiecesSubFields: MongoFieldSpecifierZeroes = { timelineObjectsString: 0, } -meteorPublish(CorelibPubSub.adLibPieces, async function (rundownIds: RundownId[], token: string | undefined) { +meteorPublish(CorelibPubSub.adLibPieces, async function (rundownIds: RundownId[], _token: string | undefined) { check(rundownIds, Array) + triggerWriteAccessBecauseNoCheckNecessary() + if (rundownIds.length === 0) return null const selector: MongoQuery = { rundownId: { $in: rundownIds }, } - if ( - NoSecurityReadAccess.any() || - (await RundownReadAccess.rundownContent(selector.rundownId, { userId: this.userId, token })) - ) { - return AdLibPieces.findWithCursor(selector, { - fields: adlibPiecesSubFields, - }) - } - return null + return AdLibPieces.findWithCursor(selector, { + fields: adlibPiecesSubFields, + }) }) meteorPublish(MeteorPubSub.adLibPiecesForPart, async function (partId: PartId, sourceLayerIds: string[]) { - if (!partId) throw new Meteor.Error(400, 'partId argument missing') - if (!sourceLayerIds) throw new Meteor.Error(400, 'sourceLayerIds argument missing') + check(partId, String) + check(sourceLayerIds, Array) - // Future: This needs some thought for a security enabled environment - if (!NoSecurityReadAccess.any()) return null + triggerWriteAccessBecauseNoCheckNecessary() return AdLibPieces.findWithCursor( { @@ -411,11 +350,13 @@ meteorPublish( onlyPlayingAdlibsOrWithTags?: boolean } | undefined, - token: string | undefined + _token: string | undefined ) { check(rundownIds, Array) check(partInstanceIds, Match.Maybe(Array)) + triggerWriteAccessBecauseNoCheckNecessary() + // If values were provided, they must have values if (rundownIds.length === 0) return null if (partInstanceIds && partInstanceIds.length === 0) return null @@ -464,15 +405,9 @@ meteorPublish( ] } - if ( - NoSecurityReadAccess.any() || - (await RundownReadAccess.rundownContent(selector.rundownId, { userId: this.userId, token })) - ) { - return PieceInstances.findWithCursor(selector, { - fields: pieceInstanceFields, - }) - } - return null + return PieceInstances.findWithCursor(selector, { + fields: pieceInstanceFields, + }) } ) @@ -481,10 +416,12 @@ meteorPublish( async function ( rundownIds: RundownId[], playlistActivationId: RundownPlaylistActivationId | null, - token: string | undefined + _token: string | undefined ) { check(rundownIds, Array) + triggerWriteAccessBecauseNoCheckNecessary() + if (rundownIds.length === 0) return null const selector: MongoQuery = { @@ -494,81 +431,62 @@ meteorPublish( } if (playlistActivationId) selector.playlistActivationId = playlistActivationId - if ( - NoSecurityReadAccess.any() || - (await RundownReadAccess.rundownContent(selector.rundownId, { userId: this.userId, token })) - ) { - return PieceInstances.findWithCursor(selector, { - fields: literal>({ - ...pieceInstanceFields, - plannedStartedPlayback: 0, - plannedStoppedPlayback: 0, - }), - }) - } - return null + return PieceInstances.findWithCursor(selector, { + fields: literal>({ + ...pieceInstanceFields, + plannedStartedPlayback: 0, + plannedStoppedPlayback: 0, + }), + }) } ) meteorPublish( PeripheralDevicePubSub.expectedPlayoutItemsForDevice, async function (deviceId: PeripheralDeviceId, token: string | undefined) { - if (!deviceId) throw new Meteor.Error(400, 'deviceId argument missing') check(deviceId, String) - if (await PeripheralDeviceReadAccess.peripheralDeviceContent(deviceId, { userId: this.userId, token })) { - const peripheralDevice = await PeripheralDevices.findOneAsync(deviceId) + const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, token, this) - if (!peripheralDevice) throw new Meteor.Error(`PeripheralDevice "${deviceId}" not found`) + const studioId = peripheralDevice.studioId + if (!studioId) return null - const studioId = peripheralDevice.studioId - if (!studioId) return null - - return ExpectedPlayoutItems.findWithCursor({ studioId }) - } - return null + return ExpectedPlayoutItems.findWithCursor({ studioId }) } ) // Note: this publication is for dev purposes only: meteorPublish( CorelibPubSub.ingestDataCache, - async function (selector: MongoQuery, token: string | undefined) { + async function (selector: MongoQuery, _token: string | undefined) { + triggerWriteAccessBecauseNoCheckNecessary() + if (!selector) throw new Meteor.Error(400, 'selector argument missing') const modifier: FindOptions = { fields: {}, } - if ( - NoSecurityReadAccess.any() || - (await RundownReadAccess.rundownContent(selector.rundownId, { userId: this.userId, token })) - ) { - return NrcsIngestDataCache.findWithCursor(selector, modifier) - } - return null + + return NrcsIngestDataCache.findWithCursor(selector, modifier) } ) meteorPublish( CorelibPubSub.rundownBaselineAdLibPieces, - async function (rundownIds: RundownId[], token: string | undefined) { + async function (rundownIds: RundownId[], _token: string | undefined) { check(rundownIds, Array) + triggerWriteAccessBecauseNoCheckNecessary() + if (rundownIds.length === 0) return null const selector: MongoQuery = { rundownId: { $in: rundownIds }, } - if ( - NoSecurityReadAccess.any() || - (await RundownReadAccess.rundownContent(selector.rundownId, { userId: this.userId, token })) - ) { - return RundownBaselineAdLibPieces.findWithCursor(selector, { - fields: { - timelineObjectsString: 0, - privateData: 0, - }, - }) - } - return null + return RundownBaselineAdLibPieces.findWithCursor(selector, { + fields: { + timelineObjectsString: 0, + privateData: 0, + }, + }) } ) @@ -576,31 +494,26 @@ const adlibActionSubFields: MongoFieldSpecifierZeroes = { privateData: 0, } -meteorPublish(CorelibPubSub.adLibActions, async function (rundownIds: RundownId[], token: string | undefined) { +meteorPublish(CorelibPubSub.adLibActions, async function (rundownIds: RundownId[], _token: string | undefined) { check(rundownIds, Array) + triggerWriteAccessBecauseNoCheckNecessary() + if (rundownIds.length === 0) return null const selector: MongoQuery = { rundownId: { $in: rundownIds }, } - if ( - NoSecurityReadAccess.any() || - (await RundownReadAccess.rundownContent(selector.rundownId, { userId: this.userId, token })) - ) { - return AdLibActions.findWithCursor(selector, { - fields: adlibActionSubFields, - }) - } - return null + return AdLibActions.findWithCursor(selector, { + fields: adlibActionSubFields, + }) }) meteorPublish(MeteorPubSub.adLibActionsForPart, async function (partId: PartId, sourceLayerIds: string[]) { - if (!partId) throw new Meteor.Error(400, 'partId argument missing') - if (!sourceLayerIds) throw new Meteor.Error(400, 'sourceLayerIds argument missing') + check(partId, String) + check(sourceLayerIds, Array) - // Future: This needs some thought for a security enabled environment - if (!NoSecurityReadAccess.any()) return null + triggerWriteAccessBecauseNoCheckNecessary() return AdLibActions.findWithCursor( { @@ -615,23 +528,19 @@ meteorPublish(MeteorPubSub.adLibActionsForPart, async function (partId: PartId, meteorPublish( CorelibPubSub.rundownBaselineAdLibActions, - async function (rundownIds: RundownId[], token: string | undefined) { + async function (rundownIds: RundownId[], _token: string | undefined) { check(rundownIds, Array) + triggerWriteAccessBecauseNoCheckNecessary() + if (rundownIds.length === 0) return null const selector: MongoQuery = { rundownId: { $in: rundownIds }, } - if ( - NoSecurityReadAccess.any() || - (await RundownReadAccess.rundownContent(selector.rundownId, { userId: this.userId, token })) - ) { - return RundownBaselineAdLibActions.findWithCursor(selector, { - fields: adlibActionSubFields, - }) - } - return null + return RundownBaselineAdLibActions.findWithCursor(selector, { + fields: adlibActionSubFields, + }) } ) diff --git a/meteor/server/publications/rundownPlaylist.ts b/meteor/server/publications/rundownPlaylist.ts index 89378b1587..c52efc85d3 100644 --- a/meteor/server/publications/rundownPlaylist.ts +++ b/meteor/server/publications/rundownPlaylist.ts @@ -1,59 +1,40 @@ -import { RundownPlaylistReadAccess } from '../security/rundownPlaylist' -import { meteorPublish, AutoFillSelector } from './lib/lib' -import { StudioReadAccess } from '../security/studio' -import { OrganizationReadAccess } from '../security/organization' -import { NoSecurityReadAccess } from '../security/noSecurity' -import { isProtectedString } from '@sofie-automation/corelib/dist/protectedString' +import { meteorPublish } from './lib/lib' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { RundownPlaylists } from '../collections' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { MeteorPubSub } from '@sofie-automation/meteor-lib/dist/api/pubsub' import { RundownPlaylistId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { resolveCredentials } from '../security/lib/credentials' import { check, Match } from '../lib/check' import { MongoQuery } from '@sofie-automation/corelib/dist/mongo' +import { triggerWriteAccessBecauseNoCheckNecessary } from '../security/securityVerify' meteorPublish( CorelibPubSub.rundownPlaylists, async function ( rundownPlaylistIds: RundownPlaylistId[] | null, studioIds: StudioId[] | null, - token: string | undefined + _token: string | undefined ) { check(rundownPlaylistIds, Match.Maybe(Array)) check(studioIds, Match.Maybe(Array)) + triggerWriteAccessBecauseNoCheckNecessary() + // If values were provided, they must have values if (rundownPlaylistIds && rundownPlaylistIds.length === 0) return null if (studioIds && studioIds.length === 0) return null - const { cred, selector } = await AutoFillSelector.organizationId(this.userId, {}, token) - // Add the requested filter + const selector: MongoQuery = {} if (rundownPlaylistIds) selector._id = { $in: rundownPlaylistIds } if (studioIds) selector.studioId = { $in: studioIds } - if ( - !cred || - NoSecurityReadAccess.any() || - (selector.organizationId && - (await OrganizationReadAccess.organizationContent(selector.organizationId, cred))) || - (selector.studioId && (await StudioReadAccess.studioContent(selector.studioId, cred))) || - (isProtectedString(selector._id) && (await RundownPlaylistReadAccess.rundownPlaylist(selector._id, cred))) - ) { - return RundownPlaylists.findWithCursor(selector) - } - return null + return RundownPlaylists.findWithCursor(selector) } ) meteorPublish(MeteorPubSub.rundownPlaylistForStudio, async function (studioId: StudioId, isActive: boolean) { - if (!NoSecurityReadAccess.any()) { - const cred = await resolveCredentials({ userId: this.userId }) - if (!cred) return null - - if (!(await StudioReadAccess.studioContent(studioId, cred))) return null - } + triggerWriteAccessBecauseNoCheckNecessary() const selector: MongoQuery = { studioId, diff --git a/meteor/server/publications/segmentPartNotesUI/publication.ts b/meteor/server/publications/segmentPartNotesUI/publication.ts index 5ab2a86a44..d01a55c66a 100644 --- a/meteor/server/publications/segmentPartNotesUI/publication.ts +++ b/meteor/server/publications/segmentPartNotesUI/publication.ts @@ -16,9 +16,6 @@ import { TriggerUpdate, } from '../../lib/customPublication' import { logger } from '../../logging' -import { resolveCredentials } from '../../security/lib/credentials' -import { NoSecurityReadAccess } from '../../security/noSecurity' -import { RundownPlaylistReadAccess } from '../../security/rundownPlaylist' import { ContentCache, createReactiveContentCache, @@ -33,6 +30,7 @@ import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/Rund import { generateNotesForSegment } from './generateNotesForSegment' import { RundownPlaylists } from '../../collections' import { check, Match } from 'meteor/check' +import { triggerWriteAccessBecauseNoCheckNecessary } from '../../security/securityVerify' interface UISegmentPartNotesArgs { readonly playlistId: RundownPlaylistId @@ -215,29 +213,25 @@ meteorCustomPublish( async function (pub, playlistId: RundownPlaylistId | null) { check(playlistId, Match.Maybe(String)) - const cred = await resolveCredentials({ userId: this.userId, token: undefined }) - - if ( - playlistId && - (!cred || - NoSecurityReadAccess.any() || - (await RundownPlaylistReadAccess.rundownPlaylistContent(playlistId, cred))) - ) { - await setUpCollectionOptimizedObserver< - UISegmentPartNote, - UISegmentPartNotesArgs, - UISegmentPartNotesState, - UISegmentPartNotesUpdateProps - >( - `pub_${MeteorPubSub.uiSegmentPartNotes}_${playlistId}`, - { playlistId }, - setupUISegmentPartNotesPublicationObservers, - manipulateUISegmentPartNotesPublicationData, - pub, - 100 - ) - } else { - logger.warn(`Pub.${CustomCollectionName.UISegmentPartNotes}: Not allowed: "${playlistId}"`) + triggerWriteAccessBecauseNoCheckNecessary() + + if (!playlistId) { + logger.info(`Pub.${CustomCollectionName.UISegmentPartNotes}: Not playlistId`) + return } + + await setUpCollectionOptimizedObserver< + UISegmentPartNote, + UISegmentPartNotesArgs, + UISegmentPartNotesState, + UISegmentPartNotesUpdateProps + >( + `pub_${MeteorPubSub.uiSegmentPartNotes}_${playlistId}`, + { playlistId }, + setupUISegmentPartNotesPublicationObservers, + manipulateUISegmentPartNotesPublicationData, + pub, + 100 + ) } ) diff --git a/meteor/server/publications/showStyle.ts b/meteor/server/publications/showStyle.ts index 99b3099e50..ee3cbf0803 100644 --- a/meteor/server/publications/showStyle.ts +++ b/meteor/server/publications/showStyle.ts @@ -1,41 +1,31 @@ -import { meteorPublish, AutoFillSelector } from './lib/lib' +import { meteorPublish } from './lib/lib' import { MeteorPubSub } from '@sofie-automation/meteor-lib/dist/api/pubsub' import { DBShowStyleBase } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' import { DBShowStyleVariant } from '@sofie-automation/corelib/dist/dataModel/ShowStyleVariant' import { RundownLayoutBase } from '@sofie-automation/meteor-lib/dist/collections/RundownLayouts' -import { ShowStyleReadAccess } from '../security/showStyle' -import { OrganizationReadAccess } from '../security/organization' -import { NoSecurityReadAccess } from '../security/noSecurity' import { RundownLayouts, ShowStyleBases, ShowStyleVariants, TriggeredActions } from '../collections' import { MongoQuery } from '@sofie-automation/corelib/dist/mongo' import { DBTriggeredActions } from '@sofie-automation/meteor-lib/dist/collections/TriggeredActions' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { ShowStyleBaseId, ShowStyleVariantId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { check, Match } from '../lib/check' +import { triggerWriteAccessBecauseNoCheckNecessary } from '../security/securityVerify' meteorPublish( CorelibPubSub.showStyleBases, - async function (showStyleBaseIds: ShowStyleBaseId[] | null, token: string | undefined) { + async function (showStyleBaseIds: ShowStyleBaseId[] | null, _token: string | undefined) { check(showStyleBaseIds, Match.Maybe(Array)) + triggerWriteAccessBecauseNoCheckNecessary() + // If values were provided, they must have values if (showStyleBaseIds && showStyleBaseIds.length === 0) return null - const { cred, selector } = await AutoFillSelector.organizationId(this.userId, {}, token) - // Add the requested filter + const selector: MongoQuery = {} if (showStyleBaseIds) selector._id = { $in: showStyleBaseIds } - if ( - !cred || - NoSecurityReadAccess.any() || - (selector.organizationId && - (await OrganizationReadAccess.organizationContent(selector.organizationId, cred))) || - (selector._id && (await ShowStyleReadAccess.showStyleBase(selector.id, cred))) - ) { - return ShowStyleBases.findWithCursor(selector) - } - return null + return ShowStyleBases.findWithCursor(selector) } ) @@ -44,59 +34,51 @@ meteorPublish( async function ( showStyleBaseIds: ShowStyleBaseId[] | null, showStyleVariantIds: ShowStyleVariantId[] | null, - token: string | undefined + _token: string | undefined ) { check(showStyleBaseIds, Match.Maybe(Array)) check(showStyleVariantIds, Match.Maybe(Array)) + triggerWriteAccessBecauseNoCheckNecessary() + // If values were provided, they must have values if (showStyleBaseIds && showStyleBaseIds.length === 0) return null if (showStyleVariantIds && showStyleVariantIds.length === 0) return null - const { cred, selector } = await AutoFillSelector.showStyleBaseId(this.userId, {}, token) - // Add the requested filter + const selector: MongoQuery = {} if (showStyleBaseIds) selector.showStyleBaseId = { $in: showStyleBaseIds } if (showStyleVariantIds) selector._id = { $in: showStyleVariantIds } - if ( - !cred || - NoSecurityReadAccess.any() || - (selector.showStyleBaseId && (await ShowStyleReadAccess.showStyleBaseContent(selector, cred))) || - (selector._id && (await ShowStyleReadAccess.showStyleVariant(selector._id, cred))) - ) { - return ShowStyleVariants.findWithCursor(selector) - } - return null + return ShowStyleVariants.findWithCursor(selector) } ) meteorPublish( MeteorPubSub.rundownLayouts, - async function (showStyleBaseIds: ShowStyleBaseId[] | null, token: string | undefined) { + async function (showStyleBaseIds: ShowStyleBaseId[] | null, _token: string | undefined) { check(showStyleBaseIds, Match.Maybe(Array)) + triggerWriteAccessBecauseNoCheckNecessary() + // If values were provided, they must have values if (showStyleBaseIds && showStyleBaseIds.length === 0) return null - const selector0: MongoQuery = {} - if (showStyleBaseIds) selector0.showStyleBaseId = { $in: showStyleBaseIds } - - const { cred, selector } = await AutoFillSelector.showStyleBaseId(this.userId, selector0, token) + const selector: MongoQuery = {} + if (showStyleBaseIds) selector.showStyleBaseId = { $in: showStyleBaseIds } - if (!cred || (await ShowStyleReadAccess.showStyleBaseContent(selector, cred))) { - return RundownLayouts.findWithCursor(selector) - } - return null + return RundownLayouts.findWithCursor(selector) } ) meteorPublish( MeteorPubSub.triggeredActions, - async function (showStyleBaseIds: ShowStyleBaseId[] | null, token: string | undefined) { + async function (showStyleBaseIds: ShowStyleBaseId[] | null, _token: string | undefined) { check(showStyleBaseIds, Match.Maybe(Array)) - const selector0: MongoQuery = + triggerWriteAccessBecauseNoCheckNecessary() + + const selector: MongoQuery = showStyleBaseIds && showStyleBaseIds.length > 0 ? { $or: [ @@ -110,15 +92,6 @@ meteorPublish( } : { showStyleBaseId: null } - const { cred, selector } = await AutoFillSelector.showStyleBaseId(this.userId, selector0, token) - - if ( - !cred || - NoSecurityReadAccess.any() || - (selector.showStyleBaseId && (await ShowStyleReadAccess.showStyleBaseContent(selector, cred))) - ) { - return TriggeredActions.findWithCursor(selector) - } - return null + return TriggeredActions.findWithCursor(selector) } ) diff --git a/meteor/server/publications/showStyleUI.ts b/meteor/server/publications/showStyleUI.ts index 68309db7d9..2b6ce26ccc 100644 --- a/meteor/server/publications/showStyleUI.ts +++ b/meteor/server/publications/showStyleUI.ts @@ -12,13 +12,9 @@ import { setUpOptimizedObserverArray, TriggerUpdate, } from '../lib/customPublication' -import { logger } from '../logging' -import { NoSecurityReadAccess } from '../security/noSecurity' -import { OrganizationReadAccess } from '../security/organization' -import { ShowStyleReadAccess } from '../security/showStyle' import { ShowStyleBases } from '../collections' -import { AutoFillSelector } from './lib/lib' import { check } from 'meteor/check' +import { triggerWriteAccessBecauseNoCheckNecessary } from '../security/securityVerify' interface UIShowStyleBaseArgs { readonly showStyleBaseId: ShowStyleBaseId @@ -92,33 +88,19 @@ meteorCustomPublish( async function (pub, showStyleBaseId: ShowStyleBaseId) { check(showStyleBaseId, String) - const { cred, selector } = await AutoFillSelector.organizationId( - this.userId, - { _id: showStyleBaseId }, - undefined - ) + triggerWriteAccessBecauseNoCheckNecessary() - if ( - !cred || - NoSecurityReadAccess.any() || - (selector.organizationId && - (await OrganizationReadAccess.organizationContent(selector.organizationId, cred))) || - (selector._id && (await ShowStyleReadAccess.showStyleBase(selector._id, cred))) - ) { - await setUpOptimizedObserverArray< - UIShowStyleBase, - UIShowStyleBaseArgs, - UIShowStyleBaseState, - UIShowStyleBaseUpdateProps - >( - `pub_${MeteorPubSub.uiShowStyleBase}_${showStyleBaseId}`, - { showStyleBaseId }, - setupUIShowStyleBasePublicationObservers, - manipulateUIShowStyleBasePublicationData, - pub - ) - } else { - logger.warn(`Pub.${CustomCollectionName.UIShowStyleBase}: Not allowed: "${showStyleBaseId}"`) - } + await setUpOptimizedObserverArray< + UIShowStyleBase, + UIShowStyleBaseArgs, + UIShowStyleBaseState, + UIShowStyleBaseUpdateProps + >( + `pub_${MeteorPubSub.uiShowStyleBase}_${showStyleBaseId}`, + { showStyleBaseId }, + setupUIShowStyleBasePublicationObservers, + manipulateUIShowStyleBasePublicationData, + pub + ) } ) diff --git a/meteor/server/publications/studio.ts b/meteor/server/publications/studio.ts index 08002e6938..633f2bd393 100644 --- a/meteor/server/publications/studio.ts +++ b/meteor/server/publications/studio.ts @@ -1,13 +1,9 @@ import { Meteor } from 'meteor/meteor' import { check, Match } from '../lib/check' -import { meteorPublish, AutoFillSelector } from './lib/lib' +import { meteorPublish } from './lib/lib' import { MeteorPubSub } from '@sofie-automation/meteor-lib/dist/api/pubsub' import { getActiveRoutes, getRoutedMappings } from '@sofie-automation/meteor-lib/dist/collections/Studios' -import { PeripheralDeviceReadAccess } from '../security/peripheralDevice' import { ExternalMessageQueueObj } from '@sofie-automation/corelib/dist/dataModel/ExternalMessageQueue' -import { StudioReadAccess } from '../security/studio' -import { OrganizationReadAccess } from '../security/organization' -import { NoSecurityReadAccess } from '../security/noSecurity' import { CustomPublish, meteorCustomPublish, @@ -26,7 +22,6 @@ import { ExternalMessageQueue, PackageContainerStatuses, PackageInfos, - PeripheralDevices, Studios, } from '../collections' import { MongoQuery } from '@sofie-automation/corelib/dist/mongo' @@ -37,94 +32,85 @@ import { PeripheralDevicePubSub, PeripheralDevicePubSubCollectionsNames, } from '@sofie-automation/shared-lib/dist/pubsub/peripheralDevice' +import { triggerWriteAccessBecauseNoCheckNecessary } from '../security/securityVerify' +import { checkAccessAndGetPeripheralDevice } from '../security/check' +import { assertConnectionHasOneOfPermissions } from '../security/auth' -meteorPublish(CorelibPubSub.studios, async function (studioIds: StudioId[] | null, token: string | undefined) { +meteorPublish(CorelibPubSub.studios, async function (studioIds: StudioId[] | null, _token: string | undefined) { check(studioIds, Match.Maybe(Array)) + triggerWriteAccessBecauseNoCheckNecessary() + // If values were provided, they must have values if (studioIds && studioIds.length === 0) return null - const { cred, selector } = await AutoFillSelector.organizationId(this.userId, {}, token) - // Add the requested filter + const selector: MongoQuery = {} if (studioIds) selector._id = { $in: studioIds } - if ( - !cred || - NoSecurityReadAccess.any() || - (selector._id && (await StudioReadAccess.studio(selector._id, cred))) || - (selector.organizationId && (await OrganizationReadAccess.organizationContent(selector.organizationId, cred))) - ) { - return Studios.findWithCursor(selector) - } - return null + return Studios.findWithCursor(selector) }) meteorPublish( CorelibPubSub.externalMessageQueue, - async function (selector: MongoQuery, token: string | undefined) { + async function (selector: MongoQuery, _token: string | undefined) { + triggerWriteAccessBecauseNoCheckNecessary() + if (!selector) throw new Meteor.Error(400, 'selector argument missing') const modifier: FindOptions = { fields: {}, } - if (await StudioReadAccess.studioContent(selector.studioId, { userId: this.userId, token })) { - return ExternalMessageQueue.findWithCursor(selector, modifier) - } - return null + + return ExternalMessageQueue.findWithCursor(selector, modifier) } ) -meteorPublish(CorelibPubSub.expectedPackages, async function (studioIds: StudioId[], token: string | undefined) { +meteorPublish(CorelibPubSub.expectedPackages, async function (studioIds: StudioId[], _token: string | undefined) { // Note: This differs from the expected packages sent to the Package Manager, instead @see PubSub.expectedPackagesForDevice check(studioIds, Array) + triggerWriteAccessBecauseNoCheckNecessary() + if (studioIds.length === 0) return null - if (await StudioReadAccess.studioContent(studioIds, { userId: this.userId, token })) { - return ExpectedPackages.findWithCursor({ - studioId: { $in: studioIds }, - }) - } - return null + return ExpectedPackages.findWithCursor({ + studioId: { $in: studioIds }, + }) }) meteorPublish( CorelibPubSub.expectedPackageWorkStatuses, - async function (studioIds: StudioId[], token: string | undefined) { + async function (studioIds: StudioId[], _token: string | undefined) { check(studioIds, Array) + triggerWriteAccessBecauseNoCheckNecessary() if (studioIds.length === 0) return null - if (await StudioReadAccess.studioContent(studioIds, { userId: this.userId, token })) { - return ExpectedPackageWorkStatuses.findWithCursor({ - studioId: { $in: studioIds }, - }) - } - return null + return ExpectedPackageWorkStatuses.findWithCursor({ + studioId: { $in: studioIds }, + }) } ) meteorPublish( CorelibPubSub.packageContainerStatuses, - async function (studioIds: StudioId[], token: string | undefined) { + async function (studioIds: StudioId[], _token: string | undefined) { check(studioIds, Array) + triggerWriteAccessBecauseNoCheckNecessary() + if (studioIds.length === 0) return null - if (await StudioReadAccess.studioContent(studioIds, { userId: this.userId, token })) { - return PackageContainerStatuses.findWithCursor({ - studioId: { $in: studioIds }, - }) - } - return null + return PackageContainerStatuses.findWithCursor({ + studioId: { $in: studioIds }, + }) } ) -meteorPublish(CorelibPubSub.packageInfos, async function (deviceId: PeripheralDeviceId, token: string | undefined) { - if (!deviceId) throw new Meteor.Error(400, 'deviceId argument missing') +meteorPublish(CorelibPubSub.packageInfos, async function (deviceId: PeripheralDeviceId, _token: string | undefined) { + check(deviceId, String) - if (await PeripheralDeviceReadAccess.peripheralDeviceContent(deviceId, { userId: this.userId, token })) { - return PackageInfos.findWithCursor({ deviceId }) - } - return null + triggerWriteAccessBecauseNoCheckNecessary() + + return PackageInfos.findWithCursor({ deviceId }) }) meteorCustomPublish( @@ -133,28 +119,24 @@ meteorCustomPublish( async function (pub, deviceId: PeripheralDeviceId, token: string | undefined) { check(deviceId, String) - if (await PeripheralDeviceReadAccess.peripheralDeviceContent(deviceId, { userId: this.userId, token })) { - const peripheralDevice = await PeripheralDevices.findOneAsync(deviceId) - - if (!peripheralDevice) throw new Meteor.Error('PeripheralDevice "' + deviceId + '" not found') + const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, token, this) - const studioId = peripheralDevice.studioId - if (!studioId) return + const studioId = peripheralDevice.studioId + if (!studioId) return - await createObserverForMappingsPublication(pub, studioId) - } + await createObserverForMappingsPublication(pub, studioId) } ) meteorCustomPublish( MeteorPubSub.mappingsForStudio, PeripheralDevicePubSubCollectionsNames.studioMappings, - async function (pub, studioId: StudioId, token: string | undefined) { + async function (pub, studioId: StudioId, _token: string | undefined) { check(studioId, String) - if (await StudioReadAccess.studio(studioId, { userId: this.userId, token })) { - await createObserverForMappingsPublication(pub, studioId) - } + assertConnectionHasOneOfPermissions(this.connection, 'testing') + + await createObserverForMappingsPublication(pub, studioId) } ) diff --git a/meteor/server/publications/studioUI.ts b/meteor/server/publications/studioUI.ts index b8de6f1b7d..d91037896f 100644 --- a/meteor/server/publications/studioUI.ts +++ b/meteor/server/publications/studioUI.ts @@ -13,12 +13,9 @@ import { SetupObserversResult, TriggerUpdate, } from '../lib/customPublication' -import { logger } from '../logging' -import { resolveCredentials } from '../security/lib/credentials' -import { NoSecurityReadAccess } from '../security/noSecurity' -import { StudioReadAccess } from '../security/studio' import { Studios } from '../collections' import { check, Match } from 'meteor/check' +import { triggerWriteAccessBecauseNoCheckNecessary } from '../security/securityVerify' interface UIStudioArgs { readonly studioId: StudioId | null @@ -131,18 +128,14 @@ meteorCustomPublish( async function (pub, studioId: StudioId | null) { check(studioId, Match.Maybe(String)) - const cred = await resolveCredentials({ userId: this.userId, token: undefined }) + triggerWriteAccessBecauseNoCheckNecessary() - if (!cred || NoSecurityReadAccess.any() || (studioId && (await StudioReadAccess.studio(studioId, cred)))) { - await setUpCollectionOptimizedObserver( - `pub_${MeteorPubSub.uiStudio}_${studioId}`, - { studioId }, - setupUIStudioPublicationObservers, - manipulateUIStudioPublicationData, - pub - ) - } else { - logger.warn(`Pub.${CustomCollectionName.UIStudio}: Not allowed: "${studioId}"`) - } + await setUpCollectionOptimizedObserver( + `pub_${MeteorPubSub.uiStudio}_${studioId}`, + { studioId }, + setupUIStudioPublicationObservers, + manipulateUIStudioPublicationData, + pub + ) } ) diff --git a/meteor/server/publications/system.ts b/meteor/server/publications/system.ts index e494807e85..17447bb0ad 100644 --- a/meteor/server/publications/system.ts +++ b/meteor/server/publications/system.ts @@ -1,84 +1,33 @@ -import { Meteor } from 'meteor/meteor' import { meteorPublish } from './lib/lib' import { MeteorPubSub } from '@sofie-automation/meteor-lib/dist/api/pubsub' -import { SystemReadAccess } from '../security/system' -import { OrganizationReadAccess } from '../security/organization' -import { CoreSystem, Notifications, Users } from '../collections' +import { CoreSystem, Notifications } from '../collections' import { SYSTEM_ID } from '@sofie-automation/meteor-lib/dist/collections/CoreSystem' -import { OrganizationId, RundownId, RundownPlaylistId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { triggerWriteAccessBecauseNoCheckNecessary } from '../security/lib/securityVerify' +import { RundownId, RundownPlaylistId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { triggerWriteAccessBecauseNoCheckNecessary } from '../security/securityVerify' import { check } from 'meteor/check' -meteorPublish(MeteorPubSub.coreSystem, async function (token: string | undefined) { - if (await SystemReadAccess.coreSystem({ userId: this.userId, token })) { - return CoreSystem.findWithCursor(SYSTEM_ID, { - fields: { - // Include only specific fields in the result documents: - _id: 1, - support: 1, - systemInfo: 1, - apm: 1, - name: 1, - logLevel: 1, - serviceMessages: 1, - blueprintId: 1, - cron: 1, - logo: 1, - evaluations: 1, - }, - }) - } - return null -}) - -meteorPublish(MeteorPubSub.loggedInUser, async function (token: string | undefined) { - const currentUserId = this.userId +meteorPublish(MeteorPubSub.coreSystem, async function (_token: string | undefined) { + triggerWriteAccessBecauseNoCheckNecessary() - if (!currentUserId) return null - if (await SystemReadAccess.currentUser(currentUserId, { userId: this.userId, token })) { - return Users.findWithCursor( - { - _id: currentUserId, - }, - { - fields: { - _id: 1, - username: 1, - emails: 1, - profile: 1, - organizationId: 1, - superAdmin: 1, - }, - } - ) - } - return null + return CoreSystem.findWithCursor(SYSTEM_ID, { + fields: { + // Include only specific fields in the result documents: + _id: 1, + support: 1, + systemInfo: 1, + apm: 1, + name: 1, + logLevel: 1, + serviceMessages: 1, + blueprintId: 1, + cron: 1, + logo: 1, + evaluations: 1, + }, + }) }) -meteorPublish( - MeteorPubSub.usersInOrganization, - async function (organizationId: OrganizationId, token: string | undefined) { - if (!organizationId) throw new Meteor.Error(400, 'organizationId argument missing') - if (await OrganizationReadAccess.adminUsers(organizationId, { userId: this.userId, token })) { - return Users.findWithCursor( - { organizationId }, - { - fields: { - _id: 1, - username: 1, - emails: 1, - profile: 1, - organizationId: 1, - superAdmin: 1, - }, - } - ) - } - return null - } -) meteorPublish(MeteorPubSub.notificationsForRundown, async function (studioId: StudioId, rundownId: RundownId) { - // HACK: This should do real auth triggerWriteAccessBecauseNoCheckNecessary() check(studioId, String) @@ -94,7 +43,6 @@ meteorPublish(MeteorPubSub.notificationsForRundown, async function (studioId: St meteorPublish( MeteorPubSub.notificationsForRundownPlaylist, async function (studioId: StudioId, playlistId: RundownPlaylistId) { - // HACK: This should do real auth triggerWriteAccessBecauseNoCheckNecessary() check(studioId, String) diff --git a/meteor/server/publications/timeline.ts b/meteor/server/publications/timeline.ts index 15cf679157..c32c42b938 100644 --- a/meteor/server/publications/timeline.ts +++ b/meteor/server/publications/timeline.ts @@ -19,8 +19,6 @@ import { TriggerUpdate, } from '../lib/customPublication' import { getActiveRoutes } from '@sofie-automation/meteor-lib/dist/collections/Studios' -import { PeripheralDeviceReadAccess } from '../security/peripheralDevice' -import { StudioReadAccess } from '../security/studio' import { fetchStudioLight } from '../optimizations' import { FastTrackObservers, setupFastTrackObserver } from './fastTrack' import { logger } from '../logging' @@ -29,7 +27,7 @@ import { Time } from '../lib/tempLib' import { ReadonlyDeep } from 'type-fest' import { PeripheralDeviceId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { DBTimelineDatastoreEntry } from '@sofie-automation/corelib/dist/dataModel/TimelineDatastore' -import { PeripheralDevices, Studios, Timeline, TimelineDatastore } from '../collections' +import { Studios, Timeline, TimelineDatastore } from '../collections' import { check } from 'meteor/check' import { ResultingMappingRoutes, StudioLight } from '@sofie-automation/corelib/dist/dataModel/Studio' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' @@ -38,16 +36,18 @@ import { PeripheralDevicePubSubCollectionsNames, } from '@sofie-automation/shared-lib/dist/pubsub/peripheralDevice' import { applyAndValidateOverrides } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' +import { checkAccessAndGetPeripheralDevice } from '../security/check' +import { assertConnectionHasOneOfPermissions } from '../security/auth' + +meteorPublish(CorelibPubSub.timelineDatastore, async function (studioId: StudioId, _token: string | undefined) { + assertConnectionHasOneOfPermissions(this.connection, 'testing') -meteorPublish(CorelibPubSub.timelineDatastore, async function (studioId: StudioId, token: string | undefined) { if (!studioId) throw new Meteor.Error(400, 'selector argument missing') const modifier: FindOptions = { fields: {}, } - if (await StudioReadAccess.studioContent(studioId, { userId: this.userId, token })) { - return TimelineDatastore.findWithCursor({ studioId }, modifier) - } - return null + + return TimelineDatastore.findWithCursor({ studioId }, modifier) }) meteorCustomPublish( @@ -56,16 +56,12 @@ meteorCustomPublish( async function (pub, deviceId: PeripheralDeviceId, token: string | undefined) { check(deviceId, String) - if (await PeripheralDeviceReadAccess.peripheralDeviceContent(deviceId, { userId: this.userId, token })) { - const peripheralDevice = await PeripheralDevices.findOneAsync(deviceId) + const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, token, this) - if (!peripheralDevice) throw new Meteor.Error('PeripheralDevice "' + deviceId + '" not found') + const studioId = peripheralDevice.studioId + if (!studioId) return - const studioId = peripheralDevice.studioId - if (!studioId) return - - await createObserverForTimelinePublication(pub, studioId) - } + await createObserverForTimelinePublication(pub, studioId) } ) meteorPublish( @@ -73,30 +69,26 @@ meteorPublish( async function (deviceId: PeripheralDeviceId, token: string | undefined) { check(deviceId, String) - if (await PeripheralDeviceReadAccess.peripheralDeviceContent(deviceId, { userId: this.userId, token })) { - const peripheralDevice = await PeripheralDevices.findOneAsync(deviceId) + const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, token, this) - if (!peripheralDevice) throw new Meteor.Error('PeripheralDevice "' + deviceId + '" not found') + const studioId = peripheralDevice.studioId + if (!studioId) return null - const studioId = peripheralDevice.studioId - if (!studioId) return null - const modifier: FindOptions = { - fields: {}, - } - - return TimelineDatastore.findWithCursor({ studioId }, modifier) + const modifier: FindOptions = { + fields: {}, } - return null + + return TimelineDatastore.findWithCursor({ studioId }, modifier) } ) meteorCustomPublish( MeteorPubSub.timelineForStudio, PeripheralDevicePubSubCollectionsNames.studioTimeline, - async function (pub, studioId: StudioId, token: string | undefined) { - if (await StudioReadAccess.studio(studioId, { userId: this.userId, token })) { - await createObserverForTimelinePublication(pub, studioId) - } + async function (pub, studioId: StudioId, _token: string | undefined) { + assertConnectionHasOneOfPermissions(this.connection, 'testing') + + await createObserverForTimelinePublication(pub, studioId) } ) diff --git a/meteor/server/publications/translationsBundles.ts b/meteor/server/publications/translationsBundles.ts index 8173fd3ec5..fbb2d625fd 100644 --- a/meteor/server/publications/translationsBundles.ts +++ b/meteor/server/publications/translationsBundles.ts @@ -1,20 +1,18 @@ -import { TranslationsBundlesSecurity } from '../security/translationsBundles' import { meteorPublish } from './lib/lib' import { MeteorPubSub } from '@sofie-automation/meteor-lib/dist/api/pubsub' import { TranslationsBundles } from '../collections' import { MongoQuery } from '@sofie-automation/corelib/dist/mongo' import { TranslationsBundle } from '@sofie-automation/meteor-lib/dist/collections/TranslationsBundles' +import { triggerWriteAccessBecauseNoCheckNecessary } from '../security/securityVerify' -meteorPublish(MeteorPubSub.translationsBundles, async (token: string | undefined) => { +meteorPublish(MeteorPubSub.translationsBundles, async (_token: string | undefined) => { const selector: MongoQuery = {} - if (TranslationsBundlesSecurity.allowReadAccess(selector, token, this)) { - return TranslationsBundles.findWithCursor(selector, { - fields: { - data: 0, - }, - }) - } + triggerWriteAccessBecauseNoCheckNecessary() - return null + return TranslationsBundles.findWithCursor(selector, { + fields: { + data: 0, + }, + }) }) diff --git a/meteor/server/publications/triggeredActionsUI.ts b/meteor/server/publications/triggeredActionsUI.ts index 5a431daf10..6eaeb0f52e 100644 --- a/meteor/server/publications/triggeredActionsUI.ts +++ b/meteor/server/publications/triggeredActionsUI.ts @@ -14,13 +14,10 @@ import { SetupObserversResult, TriggerUpdate, } from '../lib/customPublication' -import { logger } from '../logging' -import { resolveCredentials } from '../security/lib/credentials' -import { NoSecurityReadAccess } from '../security/noSecurity' -import { ShowStyleReadAccess } from '../security/showStyle' import { TriggeredActions } from '../collections' import { check, Match } from 'meteor/check' import { MongoQuery } from '@sofie-automation/corelib/dist/mongo' +import { triggerWriteAccessBecauseNoCheckNecessary } from '../security/securityVerify' interface UITriggeredActionsArgs { readonly showStyleBaseId: ShowStyleBaseId | null @@ -114,27 +111,19 @@ meteorCustomPublish( async function (pub, showStyleBaseId: ShowStyleBaseId | null) { check(showStyleBaseId, Match.Maybe(String)) - const cred = await resolveCredentials({ userId: this.userId, token: undefined }) - - if ( - !cred || - NoSecurityReadAccess.any() || - (showStyleBaseId && (await ShowStyleReadAccess.showStyleBase(showStyleBaseId, cred))) - ) { - await setUpCollectionOptimizedObserver< - UITriggeredActionsObj, - UITriggeredActionsArgs, - UITriggeredActionsState, - UITriggeredActionsUpdateProps - >( - `pub_${MeteorPubSub.uiTriggeredActions}_${showStyleBaseId}`, - { showStyleBaseId }, - setupUITriggeredActionsPublicationObservers, - manipulateUITriggeredActionsPublicationData, - pub - ) - } else { - logger.warn(`Pub.${CustomCollectionName.UITriggeredActions}: Not allowed: "${showStyleBaseId}"`) - } + triggerWriteAccessBecauseNoCheckNecessary() + + await setUpCollectionOptimizedObserver< + UITriggeredActionsObj, + UITriggeredActionsArgs, + UITriggeredActionsState, + UITriggeredActionsUpdateProps + >( + `pub_${MeteorPubSub.uiTriggeredActions}_${showStyleBaseId}`, + { showStyleBaseId }, + setupUITriggeredActionsPublicationObservers, + manipulateUITriggeredActionsPublicationData, + pub + ) } ) diff --git a/meteor/server/security/README.md b/meteor/server/security/README.md deleted file mode 100644 index b66a4adb58..0000000000 --- a/meteor/server/security/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# Data Ownership: - -## System - -- CoreSystem -- Users -- **Organizations** - -## Organization - -- UserActionsLog -- Evaluations -- Snapshots -- Blueprints -- **Studios** -- **ShowStyleBases** -- **PeripheralDevices** - -## ShowStyleBase - -- ShowStyleVariants -- RundownLayouts - -## Studio - -- ExternalMessageQueue -- RecordedFiles -- MediaObjects -- Timeline -- **RundownPlaylists** - -## RundownPlaylist - -- Rundowns - -## Rundown - -- Segments -- Parts -- PartInstances -- Pieces -- PieceInstances -- AdLibPieces -- RundownBaselineAdLibPieces -- IngestDataCache -- ExpectedMediaItems -- ExpectedPlayoutItems - -## PeripheralDevice - -- PeripheralDeviceCommands -- MediaWorkFlowSteps -- MediaWorkFlows diff --git a/meteor/server/security/__tests__/security.test.ts b/meteor/server/security/__tests__/security.test.ts deleted file mode 100644 index 595791d812..0000000000 --- a/meteor/server/security/__tests__/security.test.ts +++ /dev/null @@ -1,358 +0,0 @@ -import '../../../__mocks__/_extendJest' - -import { MethodContext } from '../../api/methodContext' -import { DBOrganization } from '@sofie-automation/meteor-lib/dist/collections/Organization' -import { User } from '@sofie-automation/meteor-lib/dist/collections/Users' -import { protectString } from '../../lib/tempLib' -import { Settings } from '../../Settings' -import { DefaultEnvironment, setupDefaultStudioEnvironment } from '../../../__mocks__/helpers/database' -import { BucketsAPI } from '../../api/buckets' -import { storeSystemSnapshot } from '../../api/snapshot' -import { BucketSecurity } from '../buckets' -import { Credentials } from '../lib/credentials' -import { NoSecurityReadAccess } from '../noSecurity' -import { OrganizationContentWriteAccess, OrganizationReadAccess } from '../organization' -import { StudioContentWriteAccess } from '../studio' -import { OrganizationId, UserId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { Organizations, Users } from '../../collections' -import { SupressLogMessages } from '../../../__mocks__/suppressLogging' -import { generateToken } from '../../api/singleUseTokens' -import { hashSingleUseToken } from '../../api/deviceTriggers/triggersContext' - -describe('Security', () => { - function getContext(cred: Credentials): MethodContext { - return { - ...cred, - - isSimulation: false, - connection: null, - setUserId: (_userId: string) => { - // Nothing - }, - unblock: () => { - // Nothing - }, - } - } - function getUser(userId: UserId, orgId: OrganizationId): User { - return { - _id: userId, - organizationId: orgId, - - createdAt: '', - services: { - password: { - bcrypt: 'abc', - }, - }, - username: 'username', - emails: [{ address: 'email.com', verified: false }], - profile: { - name: 'John Doe', - }, - } - } - function getOrg(id: string): DBOrganization { - return { - _id: protectString(id), - name: 'The Company', - - userRoles: { - userA: { - admin: true, - }, - }, - - created: 0, - modified: 0, - - applications: [], - broadcastMediums: [], - } - } - async function changeEnableUserAccounts(fcn: () => Promise) { - try { - Settings.enableUserAccounts = false - await fcn() - Settings.enableUserAccounts = true - await fcn() - } catch (e) { - console.log(`Error happened when Settings.enableUserAccounts = ${Settings.enableUserAccounts}`) - throw e - } - } - - const idCreator: UserId = protectString('userCreator') - const idUserB: UserId = protectString('userB') - const idNonExisting: UserId = protectString('userNonExistant') - const idInWrongOrg: UserId = protectString('userInWrongOrg') - const idSuperAdmin: UserId = protectString('userSuperAdmin') - const idSuperAdminInOtherOrg: UserId = protectString('userSuperAdminOther') - - // Credentials for various users: - const nothing: MethodContext = getContext({ userId: null }) - const creator: MethodContext = getContext({ userId: idCreator }) - const userB: MethodContext = getContext({ userId: idUserB }) - const nonExisting: MethodContext = getContext({ userId: idNonExisting }) - const wrongOrg: MethodContext = getContext({ userId: idInWrongOrg }) - const superAdmin: MethodContext = getContext({ userId: idSuperAdmin }) - const otherSuperAdmin: MethodContext = getContext({ userId: idSuperAdminInOtherOrg }) - - const unknownId = protectString('unknown') - - const org0: DBOrganization = getOrg('org0') - const org1: DBOrganization = getOrg('org1') - const org2: DBOrganization = getOrg('org2') - - async function expectReadNotAllowed(fcn: () => Promise) { - if (Settings.enableUserAccounts === false) return expectReadAllowed(fcn) - return expect(fcn()).resolves.toEqual(false) - } - async function expectReadAllowed(fcn: () => Promise) { - return expect(fcn()).resolves.toEqual(true) - } - async function expectNotAllowed(fcn: () => Promise) { - if (Settings.enableUserAccounts === false) return expectAllowed(fcn) - return expect(fcn()).rejects.toBeTruthy() - } - async function expectNotLoggedIn(fcn: () => Promise) { - if (Settings.enableUserAccounts === false) return expectAllowed(fcn) - return expect(fcn()).rejects.toMatchToString(/not logged in/i) - } - async function expectNotFound(fcn: () => Promise) { - // if (Settings.enableUserAccounts === false) return expectAllowed(fcn) - return expect(fcn()).rejects.toMatchToString(/not found/i) - } - async function expectAllowed(fcn: () => Promise) { - return expect(fcn()).resolves.not.toBeUndefined() - } - let env: DefaultEnvironment - beforeAll(async () => { - env = await setupDefaultStudioEnvironment(org0._id) - - await Organizations.insertAsync(org0) - await Organizations.insertAsync(org1) - await Organizations.insertAsync(org2) - - await Users.insertAsync(getUser(idCreator, org0._id)) - await Users.insertAsync(getUser(idUserB, org0._id)) - await Users.insertAsync(getUser(idInWrongOrg, org1._id)) - await Users.insertAsync({ ...getUser(idSuperAdmin, org0._id), superAdmin: true }) - await Users.insertAsync({ ...getUser(idSuperAdminInOtherOrg, org2._id), superAdmin: true }) - }) - - // eslint-disable-next-line jest/expect-expect - test('Buckets', async () => { - const access = await StudioContentWriteAccess.bucket(creator, env.studio._id) - const bucket = await BucketsAPI.createNewBucket(access, 'myBucket') - - await changeEnableUserAccounts(async () => { - await expectReadAllowed(async () => BucketSecurity.allowReadAccess(creator, bucket._id)) - await expectAllowed(async () => BucketSecurity.allowWriteAccess(creator, bucket._id)) - // expectAccessAllowed(() => BucketSecurity.allowWriteAccessPiece({ _id: bucket._id }, credUserA)) - - // Unknown bucket: - await expectNotFound(async () => BucketSecurity.allowReadAccess(creator, unknownId)) - await expectNotFound(async () => BucketSecurity.allowWriteAccess(creator, unknownId)) - await expectNotFound(async () => BucketSecurity.allowWriteAccessPiece(creator, unknownId)) - - // Not logged in: - if (Settings.enableUserAccounts) { - SupressLogMessages.suppressLogMessage(/No organization in credentials/i) - } - await expectReadNotAllowed(async () => BucketSecurity.allowReadAccess(nothing, bucket._id)) - await expectNotLoggedIn(async () => BucketSecurity.allowWriteAccess(nothing, bucket._id)) - // expectAccessNotLoggedIn(() => BucketSecurity.allowWriteAccessPiece({ _id: bucket._id }, credNothing)) - - // Non existing user: - if (Settings.enableUserAccounts) { - SupressLogMessages.suppressLogMessage(/No organization in credentials/i) - } - await expectReadNotAllowed(async () => BucketSecurity.allowReadAccess(nonExisting, bucket._id)) - await expectNotLoggedIn(async () => BucketSecurity.allowWriteAccess(nonExisting, bucket._id)) - // expectAccess(() => BucketSecurity.allowWriteAccessPiece({ _id: bucket._id }, credNonExistingUser)) - - // Other user in same org: - await expectReadAllowed(async () => BucketSecurity.allowReadAccess(userB, bucket._id)) - await expectAllowed(async () => BucketSecurity.allowWriteAccess(userB, bucket._id)) - // expectAccess(() => BucketSecurity.allowWriteAccessPiece({ _id: bucket._id }, credUserB)) - - // Other user in other org: - if (Settings.enableUserAccounts) { - SupressLogMessages.suppressLogMessage(/User is not in the same organization as the studio/i) - } - await expectReadNotAllowed(async () => BucketSecurity.allowReadAccess(wrongOrg, bucket._id)) - await expectNotAllowed(async () => BucketSecurity.allowWriteAccess(wrongOrg, bucket._id)) - // expectAccess(() => BucketSecurity.allowWriteAccessPiece({ _id: bucket._id }, credUserInWrongOrganization)) - }) - }) - - // eslint-disable-next-line jest/expect-expect - test('NoSecurity', async () => { - await changeEnableUserAccounts(async () => { - await expectAllowed(async () => NoSecurityReadAccess.any()) - }) - }) - // eslint-disable-next-line jest/expect-expect - test('Organization', async () => { - const token = generateToken() - const snapshotId = await storeSystemSnapshot(superAdmin, hashSingleUseToken(token), env.studio._id, 'for test') - - await changeEnableUserAccounts(async () => { - const selectorId = org0._id - const selectorOrg = { organizationId: org0._id } - - // === Read access: === - - // No user credentials: - if (Settings.enableUserAccounts) { - SupressLogMessages.suppressLogMessage(/No organization in credentials/i) - } - await expectReadNotAllowed(async () => OrganizationReadAccess.adminUsers(selectorId, nothing)) - if (Settings.enableUserAccounts) { - SupressLogMessages.suppressLogMessage(/No organization in credentials/i) - } - await expectReadNotAllowed(async () => OrganizationReadAccess.organization(selectorId, nothing)) - if (Settings.enableUserAccounts) { - SupressLogMessages.suppressLogMessage(/No organization in credentials/i) - } - await expectReadNotAllowed(async () => OrganizationReadAccess.organizationContent(selectorId, nothing)) - // Normal user: - await expectReadAllowed(async () => OrganizationReadAccess.adminUsers(selectorId, creator)) - await expectReadAllowed(async () => OrganizationReadAccess.organization(selectorId, creator)) - await expectReadAllowed(async () => OrganizationReadAccess.organizationContent(selectorId, creator)) - // Other normal user: - await expectReadAllowed(async () => OrganizationReadAccess.adminUsers(selectorId, userB)) - await expectReadAllowed(async () => OrganizationReadAccess.organization(selectorId, userB)) - await expectReadAllowed(async () => OrganizationReadAccess.organizationContent(selectorId, userB)) - // Non-existing user: - if (Settings.enableUserAccounts) { - SupressLogMessages.suppressLogMessage(/No organization in credentials/i) - } - await expectReadNotAllowed(async () => OrganizationReadAccess.adminUsers(selectorId, nonExisting)) - if (Settings.enableUserAccounts) { - SupressLogMessages.suppressLogMessage(/No organization in credentials/i) - } - await expectReadNotAllowed(async () => OrganizationReadAccess.organization(selectorId, nonExisting)) - if (Settings.enableUserAccounts) { - SupressLogMessages.suppressLogMessage(/No organization in credentials/i) - } - await expectReadNotAllowed(async () => OrganizationReadAccess.organizationContent(selectorId, nonExisting)) - // User in wrong organization: - if (Settings.enableUserAccounts) { - SupressLogMessages.suppressLogMessage(/User is not in the organization/i) - } - await expectReadNotAllowed(async () => OrganizationReadAccess.adminUsers(selectorId, wrongOrg)) - if (Settings.enableUserAccounts) { - SupressLogMessages.suppressLogMessage(/User is not in the organization/i) - } - await expectReadNotAllowed(async () => OrganizationReadAccess.organization(selectorId, wrongOrg)) - if (Settings.enableUserAccounts) { - SupressLogMessages.suppressLogMessage(/User is not in the organization/i) - } - await expectReadNotAllowed(async () => OrganizationReadAccess.organizationContent(selectorId, wrongOrg)) - // SuperAdmin: - if (Settings.enableUserAccounts) { - SupressLogMessages.suppressLogMessage(/User is not in the organization/i) - } - await expectReadNotAllowed(async () => OrganizationReadAccess.adminUsers(selectorId, otherSuperAdmin)) - if (Settings.enableUserAccounts) { - SupressLogMessages.suppressLogMessage(/User is not in the organization/i) - } - await expectReadNotAllowed(async () => OrganizationReadAccess.organization(selectorId, otherSuperAdmin)) - if (Settings.enableUserAccounts) { - SupressLogMessages.suppressLogMessage(/User is not in the organization/i) - } - await expectReadNotAllowed(async () => - OrganizationReadAccess.organizationContent(selectorId, otherSuperAdmin) - ) - - // === Write access: === - - // No user credentials: - await expectNotLoggedIn(async () => OrganizationContentWriteAccess.organization(nothing, org0._id)) - await expectNotLoggedIn(async () => OrganizationContentWriteAccess.studio(nothing, env.studio)) - await expectNotLoggedIn(async () => OrganizationContentWriteAccess.evaluation(nothing)) - await expectNotLoggedIn(async () => OrganizationContentWriteAccess.mediaWorkFlows(nothing)) - await expectNotLoggedIn(async () => - OrganizationContentWriteAccess.blueprint(nothing, env.studioBlueprint._id) - ) - await expectNotLoggedIn(async () => OrganizationContentWriteAccess.snapshot(nothing, snapshotId)) - await expectNotLoggedIn(async () => OrganizationContentWriteAccess.dataFromSnapshot(nothing, org0._id)) - await expectNotLoggedIn(async () => - OrganizationContentWriteAccess.showStyleBase(nothing, env.showStyleBaseId) - ) - await expectNotLoggedIn(async () => OrganizationContentWriteAccess.translationBundle(nothing, selectorOrg)) - - // Normal user: - await expectAllowed(async () => OrganizationContentWriteAccess.organization(creator, org0._id)) - await expectAllowed(async () => OrganizationContentWriteAccess.studio(creator, env.studio)) - await expectAllowed(async () => OrganizationContentWriteAccess.evaluation(creator)) - await expectAllowed(async () => OrganizationContentWriteAccess.mediaWorkFlows(creator)) - await expectAllowed(async () => OrganizationContentWriteAccess.blueprint(creator, env.studioBlueprint._id)) - await expectAllowed(async () => OrganizationContentWriteAccess.snapshot(creator, snapshotId)) - await expectAllowed(async () => OrganizationContentWriteAccess.dataFromSnapshot(creator, org0._id)) - await expectAllowed(async () => OrganizationContentWriteAccess.showStyleBase(creator, env.showStyleBaseId)) - await expectAllowed(async () => OrganizationContentWriteAccess.translationBundle(creator, selectorOrg)) - // Other normal user: - await expectAllowed(async () => OrganizationContentWriteAccess.organization(userB, org0._id)) - await expectAllowed(async () => OrganizationContentWriteAccess.studio(userB, env.studio)) - await expectAllowed(async () => OrganizationContentWriteAccess.evaluation(userB)) - await expectAllowed(async () => OrganizationContentWriteAccess.mediaWorkFlows(userB)) - await expectAllowed(async () => OrganizationContentWriteAccess.blueprint(userB, env.studioBlueprint._id)) - await expectAllowed(async () => OrganizationContentWriteAccess.snapshot(userB, snapshotId)) - await expectAllowed(async () => OrganizationContentWriteAccess.dataFromSnapshot(userB, org0._id)) - await expectAllowed(async () => OrganizationContentWriteAccess.showStyleBase(userB, env.showStyleBaseId)) - await expectAllowed(async () => OrganizationContentWriteAccess.translationBundle(userB, selectorOrg)) - // Non-existing user: - await expectNotLoggedIn(async () => OrganizationContentWriteAccess.organization(nonExisting, org0._id)) - await expectNotLoggedIn(async () => OrganizationContentWriteAccess.studio(nonExisting, env.studio)) - await expectNotLoggedIn(async () => OrganizationContentWriteAccess.evaluation(nonExisting)) - await expectNotLoggedIn(async () => OrganizationContentWriteAccess.mediaWorkFlows(nonExisting)) - await expectNotLoggedIn(async () => - OrganizationContentWriteAccess.blueprint(nonExisting, env.studioBlueprint._id) - ) - await expectNotLoggedIn(async () => OrganizationContentWriteAccess.snapshot(nonExisting, snapshotId)) - await expectNotLoggedIn(async () => OrganizationContentWriteAccess.dataFromSnapshot(nonExisting, org0._id)) - await expectNotLoggedIn(async () => - OrganizationContentWriteAccess.showStyleBase(nonExisting, env.showStyleBaseId) - ) - await expectNotLoggedIn(async () => - OrganizationContentWriteAccess.translationBundle(nonExisting, selectorOrg) - ) - // User in wrong organization: - await expectNotAllowed(async () => OrganizationContentWriteAccess.organization(wrongOrg, org0._id)) - await expectNotAllowed(async () => OrganizationContentWriteAccess.studio(wrongOrg, env.studio)) - // expectNotAllowed(async() => OrganizationContentWriteAccess.evaluation(wrongOrg)) - // expectNotAllowed(async() => OrganizationContentWriteAccess.mediaWorkFlows(wrongOrg)) - await expectNotAllowed(async () => - OrganizationContentWriteAccess.blueprint(wrongOrg, env.studioBlueprint._id) - ) - await expectNotAllowed(async () => OrganizationContentWriteAccess.snapshot(wrongOrg, snapshotId)) - await expectNotAllowed(async () => OrganizationContentWriteAccess.dataFromSnapshot(wrongOrg, org0._id)) - await expectNotAllowed(async () => - OrganizationContentWriteAccess.showStyleBase(wrongOrg, env.showStyleBaseId) - ) - await expectNotAllowed(async () => OrganizationContentWriteAccess.translationBundle(wrongOrg, selectorOrg)) - - // Other SuperAdmin - await expectNotAllowed(async () => OrganizationContentWriteAccess.organization(otherSuperAdmin, org0._id)) - await expectNotAllowed(async () => OrganizationContentWriteAccess.studio(otherSuperAdmin, env.studio)) - // expectNotAllowed(async() => OrganizationContentWriteAccess.evaluation(otherSuperAdmin)) - // expectNotAllowed(async() => OrganizationContentWriteAccess.mediaWorkFlows(otherSuperAdmin)) - await expectNotAllowed(async () => - OrganizationContentWriteAccess.blueprint(otherSuperAdmin, env.studioBlueprint._id) - ) - await expectNotAllowed(async () => OrganizationContentWriteAccess.snapshot(otherSuperAdmin, snapshotId)) - await expectNotAllowed(async () => - OrganizationContentWriteAccess.dataFromSnapshot(otherSuperAdmin, org0._id) - ) - await expectNotAllowed(async () => - OrganizationContentWriteAccess.showStyleBase(otherSuperAdmin, env.showStyleBaseId) - ) - await expectNotAllowed(async () => - OrganizationContentWriteAccess.translationBundle(otherSuperAdmin, selectorOrg) - ) - }) - }) -}) diff --git a/meteor/server/security/_security.ts b/meteor/server/security/_security.ts deleted file mode 100644 index 320d5b5bbb..0000000000 --- a/meteor/server/security/_security.ts +++ /dev/null @@ -1,11 +0,0 @@ -import './lib/lib' - -import './buckets' -import './noSecurity' -import './organization' -import './peripheralDevice' -import './rundown' -import './rundownPlaylist' -import './showStyle' -import './studio' -import './system' diff --git a/meteor/server/security/lib/lib.ts b/meteor/server/security/allowDeny.ts similarity index 84% rename from meteor/server/security/lib/lib.ts rename to meteor/server/security/allowDeny.ts index a5c0d244d9..089032c9f8 100644 --- a/meteor/server/security/lib/lib.ts +++ b/meteor/server/security/allowDeny.ts @@ -1,5 +1,5 @@ import { FieldNames } from '@sofie-automation/meteor-lib/dist/collections/lib' -import { logger } from '../../logging' + /** * Allow only edits to the fields specified. Edits to any other fields will be rejected * @param doc @@ -32,8 +32,3 @@ export function rejectFields(_doc: T, fieldNames: FieldNames, rejectFields return true } - -export function logNotAllowed(area: string, reason: string): false { - logger.warn(`Not allowed access to ${area}: ${reason}`) - return false -} diff --git a/meteor/server/security/auth.ts b/meteor/server/security/auth.ts new file mode 100644 index 0000000000..9bdfff293c --- /dev/null +++ b/meteor/server/security/auth.ts @@ -0,0 +1,81 @@ +import { + parseUserPermissions, + USER_PERMISSIONS_HEADER, + UserPermissions, +} from '@sofie-automation/meteor-lib/dist/userPermissions' +import { Settings } from '../Settings' +import { Meteor } from 'meteor/meteor' +import Koa from 'koa' +import { triggerWriteAccess } from './securityVerify' +import { UserId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { unprotectString } from '../lib/tempLib' +import { logger } from '../logging' +import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' + +export type RequestCredentials = Meteor.Connection | Koa.ParameterizedContext + +export function parseConnectionPermissions(conn: RequestCredentials): UserPermissions { + if (!Settings.enableHeaderAuth) { + // If auth is disabled, return all permissions + return { + studio: true, + configure: true, + developer: true, + testing: true, + service: true, + gateway: true, + } + } + + let header: string | string[] | undefined + if ('httpHeaders' in conn) { + header = conn.httpHeaders[USER_PERMISSIONS_HEADER] + } else { + header = conn.request.headers[USER_PERMISSIONS_HEADER] + } + + // This shouldn't happen, but take the first header if it does + if (Array.isArray(header)) header = header[0] + + return parseUserPermissions(header) +} + +export function assertConnectionHasOneOfPermissions( + conn: RequestCredentials | null, + ...allowedPermissions: Array +): void { + if (allowedPermissions.length === 0) throw new Meteor.Error(403, 'No permissions specified') + + triggerWriteAccess() + + if (!conn) throw new Meteor.Error(403, 'Can only be invoked by clients') + + const permissions = parseConnectionPermissions(conn) + for (const permission of allowedPermissions) { + if (permissions[permission]) return + } + + // Nothing matched + throw new Meteor.Error(403, 'Not authorized') +} + +export function checkUserIdHasOneOfPermissions( + userId: UserId | null, + collectionName: CollectionName, + ...allowedPermissions: Array +): boolean { + if (allowedPermissions.length === 0) throw new Meteor.Error(403, 'No permissions specified') + + triggerWriteAccess() + + if (!userId) throw new Meteor.Error(403, 'UserId is null') + + const permissions: UserPermissions = JSON.parse(unprotectString(userId)) + for (const permission of allowedPermissions) { + if (permissions[permission]) return true + } + + // Nothing matched + logger.warn(`Not allowed access to ${collectionName}`) + return false +} diff --git a/meteor/server/security/buckets.ts b/meteor/server/security/buckets.ts deleted file mode 100644 index d7160f974c..0000000000 --- a/meteor/server/security/buckets.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { Bucket } from '@sofie-automation/meteor-lib/dist/collections/Buckets' -import { Credentials, ResolvedCredentials } from './lib/credentials' -import { triggerWriteAccess } from './lib/securityVerify' -import { check } from '../lib/check' -import { Meteor } from 'meteor/meteor' -import { StudioReadAccess, StudioContentWriteAccess, StudioContentAccess } from './studio' -import { BucketAdLib } from '@sofie-automation/corelib/dist/dataModel/BucketAdLibPiece' -import { BucketAdLibAction } from '@sofie-automation/corelib/dist/dataModel/BucketAdLibAction' -import { AdLibActionId, BucketId, PieceId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { BucketAdLibActions, BucketAdLibs, Buckets } from '../collections' - -export namespace BucketSecurity { - export interface BucketContentAccess extends StudioContentAccess { - bucket: Bucket - } - export interface BucketAdlibPieceContentAccess extends StudioContentAccess { - adlib: BucketAdLib - } - export interface BucketAdlibActionContentAccess extends StudioContentAccess { - action: BucketAdLibAction - } - - // Sometimes a studio ID is passed, others the peice / bucket id - export async function allowReadAccess( - cred: Credentials | ResolvedCredentials, - bucketId: BucketId - ): Promise { - check(bucketId, String) - - const bucket = await Buckets.findOneAsync(bucketId) - if (!bucket) throw new Meteor.Error(404, `Bucket "${bucketId}" not found!`) - - return StudioReadAccess.studioContent(bucket.studioId, cred) - } - export async function allowWriteAccess(cred: Credentials, bucketId: BucketId): Promise { - triggerWriteAccess() - - check(bucketId, String) - - const bucket = await Buckets.findOneAsync(bucketId) - if (!bucket) throw new Meteor.Error(404, `Bucket "${bucketId}" not found!`) - - return { - ...(await StudioContentWriteAccess.bucket(cred, bucket.studioId)), - bucket, - } - } - export async function allowWriteAccessPiece( - cred: Credentials, - pieceId: PieceId - ): Promise { - triggerWriteAccess() - - check(pieceId, String) - - const bucketAdLib = await BucketAdLibs.findOneAsync(pieceId) - if (!bucketAdLib) throw new Meteor.Error(404, `Bucket AdLib "${pieceId}" not found!`) - - return { - ...(await StudioContentWriteAccess.bucket(cred, bucketAdLib.studioId)), - adlib: bucketAdLib, - } - } - export async function allowWriteAccessAction( - cred: Credentials, - actionId: AdLibActionId - ): Promise { - triggerWriteAccess() - - check(actionId, String) - - const bucketAdLibAction = await BucketAdLibActions.findOneAsync(actionId) - if (!bucketAdLibAction) throw new Meteor.Error(404, `Bucket AdLib Actions "${actionId}" not found!`) - - return { - ...(await StudioContentWriteAccess.bucket(cred, bucketAdLibAction.studioId)), - action: bucketAdLibAction, - } - } -} diff --git a/meteor/server/security/check.ts b/meteor/server/security/check.ts new file mode 100644 index 0000000000..da6d38ad1d --- /dev/null +++ b/meteor/server/security/check.ts @@ -0,0 +1,104 @@ +import { PeripheralDeviceId, RundownId, RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { Meteor } from 'meteor/meteor' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' +import { assertConnectionHasOneOfPermissions, RequestCredentials } from './auth' +import { PeripheralDevices, RundownPlaylists, Rundowns } from '../collections' +import { PeripheralDevice } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' +import { MethodContext } from '../api/methodContext' +import { profiler } from '../api/profiler' +import { SubscriptionContext } from '../publications/lib/lib' + +/** + * Check that the current user has write access to the specified playlist, and ensure that the playlist exists + * @param context + * @param playlistId Id of the playlist + */ +export async function checkAccessToPlaylist( + cred: RequestCredentials | null, + playlistId: RundownPlaylistId +): Promise { + assertConnectionHasOneOfPermissions(cred, 'studio') + + const playlist = (await RundownPlaylists.findOneAsync(playlistId, { + projection: { + _id: 1, + studioId: 1, + organizationId: 1, + name: 1, + }, + })) as Pick | undefined + if (!playlist) throw new Meteor.Error(404, `RundownPlaylist "${playlistId}" not found`) + + return playlist +} +export type VerifiedRundownPlaylistForUserAction = Pick< + DBRundownPlaylist, + '_id' | 'studioId' | 'organizationId' | 'name' +> + +/** + * Check that the current user has write access to the specified rundown, and ensure that the rundown exists + * @param context + * @param rundownId Id of the rundown + */ +export async function checkAccessToRundown( + cred: RequestCredentials | null, + rundownId: RundownId +): Promise { + assertConnectionHasOneOfPermissions(cred, 'studio') + + const rundown = (await Rundowns.findOneAsync(rundownId, { + projection: { + _id: 1, + studioId: 1, + externalId: 1, + showStyleVariantId: 1, + source: 1, + }, + })) as Pick | undefined + if (!rundown) throw new Meteor.Error(404, `Rundown "${rundownId}" not found`) + + return rundown +} +export type VerifiedRundownForUserAction = Pick< + DBRundown, + '_id' | 'studioId' | 'externalId' | 'showStyleVariantId' | 'source' +> + +/** Check Access and return PeripheralDevice, throws otherwise */ +export async function checkAccessAndGetPeripheralDevice( + deviceId: PeripheralDeviceId, + token: string | undefined, + context: MethodContext | SubscriptionContext +): Promise { + const span = profiler.startSpan('lib.checkAccessAndGetPeripheralDevice') + + assertConnectionHasOneOfPermissions(context.connection, 'gateway') + + // If no token, we will never match + if (!token) throw new Meteor.Error(401, `Not allowed access to peripheralDevice`) + + const device = await PeripheralDevices.findOneAsync({ _id: deviceId }) + if (!device) throw new Meteor.Error(404, `PeripheralDevice "${deviceId}" not found`) + + // Check if the device has a token, and if it matches: + if (device.token && device.token === token) { + span?.end() + return device + } + + // If the device has a parent, try that for access control: + const parentDevice = device.parentDeviceId ? await PeripheralDevices.findOneAsync(device.parentDeviceId) : device + if (!parentDevice) throw new Meteor.Error(404, `PeripheralDevice parentDevice "${device.parentDeviceId}" not found`) + + // Check if the parent device has a token, and if it matches: + if (parentDevice.token && parentDevice.token === token) { + span?.end() + return device + } + + // No match for token found + span?.end() + throw new Meteor.Error(401, `Not allowed access to peripheralDevice`) +} diff --git a/meteor/server/security/lib/access.ts b/meteor/server/security/lib/access.ts deleted file mode 100644 index 2f913afb5b..0000000000 --- a/meteor/server/security/lib/access.ts +++ /dev/null @@ -1,64 +0,0 @@ -import * as _ from 'underscore' - -export interface Access { - // Direct database access: - read: boolean - insert: boolean - update: boolean - remove: boolean - - // Methods access: - playout: boolean - configure: boolean - - // For debugging - reason: string - - // The document in question - document: T | null -} - -/** - * Grant all access to all of the document - * @param document The document - * @param reason The reason for the access being granted - */ -export function allAccess(document: T | null, reason?: string): Access { - return { - read: true, - insert: true, - update: true, - remove: true, - - playout: true, - configure: true, - reason: reason || '', - document: document, - } -} - -/** - * Deny all access to all of the document - * @param reason The reason for the access being denied - */ -export function noAccess(reason: string): Access { - return combineAccess({}, allAccess(null, reason)) -} - -/** - * Combine access objects to find the minimum common overlap - * @param access0 - * @param access1 - */ -export function combineAccess( - access0: Access | { reason?: string; document?: null }, - access1: Access -): Access { - const a: any = {} - _.each(_.keys(access0).concat(_.keys(access1)), (key) => { - a[key] = (access0 as any)[key] && (access1 as any)[key] - }) - a.reason = _.compact([access0.reason, access1.reason]).join(',') - a.document = access0.document || access1.document || null - return a -} diff --git a/meteor/server/security/lib/credentials.ts b/meteor/server/security/lib/credentials.ts deleted file mode 100644 index b9b480a712..0000000000 --- a/meteor/server/security/lib/credentials.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { User } from '@sofie-automation/meteor-lib/dist/collections/Users' -import { PeripheralDevice } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' -import { cacheResult, clearCacheResult } from '../../lib/cacheResult' -import { LIMIT_CACHE_TIME } from './security' -import { profiler } from '../../api/profiler' -import { OrganizationId, UserId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { PeripheralDevices, Users } from '../../collections' -import { isProtectedString } from '@sofie-automation/shared-lib/dist/lib/protectedString' - -export interface Credentials { - userId: UserId | null - token?: string -} - -/** - * A minimal set of properties about the user. - * We keep it small so that we don't cache too much in memory or have to invalidate the credentials when something insignificant changes - */ -export type ResolvedUser = Pick - -/** - * A minimal set of properties about the OeripheralDevice. - * We keep it small so that we don't cache too much in memory or have to invalidate the credentials when something insignificant changes - */ -export type ResolvedPeripheralDevice = Pick - -export interface ResolvedCredentials { - organizationId: OrganizationId | null - user?: ResolvedUser - device?: ResolvedPeripheralDevice -} -export interface ResolvedUserCredentials { - organizationId: OrganizationId - user: ResolvedUser -} -export interface ResolvedPeripheralDeviceCredentials { - organizationId: OrganizationId - device: ResolvedPeripheralDevice -} - -/** - * Resolve the provided credentials, and retrieve the PeripheralDevice and Organization for the provided credentials. - * @returns null if the PeripheralDevice was not found - */ -export async function resolveAuthenticatedPeripheralDevice( - cred: Credentials -): Promise { - const resolved = await resolveCredentials({ userId: null, token: cred.token }) - - if (resolved.device && resolved.organizationId) { - return { - organizationId: resolved.organizationId, - device: resolved.device, - } - } else { - return null - } -} - -/** - * Resolve the provided credentials, and retrieve the User and Organization for the provided credentials. - * Note: this requies that the UserId came from a trusted source,it must not be from user input - * @returns null if the user was not found - */ -export async function resolveAuthenticatedUser(cred: Credentials): Promise { - const resolved = await resolveCredentials({ userId: cred.userId }) - - if (resolved.user && resolved.organizationId) { - return { - organizationId: resolved.organizationId, - user: resolved.user, - } - } else { - return null - } -} - -/** - * Resolve the provided credentials/identifier, and fetch the authenticating document from the database. - * Note: this requires that the provided UserId comes from an up-to-date location in meteor, it must not be from user input - * @returns The resolved object. If the identifiers were invalid then this object will have no properties - */ -export async function resolveCredentials(cred: Credentials | ResolvedCredentials): Promise { - const span = profiler.startSpan('security.lib.credentials') - - if (isResolvedCredentials(cred)) { - span?.end() - return cred - } - - const resolved = cacheResult( - credCacheName(cred), - async () => { - const resolved: ResolvedCredentials = { - organizationId: null, - } - - if (cred.token && typeof cred.token !== 'string') cred.token = undefined - if (cred.userId && !isProtectedString(cred.userId)) cred.userId = null - - // Lookup user, using userId: - if (cred.userId && isProtectedString(cred.userId)) { - const user = (await Users.findOneAsync(cred.userId, { - fields: { - _id: 1, - organizationId: 1, - superAdmin: 1, - }, - })) as ResolvedUser - if (user) { - resolved.user = user - resolved.organizationId = user.organizationId - } - } - // Lookup device, using token - if (cred.token) { - // TODO - token is not enforced to be unique and can be defined by a connecting gateway. - // This is rather flawed in the current model.. - const device = (await PeripheralDevices.findOneAsync( - { token: cred.token }, - { - fields: { - _id: 1, - organizationId: 1, - token: 1, - studioId: 1, - }, - } - )) as ResolvedPeripheralDevice - if (device) { - resolved.device = device - resolved.organizationId = device.organizationId - } - } - - // TODO: Implement user-token / API-key - // Lookup user, using token - // if (!resolved.user && !resolved.device && cred.token) { - // user = Users.findOne({ token: cred.token}) - // if (user) resolved.user = user - // } - - // // Make sure the organizationId is valid - // if (resolved.organizationId) { - // const org = (await Organizations.findOneAsync(resolved.organizationId, { - // fields: { _id: 1 }, - // })) as Pick | undefined - // if (org) { - // resolved.organizationId = null - // } - // } - - return resolved - }, - LIMIT_CACHE_TIME - ) - - span?.end() - return resolved -} -/** To be called whenever a user is changed */ -export function resetCredentials(cred: Credentials): void { - clearCacheResult(credCacheName(cred)) -} -function credCacheName(cred: Credentials) { - return `resolveCredentials_${cred.userId}_${cred.token}` -} -export function isResolvedCredentials(cred: Credentials | ResolvedCredentials): cred is ResolvedCredentials { - const c = cred as ResolvedCredentials - return !!(c.user || c.organizationId || c.device) -} diff --git a/meteor/server/security/lib/security.ts b/meteor/server/security/lib/security.ts deleted file mode 100644 index ed27ed1846..0000000000 --- a/meteor/server/security/lib/security.ts +++ /dev/null @@ -1,349 +0,0 @@ -import * as _ from 'underscore' -import { MongoQueryKey } from '@sofie-automation/corelib/dist/mongo' -import { Settings } from '../../Settings' -import { resolveCredentials, ResolvedCredentials, Credentials, isResolvedCredentials } from './credentials' -import { allAccess, noAccess, combineAccess, Access } from './access' -import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' -import { isProtectedString } from '../../lib/tempLib' -import { DBOrganization } from '@sofie-automation/meteor-lib/dist/collections/Organization' -import { PeripheralDevice } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' -import { DBShowStyleVariant } from '@sofie-automation/corelib/dist/dataModel/ShowStyleVariant' -import { profiler } from '../../api/profiler' -import { fetchShowStyleBasesLight, fetchStudioLight, ShowStyleBaseLight } from '../../optimizations' -import { Organizations, PeripheralDevices, RundownPlaylists, Rundowns, ShowStyleVariants } from '../../collections' -import { - OrganizationId, - PeripheralDeviceId, - RundownId, - RundownPlaylistId, - ShowStyleBaseId, - ShowStyleVariantId, - StudioId, - UserId, -} from '@sofie-automation/corelib/dist/dataModel/Ids' -import { StudioLight } from '@sofie-automation/corelib/dist/dataModel/Studio' - -export const LIMIT_CACHE_TIME = 1000 * 60 * 15 // 15 minutes - -// TODO: add caching - -/** - * Grant access to everything if security is disabled - * @returns Access granting access to everything - */ -export function allowAccessToAnythingWhenSecurityDisabled(): Access { - if (!Settings.enableUserAccounts) return allAccess(null, 'No security') - else return noAccess('Security is enabled') -} - -/** - * Check if access is allowed to the coreSystem collection - * @param cred0 Credentials to check - */ -export async function allowAccessToCoreSystem(cred: ResolvedCredentials): Promise> { - if (!Settings.enableUserAccounts) return allAccess(null, 'No security') - - return AccessRules.accessCoreSystem(cred) -} - -/** - * Check if access is allowed to a User, and that user is the current User - * @param cred0 Credentials to check - */ -export async function allowAccessToCurrentUser( - cred0: Credentials | ResolvedCredentials, - userId: UserId | null -): Promise> { - if (!Settings.enableUserAccounts) return allAccess(null, 'No security') - if (!userId) return noAccess('userId missing') - if (!isProtectedString(userId)) return noAccess('userId is not a string') - - return { - ...(await AccessRules.accessCurrentUser(cred0, userId)), - insert: false, // only allowed through methods - update: false, // only allowed through methods - remove: false, // only allowed through methods - } -} - -/** - * Check if access is allowed to the systemStatus collection - * @param cred0 Credentials to check - */ -export async function allowAccessToSystemStatus(cred0: Credentials | ResolvedCredentials): Promise> { - if (!Settings.enableUserAccounts) return allAccess(null, 'No security') - - return { - ...AccessRules.accessSystemStatus(cred0), - insert: false, // only allowed through methods - update: false, // only allowed through methods - remove: false, // only allowed through methods - } -} - -export async function allowAccessToOrganization( - cred0: Credentials | ResolvedCredentials, - organizationId: OrganizationId | null -): Promise> { - if (!Settings.enableUserAccounts) return allAccess(null, 'No security') - if (!organizationId) return noAccess('organizationId not set') - if (!isProtectedString(organizationId)) return noAccess('organizationId is not a string') - const cred = await resolveCredentials(cred0) - - const organization = await Organizations.findOneAsync(organizationId) - if (!organization) return noAccess('Organization not found') - - return { - ...AccessRules.accessOrganization(organization, cred), - insert: false, // only allowed through methods - remove: false, // only allowed through methods - } -} -export async function allowAccessToShowStyleBase( - cred0: Credentials | ResolvedCredentials, - showStyleBaseId: MongoQueryKey -): Promise> { - if (!Settings.enableUserAccounts) return allAccess(null, 'No security') - if (!showStyleBaseId) return noAccess('showStyleBaseId not set') - const cred = await resolveCredentials(cred0) - - const showStyleBases = await fetchShowStyleBasesLight({ - _id: showStyleBaseId, - }) - let access: Access = allAccess(null) - for (const showStyleBase of showStyleBases) { - access = combineAccess(access, AccessRules.accessShowStyleBase(showStyleBase, cred)) - } - return { - ...access, - insert: false, // only allowed through methods - remove: false, // only allowed through methods - } -} -export async function allowAccessToShowStyleVariant( - cred0: Credentials | ResolvedCredentials, - showStyleVariantId: MongoQueryKey -): Promise> { - if (!Settings.enableUserAccounts) return allAccess(null, 'No security') - if (!showStyleVariantId) return noAccess('showStyleVariantId not set') - const cred = await resolveCredentials(cred0) - - const showStyleVariants = await ShowStyleVariants.findFetchAsync({ - _id: showStyleVariantId, - }) - const showStyleBaseIds = _.uniq(_.map(showStyleVariants, (v) => v.showStyleBaseId)) - const showStyleBases = await fetchShowStyleBasesLight({ - _id: { $in: showStyleBaseIds }, - }) - let access: Access = allAccess(null) - for (const showStyleBase of showStyleBases) { - access = combineAccess(access, AccessRules.accessShowStyleBase(showStyleBase, cred)) - } - return { ...access, document: _.last(showStyleVariants) || null } -} -export async function allowAccessToStudio( - cred0: Credentials | ResolvedCredentials, - studioId: StudioId -): Promise> { - if (!Settings.enableUserAccounts) return allAccess(null, 'No security') - if (!studioId) return noAccess('studioId not set') - if (!isProtectedString(studioId)) return noAccess('studioId is not a string') - const cred = await resolveCredentials(cred0) - - const studio = await fetchStudioLight(studioId) - if (!studio) return noAccess('Studio not found') - - return { - ...AccessRules.accessStudio(studio, cred), - insert: false, // only allowed through methods - remove: false, // only allowed through methods - } -} -export async function allowAccessToRundownPlaylist( - cred0: Credentials | ResolvedCredentials, - playlistId: RundownPlaylistId -): Promise> { - if (!Settings.enableUserAccounts) return allAccess(null, 'No security') - if (!playlistId) return noAccess('playlistId not set') - const cred = await resolveCredentials(cred0) - - const playlist = await RundownPlaylists.findOneAsync(playlistId) - if (playlist) { - return AccessRules.accessRundownPlaylist(playlist, cred) - } else { - return allAccess(null) - } -} -export async function allowAccessToRundown( - cred0: Credentials | ResolvedCredentials, - rundownId: MongoQueryKey -): Promise> { - const access = await allowAccessToRundownContent(cred0, rundownId) - return { - ...access, - insert: false, // only allowed through methods - update: false, // only allowed through methods - remove: false, // only allowed through methods - } -} -export async function allowAccessToRundownContent( - cred0: Credentials | ResolvedCredentials, - rundownId: MongoQueryKey -): Promise> { - if (!Settings.enableUserAccounts) return allAccess(null, 'No security') - if (!rundownId) return noAccess('rundownId missing') - const cred = await resolveCredentials(cred0) - - const rundowns = await Rundowns.findFetchAsync({ _id: rundownId }) - let access: Access = allAccess(null) - for (const rundown of rundowns) { - // TODO - this is reeally inefficient on db queries - access = combineAccess(access, await AccessRules.accessRundown(rundown, cred)) - } - return access -} -export async function allowAccessToPeripheralDevice( - cred0: Credentials | ResolvedCredentials, - deviceId: PeripheralDeviceId -): Promise> { - if (!deviceId) return noAccess('deviceId missing') - if (!isProtectedString(deviceId)) return noAccess('deviceId is not a string') - - const device = await PeripheralDevices.findOneAsync(deviceId) - if (!device) return noAccess('Device not found') - - const access = await allowAccessToPeripheralDeviceContent(cred0, device) - return { - ...access, - insert: false, // only allowed through methods - remove: false, // only allowed through methods - } -} - -export async function allowAccessToPeripheralDeviceContent( - cred0: Credentials | ResolvedCredentials, - device: PeripheralDevice -): Promise> { - const span = profiler.startSpan('security.lib.security.allowAccessToPeripheralDeviceContent') - if (!Settings.enableUserAccounts) return allAccess(null, 'No security') - const cred = await resolveCredentials(cred0) - - const access = AccessRules.accessPeripheralDevice(device, cred) - - span?.end() - return access -} - -namespace AccessRules { - /** - * Check if access is allowed to the coreSystem collection - * @param cred0 Credentials to check - */ - export function accessCoreSystem(cred: ResolvedCredentials): Access { - if (cred.user && cred.user.superAdmin) { - return { - ...allAccess(null), - insert: false, // only allowed through methods - remove: false, // only allowed through methods - } - } else { - return { - ...noAccess('User is not superAdmin'), - read: true, - } - } - } - - /** - * Check the allowed access to a user (and verify that user is the current user) - * @param cred0 Credentials to check - * @param userId User to check access to - */ - export async function accessCurrentUser( - cred0: Credentials | ResolvedCredentials, - userId: UserId - ): Promise> { - let credUserId: UserId | undefined = undefined - if (isResolvedCredentials(cred0) && cred0.user) { - credUserId = cred0.user._id - } else if (!isResolvedCredentials(cred0) && cred0.userId) { - credUserId = cred0.userId - } else { - const cred = await resolveCredentials(cred0) - if (!cred.user) return noAccess('User in cred not found') - credUserId = cred.user._id - } - - if (credUserId) { - if (credUserId === userId) { - // TODO: user role access - return allAccess(null) - } else return noAccess('Not accessing current user') - } else return noAccess('Requested user not found') - } - - export function accessSystemStatus(_cred0: Credentials | ResolvedCredentials): Access { - // No restrictions on systemStatus - return allAccess(null) - } - // export function accessUser (cred: ResolvedCredentials, user: User): Access { - // if (!cred.organizationId) return noAccess('No organization in credentials') - // if (user.organizationId === cred.organizationId) { - // // TODO: user role access - // return allAccess() - // } else return noAccess('User is not in the same organization as requested user') - // } - export function accessOrganization( - organization: DBOrganization, - cred: ResolvedCredentials - ): Access { - if (!cred.organizationId) return noAccess('No organization in credentials') - if (organization._id === cred.organizationId) { - // TODO: user role access - return allAccess(organization) - } else return noAccess(`User is not in the organization "${organization._id}"`) - } - export function accessShowStyleBase( - showStyleBase: ShowStyleBaseLight, - cred: ResolvedCredentials - ): Access { - if (!showStyleBase.organizationId) return noAccess('ShowStyleBase has no organization') - if (!cred.organizationId) return noAccess('No organization in credentials') - if (showStyleBase.organizationId === cred.organizationId) { - // TODO: user role access - return allAccess(showStyleBase) - } else return noAccess(`User is not in the same organization as the showStyleBase "${showStyleBase._id}"`) - } - export function accessStudio(studio: StudioLight, cred: ResolvedCredentials): Access { - if (!studio.organizationId) return noAccess('Studio has no organization') - if (!cred.organizationId) return noAccess('No organization in credentials') - if (studio.organizationId === cred.organizationId) { - // TODO: user role access - return allAccess(studio) - } else return noAccess(`User is not in the same organization as the studio ${studio._id}`) - } - export async function accessRundownPlaylist( - playlist: DBRundownPlaylist, - cred: ResolvedCredentials - ): Promise> { - const studio = await fetchStudioLight(playlist.studioId) - if (!studio) return noAccess(`Studio of playlist "${playlist._id}" not found`) - return { ...accessStudio(studio, cred), document: playlist } - } - export async function accessRundown(rundown: Rundown, cred: ResolvedCredentials): Promise> { - const playlist = await RundownPlaylists.findOneAsync(rundown.playlistId) - if (!playlist) return noAccess(`Rundown playlist of rundown "${rundown._id}" not found`) - return { ...(await accessRundownPlaylist(playlist, cred)), document: rundown } - } - export function accessPeripheralDevice( - device: PeripheralDevice, - cred: ResolvedCredentials - ): Access { - if (!cred.organizationId) return noAccess('No organization in credentials') - if (!device.organizationId) return noAccess('Device has no organizationId') - if (device.organizationId === cred.organizationId) { - return allAccess(device) - } else return noAccess(`Device "${device._id}" is not in the same organization as user`) - } -} diff --git a/meteor/server/security/noSecurity.ts b/meteor/server/security/noSecurity.ts deleted file mode 100644 index 73236204eb..0000000000 --- a/meteor/server/security/noSecurity.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { allowAccessToAnythingWhenSecurityDisabled } from './lib/security' - -export namespace NoSecurityReadAccess { - /** - * Grant read access if security is disabled - */ - export function any(): boolean { - const access = allowAccessToAnythingWhenSecurityDisabled() - if (!access.read) return false // don't even log anything - return true - } -} diff --git a/meteor/server/security/organization.ts b/meteor/server/security/organization.ts deleted file mode 100644 index 8fd686c20a..0000000000 --- a/meteor/server/security/organization.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { Meteor } from 'meteor/meteor' -import { SnapshotItem } from '@sofie-automation/meteor-lib/dist/collections/Snapshots' -import { Blueprint } from '@sofie-automation/corelib/dist/dataModel/Blueprint' -import { logNotAllowed } from './lib/lib' -import { MongoQueryKey } from '@sofie-automation/corelib/dist/mongo' -import { allowAccessToOrganization } from './lib/security' -import { Credentials, ResolvedCredentials, resolveCredentials } from './lib/credentials' -import { Settings } from '../Settings' -import { MethodContext } from '../api/methodContext' -import { triggerWriteAccess } from './lib/securityVerify' -import { isProtectedString } from '../lib/tempLib' -import { fetchShowStyleBaseLight, fetchStudioLight, ShowStyleBaseLight } from '../optimizations' -import { - BlueprintId, - OrganizationId, - ShowStyleBaseId, - SnapshotId, - StudioId, - UserId, -} from '@sofie-automation/corelib/dist/dataModel/Ids' -import { Blueprints, Snapshots } from '../collections' -import { StudioLight } from '@sofie-automation/corelib/dist/dataModel/Studio' - -export type BasicAccessContext = { organizationId: OrganizationId | null; userId: UserId | null } - -export interface OrganizationContentAccess { - userId: UserId | null - organizationId: OrganizationId | null - cred: ResolvedCredentials | Credentials -} - -export namespace OrganizationReadAccess { - export async function organization( - organizationId: MongoQueryKey, - cred: Credentials | ResolvedCredentials - ): Promise { - return organizationContent(organizationId, cred) - } - /** Handles read access for all organization content (UserActions, Evaluations etc..) */ - export async function organizationContent( - organizationId: MongoQueryKey | undefined, - cred: Credentials | ResolvedCredentials - ): Promise { - if (!Settings.enableUserAccounts) return true - if (!organizationId || !isProtectedString(organizationId)) - throw new Meteor.Error(400, 'selector must contain organizationId') - - const access = await allowAccessToOrganization(cred, organizationId) - if (!access.read) return logNotAllowed('Organization content', access.reason) - - return true - } - export async function adminUsers( - organizationId: MongoQueryKey | undefined, - cred: Credentials | ResolvedCredentials - ): Promise { - // TODO: User roles - return organizationContent(organizationId, cred) - } -} -export namespace OrganizationContentWriteAccess { - // These functions throws if access is not allowed. - - export async function organization( - cred0: Credentials, - organizationId: OrganizationId - ): Promise { - return anyContent(cred0, { organizationId }) - } - - export async function studio( - cred0: Credentials, - existingStudio?: StudioLight | StudioId - ): Promise { - triggerWriteAccess() - if (existingStudio && isProtectedString(existingStudio)) { - const studioId = existingStudio - existingStudio = await fetchStudioLight(studioId) - if (!existingStudio) throw new Meteor.Error(404, `Studio "${studioId}" not found!`) - } - return { ...(await anyContent(cred0, existingStudio)), studio: existingStudio } - } - export async function evaluation(cred0: Credentials): Promise { - return anyContent(cred0) - } - export async function mediaWorkFlows(cred0: Credentials): Promise { - // "All mediaWOrkflows in all devices of an organization" - return anyContent(cred0) - } - export async function blueprint( - cred0: Credentials, - existingBlueprint?: Blueprint | BlueprintId, - allowMissing?: boolean - ): Promise { - triggerWriteAccess() - if (existingBlueprint && isProtectedString(existingBlueprint)) { - const blueprintId = existingBlueprint - existingBlueprint = await Blueprints.findOneAsync(blueprintId) - if (!existingBlueprint && !allowMissing) - throw new Meteor.Error(404, `Blueprint "${blueprintId}" not found!`) - } - return { ...(await anyContent(cred0, existingBlueprint)), blueprint: existingBlueprint } - } - export async function snapshot( - cred0: Credentials, - existingSnapshot?: SnapshotItem | SnapshotId - ): Promise { - triggerWriteAccess() - if (existingSnapshot && isProtectedString(existingSnapshot)) { - const snapshotId = existingSnapshot - existingSnapshot = await Snapshots.findOneAsync(snapshotId) - if (!existingSnapshot) throw new Meteor.Error(404, `Snapshot "${snapshotId}" not found!`) - } - return { ...(await anyContent(cred0, existingSnapshot)), snapshot: existingSnapshot } - } - export async function dataFromSnapshot( - cred0: Credentials, - organizationId: OrganizationId - ): Promise { - return anyContent(cred0, { organizationId: organizationId }) - } - export async function translationBundle( - cred0: Credentials, - existingObj?: { organizationId: OrganizationId | null } - ): Promise { - return anyContent(cred0, existingObj) - } - export async function showStyleBase( - cred0: Credentials, - existingShowStyleBase?: ShowStyleBaseLight | ShowStyleBaseId - ): Promise { - triggerWriteAccess() - if (existingShowStyleBase && isProtectedString(existingShowStyleBase)) { - const showStyleBaseId = existingShowStyleBase - existingShowStyleBase = await fetchShowStyleBaseLight(showStyleBaseId) - if (!existingShowStyleBase) throw new Meteor.Error(404, `ShowStyleBase "${showStyleBaseId}" not found!`) - } - return { ...(await anyContent(cred0, existingShowStyleBase)), showStyleBase: existingShowStyleBase } - } - /** Return credentials if writing is allowed, throw otherwise */ - async function anyContent( - cred0: Credentials | MethodContext, - existingObj?: { organizationId: OrganizationId | null } - ): Promise { - triggerWriteAccess() - if (!Settings.enableUserAccounts) { - return { userId: null, organizationId: null, cred: cred0 } - } - const cred = await resolveCredentials(cred0) - if (!cred.user) throw new Meteor.Error(403, `Not logged in`) - if (!cred.organizationId) throw new Meteor.Error(500, `User has no organization`) - - const access = await allowAccessToOrganization( - cred, - existingObj ? existingObj.organizationId : cred.organizationId - ) - if (!access.update) throw new Meteor.Error(403, `Not allowed: ${access.reason}`) - - return { - userId: cred.user._id, - organizationId: cred.organizationId, - cred: cred, - } - } -} diff --git a/meteor/server/security/peripheralDevice.ts b/meteor/server/security/peripheralDevice.ts deleted file mode 100644 index a773b199d1..0000000000 --- a/meteor/server/security/peripheralDevice.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { Meteor } from 'meteor/meteor' -import { check } from '../lib/check' -import { PeripheralDevice } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' -import { isProtectedString } from '../lib/tempLib' -import { logNotAllowed } from './lib/lib' -import { MediaWorkFlow } from '@sofie-automation/shared-lib/dist/core/model/MediaWorkFlows' -import { MongoQueryKey } from '@sofie-automation/corelib/dist/mongo' -import { Credentials, ResolvedCredentials, resolveCredentials } from './lib/credentials' -import { allowAccessToPeripheralDevice, allowAccessToPeripheralDeviceContent } from './lib/security' -import { Settings } from '../Settings' -import { triggerWriteAccess } from './lib/securityVerify' -import { profiler } from '../api/profiler' -import { StudioContentWriteAccess } from './studio' -import { - MediaWorkFlowId, - OrganizationId, - PeripheralDeviceId, - StudioId, - UserId, -} from '@sofie-automation/corelib/dist/dataModel/Ids' -import { MediaWorkFlows, PeripheralDevices } from '../collections' - -export namespace PeripheralDeviceReadAccess { - /** Check for read access for a peripheral device */ - export async function peripheralDevice( - deviceId: MongoQueryKey, - cred: Credentials | ResolvedCredentials - ): Promise { - return peripheralDeviceContent(deviceId, cred) - } - /** Check for read access for all peripheraldevice content (commands, mediaWorkFlows, etc..) */ - export async function peripheralDeviceContent( - deviceId: MongoQueryKey | undefined, - cred: Credentials | ResolvedCredentials - ): Promise { - if (!Settings.enableUserAccounts) return true - if (!deviceId || !isProtectedString(deviceId)) throw new Meteor.Error(400, 'selector must contain deviceId') - - const access = await allowAccessToPeripheralDevice(cred, deviceId) - if (!access.read) return logNotAllowed('PeripheralDevice content', access.reason) - - return true - } -} -export interface MediaWorkFlowContentAccess extends PeripheralDeviceContentWriteAccess.ContentAccess { - mediaWorkFlow: MediaWorkFlow -} - -export namespace PeripheralDeviceContentWriteAccess { - export interface ContentAccess { - userId: UserId | null - organizationId: OrganizationId | null - deviceId: PeripheralDeviceId - device: PeripheralDevice - cred: ResolvedCredentials | Credentials - } - - // These functions throws if access is not allowed. - - /** - * Check if a user is allowed to execute a PeripheralDevice function in a Studio - */ - export async function executeFunction(cred0: Credentials, deviceId: PeripheralDeviceId): Promise { - triggerWriteAccess() - const device = await PeripheralDevices.findOneAsync(deviceId) - if (!device) throw new Meteor.Error(404, `PeripheralDevice "${deviceId}" not found`) - - let studioId: StudioId - if (device.studioId) { - studioId = device.studioId - } else if (device.parentDeviceId) { - // Child devices aren't assigned to the studio themselves, instead look up the parent device and use it's studioId: - const parentDevice = await PeripheralDevices.findOneAsync(device.parentDeviceId) - if (!parentDevice) - throw new Meteor.Error( - 404, - `Parent PeripheralDevice "${device.parentDeviceId}" of "${deviceId}" not found!` - ) - if (!parentDevice.studioId) - throw new Meteor.Error( - 404, - `Parent PeripheralDevice "${device.parentDeviceId}" of "${deviceId}" doesn't have any studioId set` - ) - studioId = parentDevice.studioId - } else { - throw new Meteor.Error(404, `PeripheralDevice "${deviceId}" doesn't have any studioId set`) - } - - const access = await StudioContentWriteAccess.executeFunction(cred0, studioId) - - const access2 = await allowAccessToPeripheralDeviceContent(access.cred, device) - if (!access2.playout) throw new Meteor.Error(403, `Not allowed: ${access2.reason}`) - - return { - ...access, - deviceId: device._id, - device, - } - } - - /** Check for permission to modify a peripheralDevice */ - export async function peripheralDevice(cred0: Credentials, deviceId: PeripheralDeviceId): Promise { - await backwardsCompatibilityfix(cred0, deviceId) - return anyContent(cred0, deviceId) - } - - /** Check for permission to modify a mediaWorkFlow */ - export async function mediaWorkFlow( - cred0: Credentials, - existingWorkFlow: MediaWorkFlow | MediaWorkFlowId - ): Promise { - triggerWriteAccess() - if (existingWorkFlow && isProtectedString(existingWorkFlow)) { - const workFlowId = existingWorkFlow - const m = await MediaWorkFlows.findOneAsync(workFlowId) - if (!m) throw new Meteor.Error(404, `MediaWorkFlow "${workFlowId}" not found!`) - existingWorkFlow = m - } - await backwardsCompatibilityfix(cred0, existingWorkFlow.deviceId) - return { ...(await anyContent(cred0, existingWorkFlow.deviceId)), mediaWorkFlow: existingWorkFlow } - } - - /** Return credentials if writing is allowed, throw otherwise */ - async function anyContent(cred0: Credentials, deviceId: PeripheralDeviceId): Promise { - const span = profiler.startSpan('PeripheralDeviceContentWriteAccess.anyContent') - triggerWriteAccess() - check(deviceId, String) - const device = await PeripheralDevices.findOneAsync(deviceId) - if (!device) throw new Meteor.Error(404, `PeripheralDevice "${deviceId}" not found`) - - // If the device has a parent, use that for access control: - const parentDevice = device.parentDeviceId - ? await PeripheralDevices.findOneAsync(device.parentDeviceId) - : device - if (!parentDevice) - throw new Meteor.Error(404, `PeripheralDevice parentDevice "${device.parentDeviceId}" not found`) - - if (!Settings.enableUserAccounts) { - // Note: this is kind of a hack to keep backwards compatibility.. - if (!device.parentDeviceId && parentDevice.token !== cred0.token) { - throw new Meteor.Error(401, `Not allowed access to peripheralDevice`) - } - - span?.end() - return { - userId: null, - organizationId: null, - deviceId: deviceId, - device: device, - cred: cred0, - } - } else { - if (!cred0.userId && parentDevice.token !== cred0.token) { - throw new Meteor.Error(401, `Not allowed access to peripheralDevice`) - } - const cred = await resolveCredentials(cred0) - const access = await allowAccessToPeripheralDeviceContent(cred, parentDevice) - if (!access.update) throw new Meteor.Error(403, `Not allowed: ${access.reason}`) - if (!access.document) throw new Meteor.Error(500, `Internal error: access.document not set`) - - span?.end() - return { - userId: cred.user ? cred.user._id : null, - organizationId: cred.organizationId, - deviceId: deviceId, - device: device, - cred: cred, - } - } - } -} -async function backwardsCompatibilityfix(cred0: Credentials, deviceId: PeripheralDeviceId) { - if (!Settings.enableUserAccounts) { - // Note: This is a temporary hack to keep backwards compatibility: - const device = (await PeripheralDevices.findOneAsync(deviceId, { fields: { token: 1 } })) as - | Pick - | undefined - if (device) cred0.token = device.token - } -} diff --git a/meteor/server/security/rundown.ts b/meteor/server/security/rundown.ts deleted file mode 100644 index 8f4bf30ba9..0000000000 --- a/meteor/server/security/rundown.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { Meteor } from 'meteor/meteor' -import { check } from '../lib/check' -import * as _ from 'underscore' -import { Credentials, ResolvedCredentials } from './lib/credentials' -import { logNotAllowed } from './lib/lib' -import { allowAccessToRundown } from './lib/security' -import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' -import { ExpectedMediaItem } from '@sofie-automation/corelib/dist/dataModel/ExpectedMediaItem' -import { PeripheralDeviceType, PeripheralDevice } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' -import { ExpectedPlayoutItem } from '@sofie-automation/corelib/dist/dataModel/ExpectedPlayoutItem' -import { Settings } from '../Settings' -import { RundownId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { PeripheralDevices, Segments } from '../collections' -import { getStudioIdFromDevice } from '../api/studio/lib' -import { MongoQuery, MongoQueryKey } from '@sofie-automation/corelib/dist/mongo' - -export namespace RundownReadAccess { - /** Check for read access to the rundown collection */ - export async function rundown( - rundownId: MongoQueryKey, - cred: Credentials | ResolvedCredentials - ): Promise { - return rundownContent(rundownId, cred) - } - /** Check for read access for all rundown content (segments, parts, pieces etc..) */ - export async function rundownContent( - rundownId: MongoQueryKey | undefined, - cred: Credentials | ResolvedCredentials - ): Promise { - if (!Settings.enableUserAccounts) return true - if (!rundownId) throw new Meteor.Error(400, 'selector must contain rundownId') - - const access = await allowAccessToRundown(cred, rundownId) - if (!access.read) return logNotAllowed('Rundown content', access.reason) - - return true - } - /** Check for read access for segments in a rundown */ - export async function segments(segmentId: MongoQueryKey, cred: Credentials): Promise { - if (!Settings.enableUserAccounts) return true - if (!segmentId) throw new Meteor.Error(400, 'selector must contain _id') - - const segments = (await Segments.findFetchAsync(segmentId, { - fields: { - _id: 1, - rundownId: 1, - }, - })) as Array> - const rundownIds = _.uniq(_.map(segments, (s) => s.rundownId)) - - const access = await allowAccessToRundown(cred, { $in: rundownIds }) - if (!access.read) return logNotAllowed('Segments', access.reason) - - return true - } - /** Check for read access for pieces in a rundown */ - export async function pieces(rundownId: MongoQueryKey, cred: Credentials): Promise { - if (!Settings.enableUserAccounts) return true - if (!rundownId) throw new Meteor.Error(400, 'selector must contain rundownId') - - const access = await allowAccessToRundown(cred, rundownId) - if (!access.read) return logNotAllowed('Piece', access.reason) - - return true - } - /** Check for read access for exoected media items in a rundown */ - export async function expectedMediaItems( - selector: MongoQuery | any, - cred: Credentials - ): Promise { - check(selector, Object) - if (selector.mediaFlowId) { - check(selector.mediaFlowId, Object) - check(selector.mediaFlowId.$in, Array) - } - if (!(await rundownContent(selector.rundownId, cred))) return null - - const mediaManagerDevice = await PeripheralDevices.findOneAsync({ - type: PeripheralDeviceType.MEDIA_MANAGER, - token: cred.token, - }) - - if (!mediaManagerDevice) return false - - mediaManagerDevice.studioId = await getStudioIdFromDevice(mediaManagerDevice) - - if (mediaManagerDevice && cred.token) { - // mediaManagerDevice.settings - - return mediaManagerDevice - } else { - // TODO: implement access logic here - // use context.userId - - // just returning true for now - return true - } - } - - /** Check for read access to expectedPlayoutItems */ - export async function expectedPlayoutItems( - selector: MongoQuery | any, - cred: Credentials - ): Promise { - check(selector, Object) - check(selector.studioId, String) - - if (!(await rundownContent(selector.rundownId, cred))) return null - - const playoutDevice = await PeripheralDevices.findOneAsync({ - type: PeripheralDeviceType.PLAYOUT, - token: cred.token, - }) - if (!playoutDevice) return false - - playoutDevice.studioId = await getStudioIdFromDevice(playoutDevice) - - if (playoutDevice && cred.token) { - return playoutDevice - } else { - // TODO: implement access logic here - // just returning true for now - return true - } - } -} diff --git a/meteor/server/security/rundownPlaylist.ts b/meteor/server/security/rundownPlaylist.ts deleted file mode 100644 index 4666e718f8..0000000000 --- a/meteor/server/security/rundownPlaylist.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { Meteor } from 'meteor/meteor' -import { check } from '../lib/check' -import { logNotAllowed } from './lib/lib' -import { allowAccessToRundownPlaylist } from './lib/security' -import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { Credentials, ResolvedCredentials, resolveCredentials } from './lib/credentials' -import { triggerWriteAccess } from './lib/securityVerify' -import { isProtectedString } from '../lib/tempLib' -import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' -import { Settings } from '../Settings' -import { - OrganizationId, - RundownId, - RundownPlaylistId, - StudioId, - UserId, -} from '@sofie-automation/corelib/dist/dataModel/Ids' -import { RundownPlaylists, Rundowns } from '../collections' - -export namespace RundownPlaylistReadAccess { - /** Handles read access for all playlist document */ - export async function rundownPlaylist( - id: RundownPlaylistId, - cred: Credentials | ResolvedCredentials - ): Promise { - return rundownPlaylistContent(id, cred) - } - /** Handles read access for all playlist content (segments, parts, pieces etc..) */ - export async function rundownPlaylistContent( - id: RundownPlaylistId, - cred: Credentials | ResolvedCredentials - ): Promise { - triggerWriteAccess() - check(id, String) - if (!Settings.enableUserAccounts) return true - if (!id) throw new Meteor.Error(400, 'selector must contain playlistId') - - const access = await allowAccessToRundownPlaylist(cred, id) - if (!access.read) return logNotAllowed('RundownPlaylist content', access.reason) - - return true - } -} - -/** - * This is returned from a check of access to a playlist. - * Fields will be populated about the user, and the playlist if they have permission - */ -export interface RundownPlaylistContentAccess { - userId: UserId | null - organizationId: OrganizationId | null - studioId: StudioId | null - playlist: DBRundownPlaylist | null - cred: ResolvedCredentials | Credentials -} - -/** - * This is returned from a check of access to a rundown. - * Fields will be populated about the user, and the rundown if they have permission - */ -export interface RundownContentAccess { - userId: UserId | null - organizationId: OrganizationId | null - studioId: StudioId | null - rundown: Rundown | null - cred: ResolvedCredentials | Credentials -} - -export namespace RundownPlaylistContentWriteAccess { - /** Access to playout for a playlist, from a rundown. ie the playlist and everything inside it. */ - export async function rundown( - cred0: Credentials, - existingRundown: Rundown | RundownId - ): Promise { - triggerWriteAccess() - if (existingRundown && isProtectedString(existingRundown)) { - const rundownId = existingRundown - const m = await Rundowns.findOneAsync(rundownId) - if (!m) throw new Meteor.Error(404, `Rundown "${rundownId}" not found!`) - existingRundown = m - } - - const access = await anyContent(cred0, existingRundown.playlistId) - return { ...access, rundown: existingRundown } - } - /** Access to playout for a playlist. ie the playlist and everything inside it. */ - export async function playout( - cred0: Credentials, - playlistId: RundownPlaylistId - ): Promise { - return anyContent(cred0, playlistId) - } - /** - * We don't have user levels, so we can use a simple check for all cases - * Return credentials if writing is allowed, throw otherwise - */ - async function anyContent( - cred0: Credentials, - playlistId: RundownPlaylistId - ): Promise { - triggerWriteAccess() - if (!Settings.enableUserAccounts) { - const playlist = await RundownPlaylists.findOneAsync(playlistId) - return { - userId: null, - organizationId: null, - studioId: playlist?.studioId || null, - playlist: playlist || null, - cred: cred0, - } - } - const cred = await resolveCredentials(cred0) - if (!cred.user) throw new Meteor.Error(403, `Not logged in`) - if (!cred.organizationId) throw new Meteor.Error(500, `User has no organization`) - const access = await allowAccessToRundownPlaylist(cred, playlistId) - if (!access.update) throw new Meteor.Error(403, `Not allowed: ${access.reason}`) - - return { - userId: cred.user._id, - organizationId: cred.organizationId, - studioId: access.document?.studioId || null, - playlist: access.document, - cred: cred, - } - } -} diff --git a/meteor/server/security/lib/securityVerify.ts b/meteor/server/security/securityVerify.ts similarity index 99% rename from meteor/server/security/lib/securityVerify.ts rename to meteor/server/security/securityVerify.ts index edde48cb35..e7edc63cfc 100644 --- a/meteor/server/security/lib/securityVerify.ts +++ b/meteor/server/security/securityVerify.ts @@ -1,6 +1,6 @@ import { Meteor } from 'meteor/meteor' -import { AllMeteorMethods, suppressExtraErrorLogging } from '../../methods' -import { disableChecks, enableChecks as restoreChecks } from '../../lib/check' +import { AllMeteorMethods, suppressExtraErrorLogging } from '../methods' +import { disableChecks, enableChecks as restoreChecks } from '../lib/check' import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' /** These function are used to verify that all methods defined are using security functions */ diff --git a/meteor/server/security/showStyle.ts b/meteor/server/security/showStyle.ts deleted file mode 100644 index bd3e83811c..0000000000 --- a/meteor/server/security/showStyle.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { Meteor } from 'meteor/meteor' -import { check } from '../lib/check' -import { logNotAllowed } from './lib/lib' -import { DBShowStyleVariant } from '@sofie-automation/corelib/dist/dataModel/ShowStyleVariant' -import { RundownLayoutBase } from '@sofie-automation/meteor-lib/dist/collections/RundownLayouts' -import { MongoQuery, MongoQueryKey } from '@sofie-automation/corelib/dist/mongo' -import { Credentials, ResolvedCredentials, resolveCredentials } from './lib/credentials' -import { allowAccessToShowStyleBase, allowAccessToShowStyleVariant } from './lib/security' -import { triggerWriteAccess } from './lib/securityVerify' -import { Settings } from '../Settings' -import { isProtectedString } from '../lib/tempLib' -import { TriggeredActionsObj } from '@sofie-automation/meteor-lib/dist/collections/TriggeredActions' -import { SystemWriteAccess } from './system' -import { fetchShowStyleBaseLight, ShowStyleBaseLight } from '../optimizations' -import { - OrganizationId, - RundownLayoutId, - ShowStyleBaseId, - ShowStyleVariantId, - TriggeredActionId, - UserId, -} from '@sofie-automation/corelib/dist/dataModel/Ids' -import { RundownLayouts, ShowStyleVariants, TriggeredActions } from '../collections' - -export interface ShowStyleContentAccess { - userId: UserId | null - organizationId: OrganizationId | null - showStyleBaseId: ShowStyleBaseId | null - showStyleBase: ShowStyleBaseLight | null - cred: ResolvedCredentials | Credentials -} - -export namespace ShowStyleReadAccess { - /** Handles read access for all showstyle document */ - export async function showStyleBase( - showStyleBaseId: MongoQueryKey, - cred: Credentials | ResolvedCredentials - ): Promise { - return showStyleBaseContent({ showStyleBaseId }, cred) - } - - /** Handles read access for all showstyle content */ - export async function showStyleBaseContent( - selector: MongoQuery, - cred: Credentials | ResolvedCredentials - ): Promise { - check(selector, Object) - if (!Settings.enableUserAccounts) return true - if (!selector.showStyleBaseId || !isProtectedString(selector.showStyleBaseId)) - throw new Meteor.Error(400, 'selector must contain showStyleBaseId') - - const access = await allowAccessToShowStyleBase(cred, selector.showStyleBaseId) - if (!access.read) return logNotAllowed('ShowStyleBase content', access.reason) - - return true - } - - /** Check for read access to the showstyle variants */ - export async function showStyleVariant( - showStyleVariantId: MongoQueryKey, - cred: Credentials | ResolvedCredentials - ): Promise { - if (!Settings.enableUserAccounts) return true - if (!showStyleVariantId) throw new Meteor.Error(400, 'selector must contain _id') - - const access = await allowAccessToShowStyleVariant(cred, showStyleVariantId) - if (!access.read) return logNotAllowed('ShowStyleVariant', access.reason) - - return true - } -} -export namespace ShowStyleContentWriteAccess { - // These functions throws if access is not allowed. - - /** Check permissions for write access to a showStyleVariant */ - export async function showStyleVariant( - cred0: Credentials, - existingVariant: DBShowStyleVariant | ShowStyleVariantId - ): Promise { - triggerWriteAccess() - if (existingVariant && isProtectedString(existingVariant)) { - const variantId = existingVariant - const m = await ShowStyleVariants.findOneAsync(variantId) - if (!m) throw new Meteor.Error(404, `ShowStyleVariant "${variantId}" not found!`) - existingVariant = m - } - return { ...(await anyContent(cred0, existingVariant.showStyleBaseId)), showStyleVariant: existingVariant } - } - /** Check permissions for write access to a rundownLayout */ - export async function rundownLayout( - cred0: Credentials, - existingLayout: RundownLayoutBase | RundownLayoutId - ): Promise { - triggerWriteAccess() - if (existingLayout && isProtectedString(existingLayout)) { - const layoutId = existingLayout - const m = await RundownLayouts.findOneAsync(layoutId) - if (!m) throw new Meteor.Error(404, `RundownLayout "${layoutId}" not found!`) - existingLayout = m - } - return { ...(await anyContent(cred0, existingLayout.showStyleBaseId)), rundownLayout: existingLayout } - } - /** Check permissions for write access to a triggeredAction */ - export async function triggeredActions( - cred0: Credentials, - existingTriggeredAction: TriggeredActionsObj | TriggeredActionId - ): Promise<(ShowStyleContentAccess & { triggeredActions: TriggeredActionsObj }) | boolean> { - triggerWriteAccess() - if (existingTriggeredAction && isProtectedString(existingTriggeredAction)) { - const layoutId = existingTriggeredAction - const m = await TriggeredActions.findOneAsync(layoutId) - if (!m) throw new Meteor.Error(404, `RundownLayout "${layoutId}" not found!`) - existingTriggeredAction = m - } - if (existingTriggeredAction.showStyleBaseId) { - return { - ...(await anyContent(cred0, existingTriggeredAction.showStyleBaseId)), - triggeredActions: existingTriggeredAction, - } - } else { - return SystemWriteAccess.coreSystem(cred0) - } - } - /** Return credentials if writing is allowed, throw otherwise */ - export async function anyContent( - cred0: Credentials, - showStyleBaseId: ShowStyleBaseId - ): Promise { - triggerWriteAccess() - if (!Settings.enableUserAccounts) { - return { - userId: null, - organizationId: null, - showStyleBaseId: showStyleBaseId, - showStyleBase: (await fetchShowStyleBaseLight(showStyleBaseId)) || null, - cred: cred0, - } - } - const cred = await resolveCredentials(cred0) - if (!cred.user) throw new Meteor.Error(403, `Not logged in`) - if (!cred.organizationId) throw new Meteor.Error(500, `User has no organization`) - - const access = await allowAccessToShowStyleBase(cred, showStyleBaseId) - if (!access.update) throw new Meteor.Error(403, `Not allowed: ${access.reason}`) - - return { - userId: cred.user._id, - organizationId: cred.organizationId, - showStyleBaseId: showStyleBaseId, - showStyleBase: access.document, - cred: cred, - } - } -} diff --git a/meteor/server/security/studio.ts b/meteor/server/security/studio.ts deleted file mode 100644 index 3b52624f84..0000000000 --- a/meteor/server/security/studio.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { Meteor } from 'meteor/meteor' -import { allowAccessToStudio } from './lib/security' -import { MongoQueryKey } from '@sofie-automation/corelib/dist/mongo' -import { logNotAllowed } from './lib/lib' -import { ExternalMessageQueueObj } from '@sofie-automation/corelib/dist/dataModel/ExternalMessageQueue' -import { Credentials, ResolvedCredentials, resolveCredentials } from './lib/credentials' -import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { Settings } from '../Settings' -import { triggerWriteAccess } from './lib/securityVerify' -import { isProtectedString } from '../lib/tempLib' -import { fetchStudioLight } from '../optimizations' -import { - ExternalMessageQueueObjId, - OrganizationId, - RundownPlaylistId, - StudioId, - UserId, -} from '@sofie-automation/corelib/dist/dataModel/Ids' -import { ExternalMessageQueue, RundownPlaylists } from '../collections' -import { StudioLight } from '@sofie-automation/corelib/dist/dataModel/Studio' - -export namespace StudioReadAccess { - /** Handles read access for all studio document */ - export async function studio( - studioId: MongoQueryKey, - cred: Credentials | ResolvedCredentials - ): Promise { - return studioContent(studioId, cred) - } - /** Handles read access for all studioId content */ - export async function studioContent( - studioId: MongoQueryKey | undefined, - cred: Credentials | ResolvedCredentials - ): Promise { - if (!Settings.enableUserAccounts) return true - if (!studioId || !isProtectedString(studioId)) throw new Meteor.Error(400, 'selector must contain studioId') - - const access = await allowAccessToStudio(cred, studioId) - if (!access.read) return logNotAllowed('Studio content', access.reason) - - return true - } -} - -/** - * This is returned from a check of access to a studio. - * Fields will be populated about the user, and the studio if they have permission - */ -export interface StudioContentAccess { - userId: UserId | null - organizationId: OrganizationId | null - studioId: StudioId - studio: StudioLight - cred: ResolvedCredentials | Credentials -} - -export interface ExternalMessageContentAccess extends StudioContentAccess { - message: ExternalMessageQueueObj -} - -export namespace StudioContentWriteAccess { - // These functions throws if access is not allowed. - - export async function rundownPlaylist( - cred0: Credentials, - existingPlaylist: DBRundownPlaylist | RundownPlaylistId - ): Promise { - triggerWriteAccess() - if (existingPlaylist && isProtectedString(existingPlaylist)) { - const playlistId = existingPlaylist - const m = await RundownPlaylists.findOneAsync(playlistId) - if (!m) throw new Meteor.Error(404, `RundownPlaylist "${playlistId}" not found!`) - existingPlaylist = m - } - return { ...(await anyContent(cred0, existingPlaylist.studioId)), playlist: existingPlaylist } - } - - /** Check for permission to restore snapshots into the studio */ - export async function dataFromSnapshot(cred0: Credentials, studioId: StudioId): Promise { - return anyContent(cred0, studioId) - } - - /** Check for permission to select active routesets in the studio */ - export async function routeSet(cred0: Credentials, studioId: StudioId): Promise { - return anyContent(cred0, studioId) - } - - export async function timelineDatastore(cred0: Credentials, studioId: StudioId): Promise { - return anyContent(cred0, studioId) - } - /** Check for permission to update the studio baseline */ - export async function baseline(cred0: Credentials, studioId: StudioId): Promise { - return anyContent(cred0, studioId) - } - - /** Check for permission to modify a bucket or its contents belonging to the studio */ - export async function bucket(cred0: Credentials, studioId: StudioId): Promise { - return anyContent(cred0, studioId) - } - - /** Check for permission to execute a function on a PeripheralDevice in the studio */ - export async function executeFunction(cred0: Credentials, studioId: StudioId): Promise { - return anyContent(cred0, studioId) - } - - /** Check for permission to modify an ExternalMessageQueueObj */ - export async function externalMessage( - cred0: Credentials, - existingMessage: ExternalMessageQueueObj | ExternalMessageQueueObjId - ): Promise { - triggerWriteAccess() - if (existingMessage && isProtectedString(existingMessage)) { - const messageId = existingMessage - const m = await ExternalMessageQueue.findOneAsync(messageId) - if (!m) throw new Meteor.Error(404, `ExternalMessage "${messageId}" not found!`) - existingMessage = m - } - return { ...(await anyContent(cred0, existingMessage.studioId)), message: existingMessage } - } - - /** - * We don't have user levels, so we can use a simple check for all cases - * Return credentials if writing is allowed, throw otherwise - */ - async function anyContent(cred0: Credentials, studioId: StudioId): Promise { - triggerWriteAccess() - if (!Settings.enableUserAccounts) { - const studio = await fetchStudioLight(studioId) - if (!studio) throw new Meteor.Error(404, `Studio "${studioId}" not found`) - - return { - userId: null, - organizationId: null, - studioId: studioId, - studio: studio, - cred: cred0, - } - } - const cred = await resolveCredentials(cred0) - if (!cred.user) throw new Meteor.Error(403, `Not logged in`) - if (!cred.organizationId) throw new Meteor.Error(500, `User has no organization`) - - const access = await allowAccessToStudio(cred, studioId) - if (!access.update) throw new Meteor.Error(403, `Not allowed: ${access.reason}`) - if (!access.document) throw new Meteor.Error(404, `Studio "${studioId}" not found`) - - return { - userId: cred.user._id, - organizationId: cred.organizationId, - studioId: studioId, - studio: access.document, - cred: cred, - } - } -} diff --git a/meteor/server/security/system.ts b/meteor/server/security/system.ts deleted file mode 100644 index d7d13b760e..0000000000 --- a/meteor/server/security/system.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Meteor } from 'meteor/meteor' -import { Credentials, resolveAuthenticatedUser, resolveCredentials } from './lib/credentials' -import { logNotAllowed } from './lib/lib' -import { allowAccessToCoreSystem, allowAccessToCurrentUser, allowAccessToSystemStatus } from './lib/security' -import { Settings } from '../Settings' -import { triggerWriteAccess } from './lib/securityVerify' -import { UserId } from '@sofie-automation/corelib/dist/dataModel/Ids' - -export namespace SystemReadAccess { - /** Handles read access for all organization content (segments, parts, pieces etc..) */ - export async function coreSystem(cred0: Credentials): Promise { - const cred = await resolveCredentials(cred0) - - const access = await allowAccessToCoreSystem(cred) - if (!access.read) return logNotAllowed('CoreSystem', access.reason) - - return true - } - /** Check if access is allowed to read a User, and that user is the current User */ - export async function currentUser(userId: UserId, cred: Credentials): Promise { - const access = await allowAccessToCurrentUser(cred, userId) - if (!access.read) return logNotAllowed('Current user', access.reason) - - return true - } - /** Check permissions to get the system status */ - export async function systemStatus(cred0: Credentials): Promise { - // For reading only - triggerWriteAccess() - const access = await allowAccessToSystemStatus(cred0) - if (!access.read) throw new Meteor.Error(403, `Not allowed: ${access.reason}`) - - return true - } -} -export namespace SystemWriteAccess { - // These functions throws if access is not allowed. - - export async function coreSystem(cred0: Credentials): Promise { - triggerWriteAccess() - if (!Settings.enableUserAccounts) return true - const cred = await resolveAuthenticatedUser(cred0) - if (!cred) throw new Meteor.Error(403, `Not logged in`) - - const access = await allowAccessToCoreSystem(cred) - if (!access.configure) throw new Meteor.Error(403, `Not allowed: ${access.reason}`) - - return true - } - /** Check if access is allowed to modify a User, and that user is the current User */ - export async function currentUser(userId: UserId | null, cred: Credentials): Promise { - const access = await allowAccessToCurrentUser(cred, userId) - if (!access.update) return logNotAllowed('Current user', access.reason) - - return true - } - /** Check permissions to run migrations of all types */ - export async function migrations(cred0: Credentials): Promise { - return coreSystem(cred0) - } - /** Check permissions to perform a system-level action */ - export async function systemActions(cred0: Credentials): Promise { - return coreSystem(cred0) - } -} diff --git a/meteor/server/security/translationsBundles.ts b/meteor/server/security/translationsBundles.ts deleted file mode 100644 index b7733f1517..0000000000 --- a/meteor/server/security/translationsBundles.ts +++ /dev/null @@ -1,8 +0,0 @@ -export namespace TranslationsBundlesSecurity { - export function allowReadAccess(_selector: object, _token: string | undefined, _context: unknown): boolean { - return true - } - export function allowWriteAccess(): boolean { - return false - } -} diff --git a/meteor/server/systemStatus/api.ts b/meteor/server/systemStatus/api.ts index d81b351114..6a95a37388 100644 --- a/meteor/server/systemStatus/api.ts +++ b/meteor/server/systemStatus/api.ts @@ -6,7 +6,6 @@ import { } from '@sofie-automation/meteor-lib/dist/api/systemStatus' import { getDebugStates, getSystemStatus } from './systemStatus' import { protectString } from '../lib/tempLib' -import { Settings } from '../Settings' import { MethodContextAPI } from '../api/methodContext' import { profiler } from '../api/profiler' import { PeripheralDeviceId } from '@sofie-automation/corelib/dist/dataModel/Ids' @@ -22,53 +21,38 @@ const apmNamespace = 'http' export const metricsRouter = new KoaRouter() export const healthRouter = new KoaRouter() -if (!Settings.enableUserAccounts) { - // For backwards compatibility: +metricsRouter.get('/', async (ctx) => { + const transaction = profiler.startTransaction('metrics', apmNamespace) + try { + ctx.response.type = PrometheusHTTPContentType - metricsRouter.get('/', async (ctx) => { - const transaction = profiler.startTransaction('metrics', apmNamespace) - try { - ctx.response.type = PrometheusHTTPContentType + const [meteorMetrics, workerMetrics] = await Promise.all([ + getPrometheusMetricsString(), + collectWorkerPrometheusMetrics(), + ]) - const [meteorMetrics, workerMetrics] = await Promise.all([ - getPrometheusMetricsString(), - collectWorkerPrometheusMetrics(), - ]) - - ctx.body = [meteorMetrics, ...workerMetrics].join('\n\n') - } catch (ex) { - ctx.response.status = 500 - ctx.body = ex + '' - } - transaction?.end() - }) - - healthRouter.get('/', async (ctx) => { - const transaction = profiler.startTransaction('health', apmNamespace) - const status = await getSystemStatus({ userId: null }) - health(status, ctx) - transaction?.end() - }) + ctx.body = [meteorMetrics, ...workerMetrics].join('\n\n') + } catch (ex) { + ctx.response.status = 500 + ctx.body = ex + '' + } + transaction?.end() +}) - healthRouter.get('/:studioId', async (ctx) => { - const transaction = profiler.startTransaction('health', apmNamespace) - const status = await getSystemStatus({ userId: null }, protectString(ctx.params.studioId)) - health(status, ctx) - transaction?.end() - }) -} -healthRouter.get('/:token', async (ctx) => { +healthRouter.get('/', async (ctx) => { const transaction = profiler.startTransaction('health', apmNamespace) - const status = await getSystemStatus({ userId: null, token: ctx.params.token }) + const status = await getSystemStatus(ctx) health(status, ctx) transaction?.end() }) -healthRouter.get('/:token/:studioId', async (ctx) => { + +healthRouter.get('/:studioId', async (ctx) => { const transaction = profiler.startTransaction('health', apmNamespace) - const status = await getSystemStatus({ userId: null, token: ctx.params.token }, protectString(ctx.params.studioId)) + const status = await getSystemStatus(ctx, protectString(ctx.params.studioId)) health(status, ctx) transaction?.end() }) + function health(status: StatusResponse, ctx: Koa.ParameterizedContext) { ctx.response.type = 'application/json' @@ -79,7 +63,7 @@ function health(status: StatusResponse, ctx: Koa.ParameterizedContext) { class ServerSystemStatusAPI extends MethodContextAPI implements NewSystemStatusAPI { async getSystemStatus() { - return getSystemStatus(this) + return getSystemStatus(this.connection) } async getDebugStates(peripheralDeviceId: PeripheralDeviceId) { diff --git a/meteor/server/systemStatus/systemStatus.ts b/meteor/server/systemStatus/systemStatus.ts index 9e48106430..34ae34ce49 100644 --- a/meteor/server/systemStatus/systemStatus.ts +++ b/meteor/server/systemStatus/systemStatus.ts @@ -1,4 +1,3 @@ -import { Meteor } from 'meteor/meteor' import { PeripheralDevice, PERIPHERAL_SUBTYPE_PROCESS } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' import { Time, getRandomId, literal } from '../lib/tempLib' import { getCurrentTime } from '../lib/lib' @@ -18,19 +17,15 @@ import { Component, } from '@sofie-automation/meteor-lib/dist/api/systemStatus' import { RelevantSystemVersions } from '../coreSystem' -import { Settings } from '../Settings' -import { StudioReadAccess } from '../security/studio' -import { OrganizationReadAccess } from '../security/organization' -import { resolveCredentials, Credentials } from '../security/lib/credentials' -import { SystemReadAccess } from '../security/system' import { StatusCode } from '@sofie-automation/blueprints-integration' import { PeripheralDevices, Workers, WorkerThreadStatuses } from '../collections' import { PeripheralDeviceId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { ServerPeripheralDeviceAPI } from '../api/peripheralDevice' -import { PeripheralDeviceContentWriteAccess } from '../security/peripheralDevice' import { MethodContext } from '../api/methodContext' import { getBlueprintVersions } from './blueprintVersions' import { getUpgradeSystemStatusMessages } from './blueprintUpgradeStatus' +import { triggerWriteAccessBecauseNoCheckNecessary } from '../security/securityVerify' +import { assertConnectionHasOneOfPermissions, RequestCredentials } from '../security/auth' const PackageInfo = require('../../package.json') const integrationVersionRange = parseCoreIntegrationCompatabilityRange(PackageInfo.version) @@ -166,10 +161,12 @@ function getSystemStatusForDevice(device: PeripheralDevice): StatusResponse { * Returns system status * @param studioId (Optional) If provided, limits the status to what's affecting the studio */ -export async function getSystemStatus(cred0: Credentials, studioId?: StudioId): Promise { - const checks: Array = [] +export async function getSystemStatus(_cred: RequestCredentials | null, studioId?: StudioId): Promise { + // Future: this should consider the studioId + // For now, all users should have access to all statuses + triggerWriteAccessBecauseNoCheckNecessary() - await SystemReadAccess.systemStatus(cred0) + const checks: Array = [] // Check systemStatuses: for (const [key, status] of Object.entries(systemStatuses)) { @@ -251,25 +248,11 @@ export async function getSystemStatus(cred0: Credentials, studioId?: StudioId): if (studioId) { // Check status for a certain studio: - if (!(await StudioReadAccess.studioContent(studioId, cred0))) { - throw new Meteor.Error(403, `Not allowed`) - } devices = await PeripheralDevices.findFetchAsync({ studioId: studioId }) } else { - if (Settings.enableUserAccounts) { - // Check status for the user's studios: + // Check status for all studios: - const cred = await resolveCredentials(cred0) - if (!cred.organizationId) throw new Meteor.Error(500, 'user has no organization') - if (!(await OrganizationReadAccess.organizationContent(cred.organizationId, cred))) { - throw new Meteor.Error(403, `Not allowed`) - } - devices = await PeripheralDevices.findFetchAsync({ organizationId: cred.organizationId }) - } else { - // Check status for all studios: - - devices = await PeripheralDevices.findFetchAsync({}) - } + devices = await PeripheralDevices.findFetchAsync({}) } for (const device of devices) { const so = getSystemStatusForDevice(device) @@ -405,6 +388,7 @@ export async function getDebugStates( methodContext: MethodContext, peripheralDeviceId: PeripheralDeviceId ): Promise { - const access = await PeripheralDeviceContentWriteAccess.peripheralDevice(methodContext, peripheralDeviceId) - return ServerPeripheralDeviceAPI.getDebugStates(access) + assertConnectionHasOneOfPermissions(methodContext.connection, 'developer') + + return ServerPeripheralDeviceAPI.getDebugStates(peripheralDeviceId) } diff --git a/meteor/server/worker/worker.ts b/meteor/server/worker/worker.ts index 6a4b8651cf..cdc1bbbb6c 100644 --- a/meteor/server/worker/worker.ts +++ b/meteor/server/worker/worker.ts @@ -21,6 +21,7 @@ import { initializeWorkerStatus, setWorkerStatus } from './workerStatus' import { MongoQuery } from '@sofie-automation/corelib/dist/mongo' import { UserActionsLog } from '../collections' import { MetricsCounter } from '@sofie-automation/corelib/dist/prometheus' +import { isInTestWrite } from '../security/securityVerify' const FREEZE_LIMIT = 1000 // how long to wait for a response to a Ping const RESTART_TIMEOUT = 30000 // how long to wait for a restart to complete before throwing an error @@ -459,6 +460,7 @@ export async function QueueStudioJob( studioId: StudioId, jobParameters: Parameters[0] ): Promise>> { + if (isInTestWrite()) throw new Meteor.Error(404, 'Should not be reachable during startup tests') if (!studioId) throw new Meteor.Error(500, 'Missing studioId') const now = getCurrentTime() diff --git a/packages/corelib/src/dataModel/Collections.ts b/packages/corelib/src/dataModel/Collections.ts index 6560aab026..8f2dd0f5fc 100644 --- a/packages/corelib/src/dataModel/Collections.ts +++ b/packages/corelib/src/dataModel/Collections.ts @@ -45,7 +45,6 @@ export enum CollectionName { TriggeredActions = 'triggeredActions', TranslationsBundles = 'translationsBundles', UserActionsLog = 'userActionsLog', - Users = 'Users', Workers = 'workers', WorkerThreads = 'workersThreads', } diff --git a/packages/meteor-lib/src/Settings.ts b/packages/meteor-lib/src/Settings.ts index 11c26a3a47..6993122406 100644 --- a/packages/meteor-lib/src/Settings.ts +++ b/packages/meteor-lib/src/Settings.ts @@ -17,8 +17,8 @@ export interface ISettings { defaultTimeScale: number // Allow grabbing the entire timeline allowGrabbingTimeline: boolean - /** If true, enables security measures, access control and user accounts. */ - enableUserAccounts: boolean + /** If true, enable http header based security measures */ + enableHeaderAuth: boolean /** Default duration to use to render parts when no duration is provided */ defaultDisplayDuration: number /** If true, allows creation of new playlists in the Lobby Gui (rundown list). If false; only pre-existing playlists are allowed. */ @@ -69,7 +69,7 @@ export const DEFAULT_SETTINGS = Object.freeze({ disableBlurBorder: false, defaultTimeScale: 1, allowGrabbingTimeline: true, - enableUserAccounts: false, + enableHeaderAuth: false, defaultDisplayDuration: 3000, allowMultiplePlaylistsInGUI: false, poisonKey: 'Escape', diff --git a/packages/meteor-lib/src/api/pubsub.ts b/packages/meteor-lib/src/api/pubsub.ts index 49c18b7816..9c61409c18 100644 --- a/packages/meteor-lib/src/api/pubsub.ts +++ b/packages/meteor-lib/src/api/pubsub.ts @@ -20,7 +20,6 @@ import { SnapshotItem } from '../collections/Snapshots' import { TranslationsBundle } from '../collections/TranslationsBundles' import { DBTriggeredActions, UITriggeredActionsObj } from '../collections/TriggeredActions' import { UserActionsLogItem } from '../collections/UserActionsLog' -import { DBUser } from '../collections/Users' import { UIBucketContentStatus, UIPieceContentStatus, UISegmentPartNote } from './rundownNotifications' import { UIShowStyleBase } from './showStyles' import { UIStudio } from './studios' @@ -218,8 +217,6 @@ export interface MeteorPubSubTypes { showStyleBaseIds: ShowStyleBaseId[] | null, token?: string ) => CollectionName.RundownLayouts - [MeteorPubSub.loggedInUser]: (token?: string) => CollectionName.Users - [MeteorPubSub.usersInOrganization]: (organizationId: OrganizationId, token?: string) => CollectionName.Users [MeteorPubSub.organization]: (organizationId: OrganizationId | null, token?: string) => CollectionName.Organizations [MeteorPubSub.buckets]: (studioId: StudioId, bucketId: BucketId | null, token?: string) => CollectionName.Buckets [MeteorPubSub.translationsBundles]: (token?: string) => CollectionName.TranslationsBundles @@ -297,7 +294,6 @@ export type MeteorPubSubCollections = { [CollectionName.Organizations]: DBOrganization [CollectionName.Buckets]: Bucket [CollectionName.TranslationsBundles]: TranslationsBundle - [CollectionName.Users]: DBUser [CollectionName.ExpectedPlayoutItems]: ExpectedPlayoutItem [CollectionName.Notifications]: DBNotificationObj diff --git a/packages/meteor-lib/src/api/user.ts b/packages/meteor-lib/src/api/user.ts index f1f737f271..83881d2819 100644 --- a/packages/meteor-lib/src/api/user.ts +++ b/packages/meteor-lib/src/api/user.ts @@ -1,33 +1,8 @@ -import { UserProfile } from '../collections/Users' -import { UserId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { UserPermissions } from '../userPermissions' export interface NewUserAPI { - enrollUser(email: string, name: string): Promise - requestPasswordReset(email: string): Promise - removeUser(): Promise + getUserPermissions(): Promise } export enum UserAPIMethods { - 'enrollUser' = 'user.enrollUser', - 'requestPasswordReset' = 'user.requestPasswordReset', - 'removeUser' = 'user.removeUser', -} - -export interface CreateNewUserData { - email: string - profile: UserProfile - password?: string - createOrganization?: { - name: string - applications: string[] - broadcastMediums: string[] - } -} -export async function createUser(_newUser: CreateNewUserData): Promise { - // This is available both client-side and server side. - // The reason for that is that the client-side should use Accounts.createUser right away - // so that the password aren't sent in "plaintext" to the server. - - // const userId = await Accounts.createUserAsync(newUser) - // return protectString(userId) - throw new Error('Not implemented') + 'getUserPermissions' = 'user.getUserPermissions', } diff --git a/packages/meteor-lib/src/api/userActions.ts b/packages/meteor-lib/src/api/userActions.ts index 91f521b617..01db5ba8fe 100644 --- a/packages/meteor-lib/src/api/userActions.ts +++ b/packages/meteor-lib/src/api/userActions.ts @@ -212,16 +212,19 @@ export interface NewUserActionAPI { mediaRestartWorkflow( userEvent: string, eventTime: Time, + deviceId: PeripheralDeviceId, workflowId: MediaWorkFlowId ): Promise> mediaAbortWorkflow( userEvent: string, eventTime: Time, + deviceId: PeripheralDeviceId, workflowId: MediaWorkFlowId ): Promise> mediaPrioritizeWorkflow( userEvent: string, eventTime: Time, + deviceId: PeripheralDeviceId, workflowId: MediaWorkFlowId ): Promise> mediaRestartAllWorkflows(userEvent: string, eventTime: Time): Promise> diff --git a/packages/meteor-lib/src/collections/Users.ts b/packages/meteor-lib/src/collections/Users.ts deleted file mode 100644 index 04d5b6b887..0000000000 --- a/packages/meteor-lib/src/collections/Users.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { UserId, OrganizationId } from '@sofie-automation/corelib/dist/dataModel/Ids' - -export interface UserProfile { - name: string -} - -export interface DBUser { - // Note: This interface is partly defined by the dataset from the Meteor.users collection - - _id: UserId - createdAt: string - services: { - password: { - bcrypt: string - } - } - username: string - emails: [ - { - address: string - verified: boolean - } - ] - profile: UserProfile - organizationId: OrganizationId - superAdmin?: boolean -} - -export type User = DBUser // to be replaced by a class somet ime later? diff --git a/packages/meteor-lib/src/userPermissions.ts b/packages/meteor-lib/src/userPermissions.ts new file mode 100644 index 0000000000..2d0f1246f3 --- /dev/null +++ b/packages/meteor-lib/src/userPermissions.ts @@ -0,0 +1,58 @@ +/** + * The header to use for user permissions + * This is currently limited to a small set that sockjs supports: https://github.com/sockjs/sockjs-node/blob/46d2f846653a91822a02794b852886c7f137378c/lib/session.js#L137-L150 + * Any other headers are not exposed in a way we can access, no matter how deep we look into meteor internals. + */ +export const USER_PERMISSIONS_HEADER = 'dnt' + +export interface UserPermissions { + studio: boolean + configure: boolean + developer: boolean + testing: boolean + service: boolean + gateway: boolean +} +const allowedPermissions = new Set([ + 'studio', + 'configure', + 'developer', + 'testing', + 'service', + 'gateway', +]) + +export function parseUserPermissions(encodedPermissions: string | undefined): UserPermissions { + if (encodedPermissions === 'admin') { + return { + studio: true, + configure: true, + developer: true, + testing: true, + service: true, + gateway: true, + } + } + + const result: UserPermissions = { + studio: false, + configure: false, + developer: false, + testing: false, + service: false, + gateway: false, + } + + if (encodedPermissions && typeof encodedPermissions === 'string') { + const parts = encodedPermissions.split(',') + + for (const part of parts) { + const part2 = part.trim() as keyof UserPermissions + if (allowedPermissions.has(part2)) { + result[part2] = true + } + } + } + + return result +} diff --git a/packages/shared-lib/src/peripheralDevice/methodsAPI.ts b/packages/shared-lib/src/peripheralDevice/methodsAPI.ts index 80dd4dd366..1f8a142f7b 100644 --- a/packages/shared-lib/src/peripheralDevice/methodsAPI.ts +++ b/packages/shared-lib/src/peripheralDevice/methodsAPI.ts @@ -116,7 +116,7 @@ export interface NewPeripheralDeviceAPI { timelineTriggerTime(deviceId: PeripheralDeviceId, deviceToken: string, r: TimelineTriggerTimeResult): Promise requestUserAuthToken(deviceId: PeripheralDeviceId, deviceToken: string, authUrl: string): Promise storeAccessToken(deviceId: PeripheralDeviceId, deviceToken: string, authToken: string): Promise - removePeripheralDevice(deviceId: PeripheralDeviceId): Promise + removePeripheralDevice(deviceId: PeripheralDeviceId, deviceToken?: string): Promise reportResolveDone( deviceId: PeripheralDeviceId, deviceToken: string, diff --git a/packages/webui/src/__mocks__/meteor.ts b/packages/webui/src/__mocks__/meteor.ts index f11ac9b999..170ed3d5dd 100644 --- a/packages/webui/src/__mocks__/meteor.ts +++ b/packages/webui/src/__mocks__/meteor.ts @@ -1,5 +1,4 @@ import * as _ from 'underscore' -import { MongoMock } from './mongo' import type { DDP } from 'meteor/ddp' let controllableDefer = false @@ -11,7 +10,7 @@ export function useNextTickDefer(): void { controllableDefer = false } -namespace Meteor { +export namespace Meteor { export interface Settings { public: { [id: string]: any @@ -19,19 +18,6 @@ namespace Meteor { [id: string]: any } - export interface UserEmail { - address: string - verified: boolean - } - export interface User { - _id?: string - username?: string - emails?: UserEmail[] - createdAt?: number - profile?: any - services?: any - } - export interface ErrorStatic { new (error: string | number, reason?: string, details?: string): Error } @@ -89,7 +75,6 @@ export namespace MeteorMock { export const settings: any = {} export const mockMethods: { [name: string]: Function } = {} - export let mockUser: Meteor.User | undefined = undefined export const mockStartupFunctions: Function[] = [] export function status(): DDP.DDPStatus { @@ -100,15 +85,8 @@ export namespace MeteorMock { } } - export function user(): Meteor.User | undefined { - return mockUser - } - export function userId(): string | undefined { - return mockUser ? mockUser._id : undefined - } function getMethodContext() { return { - userId: mockUser ? mockUser._id : undefined, connection: { clientAddress: '1.1.1.1', }, @@ -223,7 +201,6 @@ export namespace MeteorMock { export function bindEnvironment(_fcn: Function): any { throw new Error(500, 'bindEnvironment not supported on client') } - export let users: MongoMock.Collection | undefined = undefined // -- Mock functions: -------------------------- /** @@ -236,12 +213,6 @@ export namespace MeteorMock { await waitTimeNoFakeTimers(10) // So that any observers or defers has had time to run. } - export function mockLoginUser(newUser: Meteor.User): void { - mockUser = newUser - } - export function mockSetUsersCollection(usersCollection: MongoMock.Collection): void { - users = usersCollection - } /** Wait for time to pass ( unaffected by jest.useFakeTimers() ) */ export async function sleepNoFakeTimers(time: number): Promise { diff --git a/packages/webui/src/__mocks__/mongo.ts b/packages/webui/src/__mocks__/mongo.ts index 2f31d6400b..7a0d8566cb 100644 --- a/packages/webui/src/__mocks__/mongo.ts +++ b/packages/webui/src/__mocks__/mongo.ts @@ -349,5 +349,3 @@ export function setup(): any { Mongo: MongoMock, } } - -MeteorMock.mockSetUsersCollection(new MongoMock.Collection('Meteor.users')) diff --git a/packages/webui/src/client/ui/App.tsx b/packages/webui/src/client/ui/App.tsx index 77c4d7afa7..73d5ad43d0 100644 --- a/packages/webui/src/client/ui/App.tsx +++ b/packages/webui/src/client/ui/App.tsx @@ -52,7 +52,7 @@ export const App: React.FC = function App() { const [lastStart] = useState(Date.now()) - const roles = useUserPermissions() + const [roles, _rolesReady] = useUserPermissions() const featureFlags = useFeatureFlags() useEffect(() => { diff --git a/packages/webui/src/client/ui/Status/MediaManager.tsx b/packages/webui/src/client/ui/Status/MediaManager.tsx index 3471ce6b0b..d7401b0e88 100644 --- a/packages/webui/src/client/ui/Status/MediaManager.tsx +++ b/packages/webui/src/client/ui/Status/MediaManager.tsx @@ -364,7 +364,7 @@ export function MediaManagerStatus(): JSX.Element { const actionRestart = useCallback( (event: React.MouseEvent, workflow: MediaWorkFlowUi) => { doUserAction(t, event, UserAction.RESTART_MEDIA_WORKFLOW, (e, ts) => - MeteorCall.userAction.mediaRestartWorkflow(e, ts, workflow._id) + MeteorCall.userAction.mediaRestartWorkflow(e, ts, workflow.deviceId, workflow._id) ) }, [t] @@ -372,7 +372,7 @@ export function MediaManagerStatus(): JSX.Element { const actionAbort = useCallback( (event: React.MouseEvent, workflow: MediaWorkFlowUi) => { doUserAction(t, event, UserAction.ABORT_MEDIA_WORKFLOW, (e, ts) => - MeteorCall.userAction.mediaAbortWorkflow(e, ts, workflow._id) + MeteorCall.userAction.mediaAbortWorkflow(e, ts, workflow.deviceId, workflow._id) ) }, [t] @@ -380,7 +380,7 @@ export function MediaManagerStatus(): JSX.Element { const actionPrioritize = useCallback( (event: React.MouseEvent, workflow: MediaWorkFlowUi) => { doUserAction(t, event, UserAction.PRIORITIZE_MEDIA_WORKFLOW, (e, ts) => - MeteorCall.userAction.mediaPrioritizeWorkflow(e, ts, workflow._id) + MeteorCall.userAction.mediaPrioritizeWorkflow(e, ts, workflow.deviceId, workflow._id) ) }, [t] diff --git a/packages/webui/src/client/ui/Status/SystemStatus/SystemStatus.tsx b/packages/webui/src/client/ui/Status/SystemStatus/SystemStatus.tsx index bc047ccc19..346d19f6a9 100644 --- a/packages/webui/src/client/ui/Status/SystemStatus/SystemStatus.tsx +++ b/packages/webui/src/client/ui/Status/SystemStatus/SystemStatus.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useContext, useEffect, useMemo, useState } from 'react' import { useSubscription, useTracker } from '../../../lib/ReactMeteorData/react-meteor-data' import { PeripheralDevice, PeripheralDeviceType } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' import { useTranslation } from 'react-i18next' @@ -13,10 +13,13 @@ import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' import { CoreItem } from './CoreItem' import { DeviceItem } from './DeviceItem' +import { UserPermissions, UserPermissionsContext } from '../../UserPermissions' export function SystemStatus(): JSX.Element { const { t } = useTranslation() + const userPermissions = useContext(UserPermissionsContext) + // Subscribe to data: useSubscription(CorelibPubSub.peripheralDevices, null) @@ -24,7 +27,7 @@ export function SystemStatus(): JSX.Element { const devices = useTracker(() => PeripheralDevices.find({}, { sort: { lastConnected: -1 } }).fetch(), [], []) const systemStatus = useSystemStatus() - const playoutDebugStates = usePlayoutDebugStates(devices) + const playoutDebugStates = usePlayoutDebugStates(devices, userPermissions) const devicesHierarchy = convertDevicesIntoHeirarchy(devices) @@ -98,7 +101,10 @@ function useSystemStatus(): StatusResponse | undefined { return sytemStatus } -function usePlayoutDebugStates(devices: PeripheralDevice[]): Map { +function usePlayoutDebugStates( + devices: PeripheralDevice[], + userPermissions: UserPermissions +): Map { const { t } = useTranslation() const [playoutDebugStates, setPlayoutDebugStates] = useState>(new Map()) @@ -117,6 +123,11 @@ function usePlayoutDebugStates(devices: PeripheralDevice[]): Map { + if (!userPermissions.developer) { + setPlayoutDebugStates(new Map()) + return + } + let destroyed = false const refreshDebugStates = () => { @@ -145,7 +156,7 @@ function usePlayoutDebugStates(devices: PeripheralDevice[]): Map>({ +const NO_PERMISSIONS: UserPermissions = Object.freeze({ studio: false, configure: false, developer: false, testing: false, service: false, + gateway: false, }) -export function useUserPermissions(): UserPermissions { +export const UserPermissionsContext = React.createContext>(NO_PERMISSIONS) + +export function useUserPermissions(): [roles: UserPermissions, ready: boolean] { const location = window.location - const [permissions, setPermissions] = useState({ - studio: getLocalAllowStudio(), - configure: getLocalAllowConfigure(), - developer: getLocalAllowDeveloper(), - testing: getLocalAllowTesting(), - service: getLocalAllowService(), - }) + const [ready, setReady] = useState(!Settings.enableHeaderAuth) + + const [permissions, setPermissions] = useState( + Settings.enableHeaderAuth + ? NO_PERMISSIONS + : { + studio: getLocalAllowStudio(), + configure: getLocalAllowConfigure(), + developer: getLocalAllowDeveloper(), + testing: getLocalAllowTesting(), + service: getLocalAllowService(), + gateway: false, + } + ) + + const isConnected = useTracker(() => Meteor.status().connected, [], false) + + useEffect(() => { + if (!Settings.enableHeaderAuth) return + + // Do nothing when not connected. Persist the previous values. + if (!isConnected) return + + const checkPermissions = () => { + MeteorCall.user + .getUserPermissions() + .then((v) => { + setPermissions(v || NO_PERMISSIONS) + setReady(true) + }) + .catch((e) => { + console.error('Failed to set level', e) + setPermissions(NO_PERMISSIONS) + }) + } + + const interval = setInterval(checkPermissions, 30000) // Arbitrary poll interval + + // Initial check now + checkPermissions() + + return () => { + clearInterval(interval) + } + }, [Settings.enableHeaderAuth, isConnected]) useEffect(() => { + if (Settings.enableHeaderAuth) return + if (!location.search) return const params = queryStringParse(location.search) @@ -66,9 +108,10 @@ export function useUserPermissions(): UserPermissions { developer: getLocalAllowDeveloper(), testing: getLocalAllowTesting(), service: getLocalAllowService(), + gateway: false, }) - }, [location.search]) + }, [location.search, Settings.enableHeaderAuth]) // A naive memoizing of the value, to avoid reactions when the value is identical - return useMemo(() => permissions, [JSON.stringify(permissions)]) + return [useMemo(() => permissions, [JSON.stringify(permissions)]), ready] } diff --git a/packages/webui/src/meteor/meteor.js b/packages/webui/src/meteor/meteor.js index ada0224220..049cad2f65 100644 --- a/packages/webui/src/meteor/meteor.js +++ b/packages/webui/src/meteor/meteor.js @@ -234,78 +234,6 @@ Meteor.Error.prototype.clone = function () { return new Meteor.Error(self.error, self.reason, self.details) } -/** - * @summary Generate an absolute URL pointing to the application. The server reads from the `ROOT_URL` environment variable to determine where it is running. This is taken care of automatically for apps deployed to Galaxy, but must be provided when using `meteor build`. - * @locus Anywhere - * @param {String} [path] A path to append to the root URL. Do not include a leading "`/`". - * @param {Object} [options] - * @param {Boolean} options.secure Create an HTTPS URL. - * @param {Boolean} options.replaceLocalhost Replace localhost with 127.0.0.1. Useful for services that don't recognize localhost as a domain name. - * @param {String} options.rootUrl Override the default ROOT_URL from the server environment. For example: "`http://foo.example.com`" - */ -Meteor.absoluteUrl = function (path, options) { - // path is optional - if (!options && typeof path === 'object') { - options = path - path = undefined - } - // merge options with defaults - options = Object.assign({}, Meteor.absoluteUrl.defaultOptions, options || {}) - - var url = options.rootUrl - if (!url) throw new Error('Must pass options.rootUrl or set ROOT_URL in the server environment') - - if (!/^http[s]?:\/\//i.test(url)) - // url starts with 'http://' or 'https://' - url = 'http://' + url // we will later fix to https if options.secure is set - - if (!url.endsWith('/')) { - url += '/' - } - - if (path) { - // join url and path with a / separator - while (path.startsWith('/')) { - path = path.slice(1) - } - url += path - } - - // turn http to https if secure option is set, and we're not talking - // to localhost. - if ( - options.secure && - /^http:/.test(url) && // url starts with 'http:' - !/http:\/\/localhost[:\/]/.test(url) && // doesn't match localhost - !/http:\/\/127\.0\.0\.1[:\/]/.test(url) - ) - // or 127.0.0.1 - url = url.replace(/^http:/, 'https:') - - if (options.replaceLocalhost) url = url.replace(/^http:\/\/localhost([:\/].*)/, 'http://127.0.0.1$1') - - return url -} - -// allow later packages to override default options -var defaultOptions = (Meteor.absoluteUrl.defaultOptions = {}) - -// available only in a browser environment -var location = typeof window === 'object' && window.location - -if (typeof window.__meteor_runtime_config__ === 'object' && window.__meteor_runtime_config__.ROOT_URL) { - defaultOptions.rootUrl = window.__meteor_runtime_config__.ROOT_URL -} else if (location && location.protocol && location.host) { - defaultOptions.rootUrl = location.protocol + '//' + location.host -} - -// Make absolute URLs use HTTPS by default if the current window.location -// uses HTTPS. Since this is just a default, it can be overridden by -// passing { secure: false } if necessary. -if (location && location.protocol === 'https:') { - defaultOptions.secure = true -} - Meteor._relativeToSiteRootUrl = function (link) { if (typeof window.__meteor_runtime_config__ === 'object' && link.substr(0, 1) === '/') link = (window.__meteor_runtime_config__.ROOT_URL_PATH_PREFIX || '') + link diff --git a/packages/webui/src/meteor/socket-stream-client/urls.js b/packages/webui/src/meteor/socket-stream-client/urls.js index 232c9bc247..d5bb41e57b 100644 --- a/packages/webui/src/meteor/socket-stream-client/urls.js +++ b/packages/webui/src/meteor/socket-stream-client/urls.js @@ -12,10 +12,6 @@ function translateUrl(url, newSchemeBase, subPath) { newSchemeBase = 'http'; } - if (subPath !== "sockjs" && url.startsWith("/")) { - url = Meteor.absoluteUrl(url.substr(1)); - } - var ddpUrlMatch = url.match(/^ddp(i?)\+sockjs:\/\//); var httpUrlMatch = url.match(/^http(s?):\/\//); var newScheme; diff --git a/packages/webui/vite.config.mts b/packages/webui/vite.config.mts index 69b894f48e..ed80e5b1df 100644 --- a/packages/webui/vite.config.mts +++ b/packages/webui/vite.config.mts @@ -66,6 +66,10 @@ export default defineConfig({ '/api': 'http://127.0.0.1:3000', '/site.webmanifest': 'http://127.0.0.1:3000', '/meteor-runtime-config.js': 'http://127.0.0.1:3000', + '/websocket': { + target: `ws://127.0.0.1:3000`, + ws: true, + }, }, }, diff --git a/scripts/run.mjs b/scripts/run.mjs index 3d15810fc5..5302a5e2f5 100644 --- a/scripts/run.mjs +++ b/scripts/run.mjs @@ -1,15 +1,22 @@ import process from "process"; +import fs from "fs"; import concurrently from "concurrently"; import { EXTRA_PACKAGES, config } from "./lib.js"; +function joinCommand(...parts) { + return parts.filter((part) => !!part).join(" "); +} + function watchPackages() { return [ { - command: config.uiOnly - ? `yarn watch ${EXTRA_PACKAGES.map((pkg) => `--ignore ${pkg}`).join( + command: joinCommand('yarn watch', + config.uiOnly + ? EXTRA_PACKAGES.map((pkg) => `--ignore ${pkg}`).join( " " - )}` - : "yarn watch", + ) + : "", + ), cwd: "packages", name: "PACKAGES-TSC", prefixColor: "red", @@ -29,6 +36,13 @@ function watchWorker() { } function watchMeteor() { + const settingsFileExists = fs.existsSync("meteor-settings.json"); + if (settingsFileExists) { + console.log('Found meteor-settings.json') + } else { + console.log('No meteor-settings.json') + } + return [ { command: "yarn watch-types --preserveWatchOutput", @@ -37,9 +51,12 @@ function watchMeteor() { prefixColor: "blue", }, { - command: `yarn debug${config.inspectMeteor ? " --inspect" : ""}${ - config.verbose ? " --verbose" : "" - }`, + command: joinCommand( + 'yarn debug', + config.inspectMeteor ? " --inspect" : "", + config.verbose ? " --verbose" : "", + settingsFileExists ? " --settings ../meteor-settings.json" : "" + ), cwd: "meteor", name: "METEOR", prefixColor: "cyan",