// // Copyright © 2024 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, AttachedDoc, Class, Doc, MeasureContext, Permission, Ref, Role, RolesAssignment, ServerStorage, Space, SpaceType, Tx, TxApplyIf, TxCUD, TxCollectionCUD, TxCreateDoc, TxMixin, TxProcessor, TxRemoveDoc, TxUpdateDoc, TypedSpace } from '@hcengineering/core' import platform, { PlatformError, Severity, Status } from '@hcengineering/platform' import { BroadcastFunc, Middleware, SessionContext, TxMiddlewareResult } from '@hcengineering/server-core' import { BaseMiddleware } from './base' import { getUser } from './utils' /** * @public */ export class SpacePermissionsMiddleware extends BaseMiddleware implements Middleware { private spaceMeasureCtx!: MeasureContext private whitelistSpaces = new Set>() private assignmentBySpace: Record, RolesAssignment> = {} private permissionsBySpace: Record, Record, Set>>> = {} private typeBySpace: Record, Ref> = {} static async create ( ctx: MeasureContext, broadcast: BroadcastFunc, storage: ServerStorage, next?: Middleware ): Promise { const res = new SpacePermissionsMiddleware(storage, next) res.spaceMeasureCtx = ctx.newChild('space permisisons', {}) await res.init(res.spaceMeasureCtx) return res } private async init (ctx: MeasureContext): Promise { const spaces: Space[] = await this.storage.findAll(ctx, core.class.Space, {}) for (const space of spaces) { if (this.isTypedSpace(space)) { await this.addRestrictedSpace(space) } } this.whitelistSpaces = new Set(spaces.filter((s) => !this.isTypedSpaceClass(s._class)).map((p) => p._id)) } private async getRoles (spaceTypeId: Ref): Promise { return await this.storage.modelDb.findAll(core.class.Role, { attachedTo: spaceTypeId }) } private async setPermissions (spaceId: Ref, roles: Role[], assignment: RolesAssignment): Promise { for (const role of roles) { const roleMembers: Ref[] = assignment[role._id] ?? [] for (const member of roleMembers) { if (this.permissionsBySpace[spaceId][member] === undefined) { this.permissionsBySpace[spaceId][member] = new Set() } for (const permission of role.permissions) { this.permissionsBySpace[spaceId][member].add(permission) } } } } private async addRestrictedSpace (space: TypedSpace): Promise { this.permissionsBySpace[space._id] = {} const spaceType = await this.storage.modelDb.findOne(core.class.SpaceType, { _id: space.type }) if (spaceType === undefined) { return } this.typeBySpace[space._id] = space.type const asMixin: RolesAssignment = this.storage.hierarchy.as( space, spaceType.targetClass ) as unknown as RolesAssignment this.assignmentBySpace[space._id] = asMixin await this.setPermissions(space._id, await this.getRoles(spaceType._id), asMixin) } private isTypedSpaceClass (_class: Ref>): boolean { const h = this.storage.hierarchy return h.isDerived(_class, core.class.TypedSpace) } private isTypedSpace (space: Space): space is TypedSpace { return this.isTypedSpaceClass(space._class) } /** * @private * * Throws if the required permission is missing in the space for the given context */ private async needPermission (ctx: SessionContext, space: Ref, id: Ref): Promise { const account = await getUser(this.storage, ctx) const permissions = this.permissionsBySpace[space]?.[account._id] ?? new Set() if (!permissions.has(id)) { throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) } } private async handleCreate (tx: TxCUD): Promise { const createTx = tx as TxCreateDoc if (!this.storage.hierarchy.isDerived(createTx.objectClass, core.class.Space)) return if (this.isTypedSpaceClass(createTx.objectClass)) { const res = TxProcessor.buildDoc2Doc([createTx]) if (res !== undefined) { await this.addRestrictedSpace(res) } } else { this.whitelistSpaces.add(createTx.objectId) } } private async handleMixin (tx: TxCUD): Promise { if (!this.isTypedSpaceClass(tx.objectClass)) { return } const spaceId = tx.objectId const spaceTypeId = this.typeBySpace[spaceId] if (spaceTypeId === undefined) { return } const spaceType = await this.storage.modelDb.findOne(core.class.SpaceType, { _id: spaceTypeId }) if (spaceType === undefined) { return } const mixinDoc = tx as TxMixin if (mixinDoc.mixin !== spaceType.targetClass) { return } // Note: currently the whole assignment is always included into the mixin update // so we can just rebuild the permissions const assignment: RolesAssignment = mixinDoc.attributes as RolesAssignment this.assignmentBySpace[spaceId] = assignment this.permissionsBySpace[tx.objectId] = {} await this.setPermissions(spaceId, await this.getRoles(spaceType._id), assignment) } 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 // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete this.permissionsBySpace[tx.objectId] // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete this.assignmentBySpace[tx.objectId] // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete this.typeBySpace[tx.objectId] this.whitelistSpaces.delete(tx.objectId) } private isSpaceTxCUD (tx: TxCUD): tx is TxCUD { return this.storage.hierarchy.isDerived(tx.objectClass, core.class.Space) } private isTxCollectionCUD (tx: TxCUD): tx is TxCollectionCUD { return this.storage.hierarchy.isDerived(tx._class, core.class.TxCollectionCUD) } private isRoleTxCUD (tx: TxCUD): tx is TxCUD { return this.storage.hierarchy.isDerived(tx.objectClass, core.class.Role) } private async handlePermissionsUpdatesFromRoleTx (ctx: SessionContext, tx: TxCUD): Promise { if (!this.isTxCollectionCUD(tx)) { return } const h = this.storage.hierarchy const actualTx = TxProcessor.extractTx(tx) if (!h.isDerived(actualTx._class, core.class.TxCUD)) { return } const actualCudTx = actualTx as TxCUD if (!this.isRoleTxCUD(actualCudTx)) { return } // We are only interested in updates of the existing roles because: // When role is created it always has empty set of permissions // And it's not currently possible to delete a role if (actualCudTx._class !== core.class.TxUpdateDoc) { return } const updateTx = actualCudTx as TxUpdateDoc if (updateTx.operations.permissions === undefined) { return } // Find affected spaces const targetSpaceTypeId = tx.objectId const affectedSpacesIds = Object.entries(this.typeBySpace) .filter(([, typeId]) => typeId === targetSpaceTypeId) .map(([spaceId]) => spaceId) as Ref[] for (const spaceId of affectedSpacesIds) { const spaceTypeId = this.typeBySpace[spaceId] if (spaceTypeId === undefined) { return } const assignment: RolesAssignment = this.assignmentBySpace[spaceId] const roles = await this.getRoles(spaceTypeId) const targetRole = roles.find((r) => r._id === updateTx.objectId) if (targetRole === undefined) { continue } targetRole.permissions = updateTx.operations.permissions this.permissionsBySpace[spaceId] = {} await this.setPermissions(spaceId, roles, assignment) } } private async handlePermissionsUpdatesFromTx (ctx: SessionContext, tx: TxCUD): Promise { if (this.isSpaceTxCUD(tx)) { if (tx._class === core.class.TxCreateDoc) { await this.handleCreate(tx) // } else if (tx._class === core.class.TxUpdateDoc) { // Roles assignment in spaces are managed through the space type mixin // so nothing to handle here } else if (tx._class === core.class.TxMixin) { await this.handleMixin(tx) } else if (tx._class === core.class.TxRemoveDoc) { this.handleRemove(tx) } } await this.handlePermissionsUpdatesFromRoleTx(ctx, tx) } private async processPermissionsUpdatesFromTx (ctx: SessionContext, tx: Tx): Promise { const h = this.storage.hierarchy if (!h.isDerived(tx._class, core.class.TxCUD)) { return } const cudTx = tx as TxCUD await this.handlePermissionsUpdatesFromTx(ctx, cudTx) } async tx (ctx: SessionContext, tx: Tx): Promise { await this.processPermissionsUpdatesFromTx(ctx, tx) await this.checkPermissions(ctx, tx) const res = await this.provideTx(ctx, tx) for (const tx of res[1]) { await this.processPermissionsUpdatesFromTx(ctx, tx) } return res } handleBroadcast (tx: Tx[], targets?: string[]): Tx[] { return this.provideHandleBroadcast(tx, targets) } protected async checkPermissions (ctx: SessionContext, tx: Tx): Promise { if (tx._class === core.class.TxApplyIf) { const applyTx = tx as TxApplyIf await Promise.all(applyTx.txes.map((t) => this.checkPermissions(ctx, t))) return } if (tx._class === core.class.TxCollectionCUD) { const actualTx = TxProcessor.extractTx(tx) await this.checkPermissions(ctx, actualTx) } const cudTx = tx as TxCUD const h = this.storage.hierarchy const isSpace = h.isDerived(cudTx.objectClass, core.class.Space) // NOTE: in assumption that we want to control permissions for space itself on that space level // and not on the system's spaces space level for now const targetSpaceId = (isSpace ? cudTx.objectId : cudTx.objectSpace) as Ref if (this.whitelistSpaces.has(targetSpaceId)) { return } // NOTE: move this checking logic later to be defined in some server plugins? // so they can contribute checks into the middleware for their custom permissions? if (tx._class === core.class.TxRemoveDoc) { await this.needPermission(ctx, targetSpaceId as Ref, core.permission.DeleteObject) } } }