// // Copyright © 2023 Hardcore Engineering Inc. // // Licensed under the Eclipse Public License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. You may // obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // // See the License for the specific language governing permissions and // limitations under the License. // import core, { Account, AccountRole, AttachedDoc, Class, DOMAIN_MODEL, Doc, DocumentQuery, Domain, FindOptions, FindResult, LookupData, MeasureContext, ObjQueryType, Position, PullArray, Ref, SearchOptions, SearchQuery, SearchResult, Space, Tx, TxCUD, TxCreateDoc, TxProcessor, TxRemoveDoc, TxUpdateDoc, TxWorkspaceEvent, WorkspaceEvent, generateId, systemAccountEmail, toFindResult } from '@hcengineering/core' import platform, { PlatformError, Severity, Status } from '@hcengineering/platform' import { Middleware, SessionContext, TxMiddlewareResult, type ServerStorage } from '@hcengineering/server-core' import { BaseMiddleware } from './base' import { getUser, isOwner, isSystem } from './utils' type SpaceWithMembers = Pick /** * @public */ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middleware { private allowedSpaces: Record, Ref[]> = {} private readonly spacesMap = new Map, SpaceWithMembers>() private readonly privateSpaces = new Set>() private readonly _domainSpaces = new Map> | Promise>>>() private readonly publicSpaces = new Set>() private readonly systemSpaces = new Set>() private spaceMeasureCtx!: MeasureContext private spaceSecurityInit: Promise | undefined private readonly mainSpaces = [ core.space.Configuration, core.space.DerivedTx, core.space.Model, core.space.Space, core.space.Workspace, core.space.Tx ] private constructor (storage: ServerStorage, next?: Middleware) { super(storage, next) } static async create ( ctx: MeasureContext, storage: ServerStorage, next?: Middleware ): Promise { const res = new SpaceSecurityMiddleware(storage, next) res.spaceMeasureCtx = ctx.newChild('space chain', {}) res.spaceSecurityInit = res.init(res.spaceMeasureCtx) return res } private resyncDomains (): void { this.spaceSecurityInit = this.init(this.spaceMeasureCtx) } private addMemberSpace (member: Ref, space: Ref): void { const arr = this.allowedSpaces[member] ?? [] arr.push(space) this.allowedSpaces[member] = arr } private addSpace (space: SpaceWithMembers): void { this.spacesMap.set(space._id, space) if (space.private) { this.privateSpaces.add(space._id) } else { this.publicSpaces.add(space._id) } for (const member of space.members) { this.addMemberSpace(member, space._id) } } private async init (ctx: MeasureContext): Promise { const spaces: SpaceWithMembers[] = await this.storage.findAll( ctx, core.class.Space, {}, { projection: { private: 1, _class: 1, _id: 1, members: 1 } } ) this.spacesMap.clear() this.publicSpaces.clear() this.systemSpaces.clear() for (const space of spaces) { if (space._class === core.class.SystemSpace) { this.systemSpaces.add(space._id) } else { this.addSpace(space) } } } async waitInit (): Promise { if (this.spaceSecurityInit !== undefined) { await this.spaceSecurityInit this.spaceSecurityInit = undefined } } private removeMemberSpace (member: Ref, space: Ref): void { const arr = this.allowedSpaces[member] if (arr !== undefined) { const index = arr.findIndex((p) => p === space) if (index !== -1) { arr.splice(index, 1) this.allowedSpaces[member] = arr } } } private removeSpace (_id: Ref): void { const space = this.spacesMap.get(_id) if (space !== undefined) { for (const member of space.members) { this.removeMemberSpace(member, space._id) } } this.spacesMap.delete(_id) this.privateSpaces.delete(_id) this.publicSpaces.delete(_id) } private handleCreate (tx: TxCUD): void { const createTx = tx as TxCreateDoc if (!this.storage.hierarchy.isDerived(createTx.objectClass, core.class.Space)) return if (createTx.objectClass === core.class.SystemSpace) { this.systemSpaces.add(createTx.objectId) } else { const res = TxProcessor.createDoc2Doc(createTx) this.addSpace(res) } } private async pushMembersHandle ( ctx: SessionContext, addedMembers: Ref | Position>, space: Ref ): Promise { if (typeof addedMembers === 'object') { for (const member of addedMembers.$each) { this.addMemberSpace(member, space) } await this.brodcastEvent(ctx, addedMembers.$each, space) } else { this.addMemberSpace(addedMembers, space) await this.brodcastEvent(ctx, [addedMembers], space) } } private async pullMembersHandle ( ctx: SessionContext, removedMembers: Partial> | PullArray>, space: Ref ): Promise { if (typeof removedMembers === 'object') { const { $in } = removedMembers as PullArray> if ($in !== undefined) { for (const member of $in) { this.removeMemberSpace(member, space) } await this.brodcastEvent(ctx, $in, space) } } else { this.removeMemberSpace(removedMembers, space) await this.brodcastEvent(ctx, [removedMembers], space) } } private async syncMembers (ctx: SessionContext, members: Ref[], space: SpaceWithMembers): Promise { const oldMembers = new Set(space.members) const newMembers = new Set(members) const changed: Ref[] = [] for (const old of oldMembers) { if (!newMembers.has(old)) { this.removeMemberSpace(old, space._id) changed.push(old) } } for (const newMem of newMembers) { if (!oldMembers.has(newMem)) { this.addMemberSpace(newMem, space._id) changed.push(newMem) } } if (changed.length > 0) { await this.brodcastEvent(ctx, changed, space._id) } } private async brodcastEvent (ctx: SessionContext, users: Ref[], space?: Ref): Promise { const targets = await this.getTargets(users) const tx: TxWorkspaceEvent = { _class: core.class.TxWorkspaceEvent, _id: generateId(), event: WorkspaceEvent.SecurityChange, modifiedBy: core.account.System, modifiedOn: Date.now(), objectSpace: space ?? core.space.DerivedTx, space: core.space.DerivedTx, params: null } ctx.derived.txes.push(tx) ctx.derived.targets.security = (it) => { // TODO: I'm not sure it is called if (it._id === tx._id) { return targets } } } private async broadcastNonMembers (ctx: SessionContext, space: SpaceWithMembers): Promise { const users = await this.storage.modelDb.findAll(core.class.Account, { _id: { $nin: space?.members } }) await this.brodcastEvent( ctx, users.map((p) => p._id), space._id ) } private async handleUpdate (ctx: SessionContext, tx: TxCUD): Promise { await this.waitInit() const updateDoc = tx as TxUpdateDoc if (!this.storage.hierarchy.isDerived(updateDoc.objectClass, core.class.Space)) return const space = this.spacesMap.get(updateDoc.objectId) if (space !== undefined) { if (updateDoc.operations.private !== undefined) { if (updateDoc.operations.private) { this.privateSpaces.add(updateDoc.objectId) this.publicSpaces.delete(updateDoc.objectId) await this.broadcastNonMembers(ctx, space) } else if (!updateDoc.operations.private) { this.privateSpaces.delete(updateDoc.objectId) this.publicSpaces.add(updateDoc.objectId) await this.broadcastNonMembers(ctx, space) } } if (updateDoc.operations.members !== undefined) { await this.syncMembers(ctx, updateDoc.operations.members, space) } if (updateDoc.operations.$push?.members !== undefined) { await this.pushMembersHandle(ctx, updateDoc.operations.$push.members, space._id) } if (updateDoc.operations.$pull?.members !== undefined) { await this.pullMembersHandle(ctx, updateDoc.operations.$pull.members, space._id) } const updatedSpace = TxProcessor.updateDoc2Doc(space as any, updateDoc) this.spacesMap.set(updateDoc.objectId, updatedSpace) } } private handleRemove (tx: TxCUD): void { const removeTx = tx as TxRemoveDoc if (!this.storage.hierarchy.isDerived(removeTx.objectClass, core.class.Space)) return if (removeTx._class !== core.class.TxCreateDoc) return this.removeSpace(tx.objectId) } private async handleTx (ctx: SessionContext, tx: TxCUD): Promise { await this.waitInit() if (tx._class === core.class.TxCreateDoc) { this.handleCreate(tx) } else if (tx._class === core.class.TxUpdateDoc) { await this.handleUpdate(ctx, tx) } else if (tx._class === core.class.TxRemoveDoc) { this.handleRemove(tx) } } async getTargets (accounts: Ref[]): Promise { const users = await this.storage.modelDb.findAll(core.class.Account, { _id: { $in: accounts } }) const res = users.map((p) => p.email) res.push(systemAccountEmail) return res } private async processTxSpaceDomain (tx: TxCUD): Promise { const actualTx = TxProcessor.extractTx(tx) if (actualTx._class === core.class.TxCreateDoc) { const ctx = actualTx as TxCreateDoc const doc = TxProcessor.createDoc2Doc(ctx) const domain = this.storage.hierarchy.getDomain(ctx.objectClass) const key = this.getKey(domain) const space = (doc as any)[key] if (space === undefined) return ;(await this.getDomainSpaces(domain)).add(space) } else if (actualTx._class === core.class.TxUpdateDoc) { const updTx = actualTx as TxUpdateDoc const domain = this.storage.hierarchy.getDomain(updTx.objectClass) const key = this.getKey(domain) const space = (updTx.operations as any)[key] if (space !== undefined) { ;(await this.getDomainSpaces(domain)).add(space) } } } private async processTx (ctx: SessionContext, tx: Tx): Promise { const h = this.storage.hierarchy if (TxProcessor.isExtendsCUD(tx._class)) { const cudTx = tx as TxCUD const isSpace = h.isDerived(cudTx.objectClass, core.class.Space) if (isSpace) { const account = await getUser(this.storage, ctx) if (account.role === AccountRole.Guest) { throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) } await this.handleTx(ctx, cudTx as TxCUD) } await this.processTxSpaceDomain(tx as TxCUD) if (h.isDerived(cudTx.objectClass, core.class.Account) && cudTx._class === core.class.TxUpdateDoc) { const cud = cudTx as TxUpdateDoc if (cud.operations.role !== undefined) { await this.brodcastEvent(ctx, [cud.objectId]) } } } else if (tx._class === core.class.TxWorkspaceEvent) { const event = tx as TxWorkspaceEvent if (event.event === WorkspaceEvent.BulkUpdate) { this.resyncDomains() } } } async tx (ctx: SessionContext, tx: Tx): Promise { await this.waitInit() const account = await getUser(this.storage, ctx) if (account.role === AccountRole.DocGuest) { throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) } await this.processTx(ctx, tx) const res = await this.provideTx(ctx, tx) for (const txd of ctx.derived.txes) { await this.processTx(ctx, txd) } return res } override async handleBroadcast ( txes: Tx[], targets?: string | string[] | undefined, exclude?: string[] | undefined ): Promise { for (const tx of txes) { if (TxProcessor.isExtendsCUD(tx._class)) { // TODO: Do we need security check here? const cudTx = tx as TxCUD await this.processTxSpaceDomain(cudTx) } else if (tx._class === core.class.TxWorkspaceEvent) { const event = tx as TxWorkspaceEvent if (event.event === WorkspaceEvent.BulkUpdate) { this.resyncDomains() } } } await this.next?.handleBroadcast(txes, targets, exclude) } private getAllAllowedSpaces (account: Account, isData: boolean): Ref[] { const userSpaces = this.allowedSpaces[account._id] ?? [] const res = [...userSpaces, account._id as string as Ref, ...this.systemSpaces, ...this.mainSpaces] return isData ? res : [...res, ...this.publicSpaces] } async getDomainSpaces (domain: Domain): Promise>> { let domainSpaces = this._domainSpaces.get(domain) if (domainSpaces === undefined) { const p = this.storage.groupBy>(this.spaceMeasureCtx, domain, this.getKey(domain)) this._domainSpaces.set(domain, p) domainSpaces = await p this._domainSpaces.set(domain, domainSpaces) } return domainSpaces instanceof Promise ? await domainSpaces : domainSpaces } private async filterByDomain ( domain: Domain, spaces: Ref[] ): Promise<{ result: Ref[], allDomainSpaces: boolean, domainSpaces: Set> }> { const domainSpaces = await this.getDomainSpaces(domain) const result = spaces.filter((p) => domainSpaces.has(p)) return { result: spaces.filter((p) => domainSpaces.has(p)), allDomainSpaces: result.length === domainSpaces.size, domainSpaces } } private async mergeQuery( account: Account, query: ObjQueryType, domain: Domain, isSpace: boolean ): Promise | undefined> { const spaces = await this.filterByDomain(domain, this.getAllAllowedSpaces(account, !isSpace)) if (query == null) { if (spaces.allDomainSpaces) { return undefined } return { $in: spaces.result } } if (typeof query === 'string') { if (!spaces.result.includes(query)) { return { $in: [] } } } else if (query.$in != null) { query.$in = query.$in.filter((p) => spaces.result.includes(p)) if (query.$in.length === spaces.domainSpaces.size) { // all domain spaces delete query.$in } } else { if (spaces.allDomainSpaces) { delete query.$in } else { query.$in = spaces.result } } if (Object.keys(query).length === 0) { return undefined } return query } private getKey (domain: string): string { return domain === 'tx' ? 'objectSpace' : domain === 'space' ? '_id' : 'space' } override async findAll( ctx: SessionContext, _class: Ref>, query: DocumentQuery, options?: FindOptions ): Promise> { await this.waitInit() const domain = this.storage.hierarchy.getDomain(_class) const newQuery = { ...query } const account = await getUser(this.storage, ctx) const isSpace = this.storage.hierarchy.isDerived(_class, core.class.Space) const field = this.getKey(domain) let clientFilterSpaces: Set> | undefined if (!isSystem(account) && account.role !== AccountRole.DocGuest && domain !== DOMAIN_MODEL) { if (!isOwner(account, ctx) || !isSpace) { if (query[field] !== undefined) { const res = await this.mergeQuery(account, query[field], domain, isSpace) if (res === undefined) { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete (newQuery as any)[field] } else { ;(newQuery as any)[field] = res if (typeof res === 'object') { if (Array.isArray(res.$in) && res.$in.length === 1 && Object.keys(res).length === 1) { ;(newQuery as any)[field] = res.$in[0] } } } } else { const spaces = await this.filterByDomain(domain, this.getAllAllowedSpaces(account, !isSpace)) if (spaces.allDomainSpaces) { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete (newQuery as any)[field] } else if (spaces.result.length === 1) { ;(newQuery as any)[field] = spaces.result[0] } else { // Check if spaces > 85% of all domain spaces, in this case return all and filter on client. if (spaces.result.length / spaces.domainSpaces.size > 0.85 && options?.limit === undefined) { clientFilterSpaces = new Set(spaces.result) delete newQuery.space } else { ;(newQuery as any)[field] = { $in: spaces.result } } } } } } let findResult = await this.provideFindAll(ctx, _class, newQuery, options) if (clientFilterSpaces !== undefined) { findResult = toFindResult( findResult.filter((it) => (clientFilterSpaces as Set>).has(it.space)), findResult.total, findResult.lookupMap ) } if (!isOwner(account, ctx) && account.role !== AccountRole.DocGuest) { if (options?.lookup !== undefined) { for (const object of findResult) { if (object.$lookup !== undefined) { await this.filterLookup(ctx, object.$lookup) } } } } return findResult } override async searchFulltext ( ctx: SessionContext, query: SearchQuery, options: SearchOptions ): Promise { await this.waitInit() const newQuery = { ...query } const account = await getUser(this.storage, ctx) if (!isSystem(account)) { const allSpaces = this.getAllAllowedSpaces(account, true) if (query.classes !== undefined) { const res = new Set>() const passedDomains = new Set() for (const _class of query.classes) { const domain = this.storage.hierarchy.getDomain(_class) if (passedDomains.has(domain)) { continue } passedDomains.add(domain) const spaces = await this.filterByDomain(domain, allSpaces) for (const space of spaces.result) { res.add(space) } } newQuery.spaces = [...res] } else { newQuery.spaces = allSpaces } } const result = await this.provideSearchFulltext(ctx, newQuery, options) return result } async isUnavailable (ctx: SessionContext, space: Ref): Promise { const account = await getUser(this.storage, ctx) if (isSystem(account)) return false return !this.getAllAllowedSpaces(account, true).includes(space) } async filterLookup(ctx: SessionContext, lookup: LookupData): Promise { for (const key in lookup) { const val = lookup[key] if (Array.isArray(val)) { const arr: AttachedDoc[] = [] for (const value of val) { if (!(await this.isUnavailable(ctx, value.space))) { arr.push(value) } } lookup[key] = arr as any } else if (val !== undefined) { if (await this.isUnavailable(ctx, val.space)) { lookup[key] = undefined } } } } }