Skip to content

Commit f431441

Browse files
committed
Merge pull request #1435 from SuperFlyTV/feat/reimplement-client-mongo-writes
feat(webui): reimplement client mongo writes
2 parents da0a0e2 + b282691 commit f431441

File tree

9 files changed

+159
-89
lines changed

9 files changed

+159
-89
lines changed

meteor/server/Connections.ts

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ import { logger } from './logging'
44
import { sendTrace } from './api/integration/influx'
55
import { PeripheralDevices } from './collections'
66
import { MetricsGauge } from '@sofie-automation/corelib/dist/prometheus'
7-
import { parseUserPermissions, USER_PERMISSIONS_HEADER } from '@sofie-automation/meteor-lib/dist/userPermissions'
8-
import { Settings } from './Settings'
97

108
const connections = new Set<string>()
119
const connectionsGauge = new MetricsGauge({
@@ -16,24 +14,6 @@ const connectionsGauge = new MetricsGauge({
1614
Meteor.onConnection((conn: Meteor.Connection) => {
1715
// This is called whenever a new ddp-connection is opened (ie a web-client or a peripheral-device)
1816

19-
if (Settings.enableHeaderAuth) {
20-
const userLevel = parseUserPermissions(conn.httpHeaders[USER_PERMISSIONS_HEADER])
21-
22-
// HACK: force the userId of the connection before it can be used.
23-
// This ensures we know the permissions of the connection before it can try to do anything
24-
// This could probably be safely done inside a meteor method, as we only need it when directly modifying a collection in the client,
25-
// but that will cause all the publications to restart when changing the userId.
26-
const connSession = (Meteor as any).server.sessions.get(conn.id)
27-
if (!connSession) {
28-
logger.error(`Failed to find session for ddp connection! "${conn.id}"`)
29-
// Close the connection, it won't be secure
30-
conn.close()
31-
return
32-
} else {
33-
connSession.userId = JSON.stringify(userLevel)
34-
}
35-
}
36-
3717
const connectionId: string = conn.id
3818
// var clientAddress = conn.clientAddress; // ip-adress
3919

meteor/server/api/mongo.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { registerClassToMeteorMethods } from '../methods'
2+
import { MethodContextAPI } from './methodContext'
3+
import { MongoAPI, MongoAPIMethods } from '@sofie-automation/meteor-lib/dist/api/mongo'
4+
import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections'
5+
import { ProtectedString } from '../lib/tempLib'
6+
import { logger } from '../logging'
7+
import { collectionsAllowDenyCache, collectionsCache } from '../collections/collection'
8+
import { Meteor } from 'meteor/meteor'
9+
import { checkHasOneOfPermissions, parseConnectionPermissions } from '../security/auth'
10+
import { triggerWriteAccess } from '../security/securityVerify'
11+
12+
const hasOwn = Object.prototype.hasOwnProperty
13+
const ALLOWED_UPDATE_OPERATIONS = {
14+
$inc: 1,
15+
$set: 1,
16+
$unset: 1,
17+
$addToSet: 1,
18+
$pop: 1,
19+
$pullAll: 1,
20+
$pull: 1,
21+
$pushAll: 1,
22+
$push: 1,
23+
$bit: 1,
24+
}
25+
26+
class MongoAPIClass extends MethodContextAPI implements MongoAPI {
27+
async insertDocument(collectionName: CollectionName, _newDocument: any): Promise<ProtectedString<any>> {
28+
triggerWriteAccess()
29+
30+
logger.error(`MongoAPI.insertDocument for "${collectionName}"`)
31+
throw new Error('Not supported')
32+
}
33+
34+
async updateDocument(collectionName: CollectionName, selector: any, modifier: any, _options: any): Promise<number> {
35+
triggerWriteAccess()
36+
37+
if (!this.connection) throw new Meteor.Error(403, 'Only supported from the client')
38+
39+
const validator = collectionsAllowDenyCache.get(collectionName)
40+
if (!validator) throw new Meteor.Error(403, `Not allowed to update collection: "${collectionName}`)
41+
42+
const collection = collectionsCache.get(collectionName)
43+
if (!collection) throw new Meteor.Error(403, `Unknown collection: "${collectionName}`)
44+
45+
const permissions = parseConnectionPermissions(this.connection)
46+
if (!checkHasOneOfPermissions(permissions, collectionName, ...validator.requiredPermissions))
47+
throw new Meteor.Error(403, `Not allowed to update collection: "${collectionName}"`)
48+
49+
let documentId: string | null = null
50+
if (typeof selector === 'string') {
51+
documentId = selector
52+
} else if (selector && typeof selector === 'object') {
53+
documentId = selector._id
54+
}
55+
if (!documentId || typeof documentId !== 'string') {
56+
throw new Meteor.Error(403, `Update operations can only do so by id: "${collectionName}"`)
57+
}
58+
59+
const mutatorKeys = Object.keys(modifier)
60+
if (mutatorKeys.length === 0) {
61+
throw new Meteor.Error(403, 'Update modifier is not valid.')
62+
}
63+
64+
// compute modified fields
65+
const modifiedFields = new Set<string>()
66+
mutatorKeys.forEach((op) => {
67+
const params = modifier[op]
68+
if (op.charAt(0) !== '$') {
69+
throw new Meteor.Error(403, 'Update modifier is not valid.')
70+
} else if (!hasOwn.call(ALLOWED_UPDATE_OPERATIONS, op)) {
71+
throw new Meteor.Error(403, `Access denied. Operator ${op} not allowed in a restricted collection.`)
72+
} else {
73+
Object.keys(params).forEach((field) => {
74+
// treat dotted fields as if they are replacing their
75+
// top-level part
76+
if (field.indexOf('.') !== -1) field = field.substring(0, field.indexOf('.'))
77+
78+
// record the field we are trying to change
79+
modifiedFields.add(field)
80+
})
81+
}
82+
})
83+
84+
const currentDocument = await collection.findOneAsync(selector)
85+
if (!currentDocument) throw new Meteor.Error(404, `Document not found`)
86+
87+
// Perform check
88+
const isAllowed = await validator.update(permissions, currentDocument, Array.from(modifiedFields), modifier)
89+
if (!isAllowed) throw new Meteor.Error(403, `Not allowed to update collection: "${collectionName}"`)
90+
91+
// Perform update
92+
return collection.updateAsync(currentDocument._id, modifier)
93+
}
94+
95+
async removeDocument(collectionName: CollectionName, _selector: any): Promise<any> {
96+
triggerWriteAccess()
97+
98+
logger.error(`MongoAPI.insertDocument for "${collectionName}"`)
99+
throw new Meteor.Error(500, 'Not supported')
100+
}
101+
}
102+
registerClassToMeteorMethods(MongoAPIMethods, MongoAPIClass, true)

meteor/server/collections/collection.ts

Lines changed: 15 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { UserId } from '@sofie-automation/corelib/dist/dataModel/Ids'
21
import { FindOptions, MongoModifier, MongoQuery } from '@sofie-automation/corelib/dist/mongo'
3-
import { ProtectedString, protectString } from '@sofie-automation/corelib/dist/protectedString'
2+
import { ProtectedString } from '@sofie-automation/corelib/dist/protectedString'
43
import { Meteor } from 'meteor/meteor'
54
import { Mongo } from 'meteor/mongo'
65
import { NpmModuleMongodb } from 'meteor/npm-mongo'
@@ -20,18 +19,22 @@ import {
2019
UpsertOptions,
2120
} from '@sofie-automation/meteor-lib/dist/collections/lib'
2221
import { MinimalMongoCursor } from './implementations/asyncCollection'
22+
import { UserPermissions } from '@sofie-automation/meteor-lib/dist/userPermissions'
2323

24-
export interface MongoAllowRules<DBInterface> {
24+
export interface CustomMongoAllowRules<DBInterface> {
2525
// insert?: (userId: UserId | null, doc: DBInterface) => Promise<boolean> | boolean
26-
update?: (
27-
userId: UserId | null,
26+
requiredPermissions: Array<keyof UserPermissions>
27+
update: (
28+
permissions: UserPermissions,
2829
doc: DBInterface,
2930
fieldNames: FieldNames<DBInterface>,
3031
modifier: MongoModifier<DBInterface>
3132
) => Promise<boolean> | boolean
3233
// remove?: (userId: UserId | null, doc: DBInterface) => Promise<boolean> | boolean
3334
}
3435

36+
export const collectionsAllowDenyCache = new Map<string, CustomMongoAllowRules<any>>()
37+
3538
/**
3639
* Map of current collection objects.
3740
* Future: Could this weakly hold the collections?
@@ -55,11 +58,16 @@ export function getOrCreateMongoCollection(name: string): Mongo.Collection<any>
5558
*/
5659
export function createAsyncOnlyMongoCollection<DBInterface extends { _id: ProtectedString<any> }>(
5760
name: CollectionName,
58-
allowRules: MongoAllowRules<DBInterface> | false
61+
allowRules: CustomMongoAllowRules<DBInterface> | false
5962
): AsyncOnlyMongoCollection<DBInterface> {
6063
const collection = getOrCreateMongoCollection(name)
6164

62-
setupCollectionAllowRules(collection, allowRules)
65+
if (allowRules) {
66+
if (allowRules.requiredPermissions.length === 0)
67+
throw new Meteor.Error(403, `No permissions specified for collection "${name}"`)
68+
69+
collectionsAllowDenyCache.set(name, allowRules as CustomMongoAllowRules<any>)
70+
}
6371

6472
const wrappedCollection = wrapMeteorCollectionIntoAsyncCollection<DBInterface>(collection, name)
6573

@@ -83,8 +91,6 @@ export function createAsyncOnlyReadOnlyMongoCollection<DBInterface extends { _id
8391

8492
registerCollection(name, readonlyCollection)
8593

86-
setupCollectionAllowRules(collection, false)
87-
8894
return readonlyCollection
8995
}
9096

@@ -101,27 +107,6 @@ function wrapMeteorCollectionIntoAsyncCollection<DBInterface extends { _id: Prot
101107
}
102108
}
103109

104-
function setupCollectionAllowRules<DBInterface extends { _id: ProtectedString<any> }>(
105-
collection: Mongo.Collection<DBInterface>,
106-
args: MongoAllowRules<DBInterface> | false
107-
) {
108-
if (!args) {
109-
// Mutations are disabled by default
110-
return
111-
}
112-
113-
const { /* insert: origInsert,*/ update: origUpdate /*remove: origRemove*/ } = args
114-
115-
const options: Parameters<Mongo.Collection<DBInterface>['allow']>[0] = {
116-
update: origUpdate
117-
? async (userId: string | null, doc: DBInterface, fieldNames: string[], modifier: any) =>
118-
origUpdate(protectString(userId), doc, fieldNames as any, modifier)
119-
: () => false,
120-
}
121-
122-
collection.allow(options)
123-
}
124-
125110
/**
126111
* A minimal Async only wrapping around the base Mongo.Collection type
127112
*/

meteor/server/collections/index.ts

Lines changed: 20 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,15 @@ import { createAsyncOnlyMongoCollection, createAsyncOnlyReadOnlyMongoCollection
3232
import { ObserveChangesForHash } from './lib'
3333
import { logger } from '../logging'
3434
import { allowOnlyFields, rejectFields } from '../security/allowDeny'
35-
import { checkUserIdHasOneOfPermissions } from '../security/auth'
3635
import { DBNotificationObj } from '@sofie-automation/corelib/dist/dataModel/Notifications'
3736

3837
export * from './bucket'
3938
export * from './packages-media'
4039
export * from './rundown'
4140

4241
export const Blueprints = createAsyncOnlyMongoCollection<Blueprint>(CollectionName.Blueprints, {
43-
update(userId, doc, fields, _modifier) {
44-
if (!checkUserIdHasOneOfPermissions(userId, CollectionName.Blueprints, 'configure')) return false
45-
42+
requiredPermissions: ['configure'],
43+
update(_permissions, doc, fields, _modifier) {
4644
return allowOnlyFields(doc, fields, ['name', 'disableVersionChecks'])
4745
},
4846
})
@@ -51,9 +49,8 @@ registerIndex(Blueprints, {
5149
})
5250

5351
export const CoreSystem = createAsyncOnlyMongoCollection<ICoreSystem>(CollectionName.CoreSystem, {
54-
async update(userId, doc, fields, _modifier) {
55-
if (!checkUserIdHasOneOfPermissions(userId, CollectionName.CoreSystem, 'configure')) return false
56-
52+
requiredPermissions: ['configure'],
53+
async update(_permissions, doc, fields, _modifier) {
5754
return allowOnlyFields(doc, fields, [
5855
'systemInfo',
5956
'name',
@@ -110,9 +107,8 @@ registerIndex(Notifications, {
110107
})
111108

112109
export const Organizations = createAsyncOnlyMongoCollection<DBOrganization>(CollectionName.Organizations, {
113-
async update(userId, doc, fields, _modifier) {
114-
if (!checkUserIdHasOneOfPermissions(userId, CollectionName.Organizations, 'configure')) return false
115-
110+
requiredPermissions: ['configure'],
111+
async update(_permissions, doc, fields, _modifier) {
116112
return allowOnlyFields(doc, fields, ['userRoles'])
117113
},
118114
})
@@ -126,9 +122,8 @@ registerIndex(PeripheralDeviceCommands, {
126122
})
127123

128124
export const PeripheralDevices = createAsyncOnlyMongoCollection<PeripheralDevice>(CollectionName.PeripheralDevices, {
129-
update(userId, doc, fields, _modifier) {
130-
if (!checkUserIdHasOneOfPermissions(userId, CollectionName.PeripheralDevices, 'configure')) return false
131-
125+
requiredPermissions: ['configure'],
126+
update(_permissions, doc, fields, _modifier) {
132127
return allowOnlyFields(doc, fields, [
133128
'name',
134129
'deviceName',
@@ -151,9 +146,8 @@ registerIndex(PeripheralDevices, {
151146
})
152147

153148
export const RundownLayouts = createAsyncOnlyMongoCollection<RundownLayoutBase>(CollectionName.RundownLayouts, {
154-
async update(userId, doc, fields) {
155-
if (!checkUserIdHasOneOfPermissions(userId, CollectionName.RundownLayouts, 'configure')) return false
156-
149+
requiredPermissions: ['configure'],
150+
async update(_permissions, doc, fields) {
157151
return rejectFields(doc, fields, ['_id', 'showStyleBaseId'])
158152
},
159153
})
@@ -168,9 +162,8 @@ registerIndex(RundownLayouts, {
168162
})
169163

170164
export const ShowStyleBases = createAsyncOnlyMongoCollection<DBShowStyleBase>(CollectionName.ShowStyleBases, {
171-
async update(userId, doc, fields) {
172-
if (!checkUserIdHasOneOfPermissions(userId, CollectionName.ShowStyleBases, 'configure')) return false
173-
165+
requiredPermissions: ['configure'],
166+
async update(_permissions, doc, fields) {
174167
return rejectFields(doc, fields, ['_id'])
175168
},
176169
})
@@ -179,9 +172,8 @@ registerIndex(ShowStyleBases, {
179172
})
180173

181174
export const ShowStyleVariants = createAsyncOnlyMongoCollection<DBShowStyleVariant>(CollectionName.ShowStyleVariants, {
182-
async update(userId, doc, fields) {
183-
if (!checkUserIdHasOneOfPermissions(userId, CollectionName.ShowStyleVariants, 'configure')) return false
184-
175+
requiredPermissions: ['configure'],
176+
async update(_permissions, doc, fields) {
185177
return rejectFields(doc, fields, ['showStyleBaseId'])
186178
},
187179
})
@@ -191,9 +183,8 @@ registerIndex(ShowStyleVariants, {
191183
})
192184

193185
export const Snapshots = createAsyncOnlyMongoCollection<SnapshotItem>(CollectionName.Snapshots, {
194-
update(userId, doc, fields, _modifier) {
195-
if (!checkUserIdHasOneOfPermissions(userId, CollectionName.Snapshots, 'configure')) return false
196-
186+
requiredPermissions: ['configure'],
187+
update(_permissions, doc, fields, _modifier) {
197188
return allowOnlyFields(doc, fields, ['comment'])
198189
},
199190
})
@@ -205,9 +196,8 @@ registerIndex(Snapshots, {
205196
})
206197

207198
export const Studios = createAsyncOnlyMongoCollection<DBStudio>(CollectionName.Studios, {
208-
async update(userId, doc, fields, _modifier) {
209-
if (!checkUserIdHasOneOfPermissions(userId, CollectionName.Studios, 'configure')) return false
210-
199+
requiredPermissions: ['configure'],
200+
async update(_permissions, doc, fields, _modifier) {
211201
return rejectFields(doc, fields, ['_id'])
212202
},
213203
})
@@ -234,9 +224,8 @@ export const TranslationsBundles = createAsyncOnlyMongoCollection<TranslationsBu
234224
)
235225

236226
export const TriggeredActions = createAsyncOnlyMongoCollection<DBTriggeredActions>(CollectionName.TriggeredActions, {
237-
async update(userId, doc, fields) {
238-
if (!checkUserIdHasOneOfPermissions(userId, CollectionName.TriggeredActions, 'configure')) return false
239-
227+
requiredPermissions: ['configure'],
228+
async update(_permissions, doc, fields) {
240229
return rejectFields(doc, fields, ['_id'])
241230
},
242231
})

meteor/server/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import './api/ingest/debug'
1919
import './api/integration/expectedPackages'
2020
import './api/integration/media-scanner'
2121
import './api/integration/mediaWorkFlows'
22+
import './api/mongo'
2223
import './api/peripheralDevice'
2324
import './api/playout/api'
2425
import './api/rundown'

meteor/server/security/auth.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ import { Settings } from '../Settings'
77
import { Meteor } from 'meteor/meteor'
88
import Koa from 'koa'
99
import { triggerWriteAccess } from './securityVerify'
10-
import { UserId } from '@sofie-automation/corelib/dist/dataModel/Ids'
11-
import { unprotectString } from '../lib/tempLib'
1210
import { logger } from '../logging'
1311
import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections'
1412

@@ -62,8 +60,8 @@ export function assertConnectionHasOneOfPermissions(
6260
throw new Meteor.Error(403, 'Not authorized')
6361
}
6462

65-
export function checkUserIdHasOneOfPermissions(
66-
userId: UserId | null,
63+
export function checkHasOneOfPermissions(
64+
permissions: UserPermissions,
6765
collectionName: CollectionName,
6866
...allowedPermissions: Array<keyof UserPermissions>
6967
): boolean {
@@ -74,9 +72,8 @@ export function checkUserIdHasOneOfPermissions(
7472
// Skip if auth is disabled
7573
if (!Settings.enableHeaderAuth) return true
7674

77-
if (!userId) throw new Meteor.Error(403, 'UserId is null')
75+
if (!permissions) throw new Meteor.Error(403, 'Permissions is null')
7876

79-
const permissions: UserPermissions = JSON.parse(unprotectString(userId))
8077
for (const permission of allowedPermissions) {
8178
if (permissions[permission]) return true
8279
}

0 commit comments

Comments
 (0)