From b49722b6bf43939cc0bf6ea7546f82eca379eb64 Mon Sep 17 00:00:00 2001 From: Denis Bykhov Date: Thu, 18 Dec 2025 21:10:29 +0500 Subject: [PATCH] RBAC per class (#10314) Signed-off-by: Denis Bykhov --- .vscode/launch.json | 2 +- foundations/core/packages/core/src/classes.ts | 6 +- .../core/packages/core/src/component.ts | 16 ++-- .../middleware/src/spacePermissions.ts | 47 +++++----- models/card/src/permissions.ts | 5 +- models/core/src/index.ts | 30 ++++--- models/core/src/security.ts | 23 +++-- models/setting/src/index.ts | 4 + plugins/card-assets/assets/icons.svg | 3 + plugins/card-assets/src/index.ts | 3 +- .../components/navigator/CreateSpace.svelte | 27 +++++- .../src/components/settings/EditRole.svelte | 25 ++++-- .../settings/ProperitiesSection.svelte | 87 ++++++++++++++++++- plugins/card/src/index.ts | 3 +- .../src/components/ClassAttributes.svelte | 2 + .../AttributePermissionPresenter.svelte | 1 - .../ClassPermissionPresenter.svelte | 34 ++++++++ plugins/setting-resources/src/index.ts | 2 + plugins/setting/src/index.ts | 1 + plugins/view-resources/src/permissions.ts | 12 +++ 20 files changed, 268 insertions(+), 65 deletions(-) create mode 100644 plugins/setting-resources/src/components/presenters/ClassPermissionPresenter.svelte diff --git a/.vscode/launch.json b/.vscode/launch.json index ee2409e1d1..3d9de94495 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -97,7 +97,7 @@ "MODEL_JSON": "${workspaceRoot}/models/all/bundle/model.json", // "SERVER_PROVIDER":"uweb" "SERVER_PROVIDER": "ws", - "MODEL_VERSION": "0.7.312", + "MODEL_VERSION": "0.7.320", // "version": "0.7.0", "COMMUNICATION_API_ENABLED": "true", "ELASTIC_INDEX_NAME": "local_storage_index", diff --git a/foundations/core/packages/core/src/classes.ts b/foundations/core/packages/core/src/classes.ts index bfdd841d04..f00b9ba387 100644 --- a/foundations/core/packages/core/src/classes.ts +++ b/foundations/core/packages/core/src/classes.ts @@ -15,9 +15,9 @@ // import type { Asset, IntlString, Plugin } from '@hcengineering/platform' +import { Tx } from '.' import type { DocumentQuery } from './storage' import { type WorkspaceDataId, type WorkspaceUuid } from './utils' -import { Tx } from '.' /** * @public @@ -556,6 +556,10 @@ export interface AttributePermission extends Permission { attribute: Ref } +export interface ClassPermission extends Permission { + targetClass: Ref> +} + /** * @public */ diff --git a/foundations/core/packages/core/src/component.ts b/foundations/core/packages/core/src/component.ts index 1a2364c284..a870ff2db0 100644 --- a/foundations/core/packages/core/src/component.ts +++ b/foundations/core/packages/core/src/component.ts @@ -15,18 +15,23 @@ import type { Asset, IntlString, Metadata, Plugin, StatusCode } from '@hcengineering/platform' import { plugin } from '@hcengineering/platform' import type { BenchmarkDoc } from './benchmark' -import { AccountRole, TxAccessLevel } from './classes' import type { Account, + AccountUuid, AnyAttribute, ArrOf, Association, AttachedDoc, + AttributePermission, Blob, Class, + ClassCollaborators, + ClassPermission, + Collaborator, Collection, Configuration, ConfigurationElement, + CustomSequence, Doc, DomainIndexConfiguration, Enum, @@ -49,7 +54,6 @@ import type { Relation, Role, Sequence, - CustomSequence, Space, SpaceType, SpaceTypeDescriptor, @@ -60,12 +64,9 @@ import type { TypeAny, TypedSpace, UserStatus, - Version, - AccountUuid, - ClassCollaborators, - Collaborator, - AttributePermission + Version } from './classes' +import { AccountRole, TxAccessLevel } from './classes' import { type Status, type StatusCategory } from './status' import type { Tx, @@ -131,6 +132,7 @@ export default plugin(coreId, { Role: '' as Ref>, Permission: '' as Ref>, AttributePermission: '' as Ref>, + ClassPermission: '' as Ref>, Type: '' as Ref>>, TypeRelation: '' as Ref>>, TypeString: '' as Ref>>, diff --git a/foundations/server/packages/middleware/src/spacePermissions.ts b/foundations/server/packages/middleware/src/spacePermissions.ts index 50d7480b68..5ef5abf57d 100644 --- a/foundations/server/packages/middleware/src/spacePermissions.ts +++ b/foundations/server/packages/middleware/src/spacePermissions.ts @@ -206,22 +206,28 @@ export class SpacePermissionsMiddleware extends BaseMiddleware implements Middle const attachedDocAncestors = this.context.hierarchy.getAncestors(core.class.AttachedDoc) const ancestors = this.context.hierarchy.getAncestors(getTxObjectClass(tx)) const targetAncestors = ancestors.filter((a) => !attachedDocAncestors.includes(a)) - const txClass = getTxClass(tx) const permissions = this.context.modelDb.findAllSync(core.class.Permission, { - objectClass: { $in: targetAncestors }, - txClass + objectClass: { $in: targetAncestors } }) - const matched = permissions.filter((p) => { - if (p.txMatch === undefined) return false - const checkMatch = matchQuery([tx], p.txMatch, tx._class, this.context.hierarchy, true) - return p.forbid !== true && checkMatch.length > 0 - }) - - if (matched.length > 0) return false - - const withoutMatch = permissions.filter((p) => p.txMatch === undefined) - return withoutMatch.length === 0 + for (const permission of permissions) { + if (!isTxClassMatched(tx, permission)) continue + if ( + permission.objectClass !== undefined && + !this.context.hierarchy.isDerived(getTxObjectClass(tx), permission.objectClass) + ) { + continue + } + if (permission.txMatch === undefined) { + return false + } else { + const checkMatch = matchQuery([tx], permission.txMatch, tx._class, this.context.hierarchy, true) + if (checkMatch.length === 0) { + continue + } + } + return false + } } return false @@ -463,17 +469,16 @@ export class SpacePermissionsMiddleware extends BaseMiddleware implements Middle } } -function getTxClass (tx: Tx): Ref> { - let _class = tx._class - if (tx._class === core.class.TxMixin && Object.keys((tx as TxMixin).attributes).length > 0) { - _class = core.class.TxUpdateDoc - } - return _class +function isMixinUpdateTx (tx: Tx): boolean { + return tx._class === core.class.TxMixin && Object.keys((tx as TxMixin).attributes).length > 0 } function isTxClassMatched (tx: Tx, permission: Permission): boolean { - const txClass = getTxClass(tx) - return permission.txClass === txClass || permission.txClass === tx._class + if (permission.txClass === tx._class) return true + if (permission.txMatch === undefined && isMixinUpdateTx(tx)) { + return permission.txClass === core.class.TxUpdateDoc + } + return false } function getTxObjectClass (tx: TxCUD): Ref> { diff --git a/models/card/src/permissions.ts b/models/card/src/permissions.ts index bd50390bd4..4c59813696 100644 --- a/models/card/src/permissions.ts +++ b/models/card/src/permissions.ts @@ -23,7 +23,10 @@ export function definePermissions (builder: Builder): void { label: card.string.AddTagPermission, txClass: core.class.TxMixin, objectClass: card.class.Card, - scope: 'space' + scope: 'space', + txMatch: { + attributes: {} + } }, card.permission.AddTag ) diff --git a/models/core/src/index.ts b/models/core/src/index.ts index 314b3f6e99..57045c35db 100644 --- a/models/core/src/index.ts +++ b/models/core/src/index.ts @@ -33,9 +33,12 @@ import { TAttribute, TBlob, TClass, + TClassCollaborators, + TCollaborator, TCollection, TConfiguration, TConfigurationElement, + TCustomSequence, TDoc, TDomainIndexConfiguration, TEnum, @@ -49,8 +52,10 @@ import { TPluginConfiguration, TRefTo, TRelation, + TSequence, TTransientConfiguration, TType, + TTypeAccountUuid, TTypeAny, TTypeBlob, TTypeBoolean, @@ -58,26 +63,22 @@ import { TTypeDate, TTypeFileSize, TTypeHyperlink, + TTypeIdentifier, TTypeIntlString, TTypeMarkup, - TTypePersonId, - TTypeAccountUuid, TTypeNumber, - TTypeIdentifier, + TTypePersonId, TTypeRank, TTypeRecord, TTypeRelatedDocument, TTypeString, TTypeTimestamp, - TVersion, - TSequence, - TCustomSequence, - TClassCollaborators, - TCollaborator + TVersion } from './core' import { definePermissions } from './permissions' import { TAttributePermission, + TClassPermission, TPermission, TRole, TSpace, @@ -95,15 +96,15 @@ export { coreId, DOMAIN_SPACE } from '@hcengineering/core' export * from './core' export { coreOperation, - getSocialKeyByOldAccount, getAccountsFromTxes, - getSocialKeyByOldEmail, - getAccountUuidBySocialKey, - getUniqueAccounts, getAccountUuidByOldAccount, - getUniqueAccountsFromOldAccounts, + getAccountUuidBySocialKey, getSocialIdBySocialKey, - getSocialIdFromOldAccount + getSocialIdFromOldAccount, + getSocialKeyByOldAccount, + getSocialKeyByOldEmail, + getUniqueAccounts, + getUniqueAccountsFromOldAccounts } from './migration' export * from './security' export * from './status' @@ -134,6 +135,7 @@ export function createModel (builder: Builder): void { TRole, TPermission, TAttributePermission, + TClassPermission, TAttribute, TType, TEnumOf, diff --git a/models/core/src/security.ts b/models/core/src/security.ts index 4dcfc2107a..d6481d720c 100644 --- a/models/core/src/security.ts +++ b/models/core/src/security.ts @@ -18,8 +18,13 @@ import { DOMAIN_SPACE, IndexKind, type AccountRole, + type AccountUuid, + type AnyAttribute, + type AttributePermission, type Class, + type ClassPermission, type CollectionSize, + type Doc, type Permission, type Ref, type Role, @@ -27,13 +32,9 @@ import { type Space, type SpaceType, type SpaceTypeDescriptor, - type TypedSpace, - type AccountUuid, - type TxAccessLevel, type Tx, - type Doc, - type AnyAttribute, - type AttributePermission + type TxAccessLevel, + type TypedSpace } from '@hcengineering/core' import { ArrOf, @@ -43,15 +44,15 @@ import { Mixin, Model, Prop, - TypeBoolean, TypeAccountUuid, + TypeBoolean, TypeRef, TypeString, UX } from '@hcengineering/model' import { getEmbeddedLabel, type Asset, type IntlString } from '@hcengineering/platform' import core from './component' -import { TAttachedDoc, TDoc, TClass } from './core' +import { TAttachedDoc, TClass, TDoc } from './core' // S P A C E @Model(core.class.Space, core.class.Doc, DOMAIN_SPACE) @@ -173,6 +174,12 @@ export class TAttributePermission extends TPermission implements AttributePermis attribute!: Ref } +@Model(core.class.ClassPermission, core.class.Permission) +@UX(core.string.Permission) +export class TClassPermission extends TPermission implements ClassPermission { + targetClass!: Ref> +} + @Mixin(core.mixin.SpacesTypeData, core.class.Space) @UX(getEmbeddedLabel("All spaces' type")) // TODO: add icon? export class TSpacesTypeData extends TSpace implements RolesAssignment { diff --git a/models/setting/src/index.ts b/models/setting/src/index.ts index 85326a5112..025015bbc4 100644 --- a/models/setting/src/index.ts +++ b/models/setting/src/index.ts @@ -737,6 +737,10 @@ export function createModel (builder: Builder): void { presenter: setting.component.AttributePermissionPresenter }) + builder.mixin(core.class.ClassPermission, core.class.Class, view.mixin.ObjectPresenter, { + presenter: setting.component.ClassPermissionPresenter + }) + builder.createDoc(core.class.DomainIndexConfiguration, core.space.Model, { domain: DOMAIN_SETTING, disabled: [{ modifiedOn: 1 }, { modifiedBy: 1 }, { createdOn: 1 }, { space: 1 }] diff --git a/plugins/card-assets/assets/icons.svg b/plugins/card-assets/assets/icons.svg index 632d511d18..0a86454b9f 100644 --- a/plugins/card-assets/assets/icons.svg +++ b/plugins/card-assets/assets/icons.svg @@ -73,4 +73,7 @@ + + + diff --git a/plugins/card-assets/src/index.ts b/plugins/card-assets/src/index.ts index 45bad82d03..7441c7cab9 100644 --- a/plugins/card-assets/src/index.ts +++ b/plugins/card-assets/src/index.ts @@ -29,5 +29,6 @@ loadMetadata(card.icon, { Expand: `${icons}#expand`, Feed: `${icons}#feed`, All: `${icons}#all`, - Duplicate: `${icons}#duplicate` + Duplicate: `${icons}#duplicate`, + Lock: `${icons}#lock` }) diff --git a/plugins/card-resources/src/components/navigator/CreateSpace.svelte b/plugins/card-resources/src/components/navigator/CreateSpace.svelte index 7987c85673..3e1235e649 100644 --- a/plugins/card-resources/src/components/navigator/CreateSpace.svelte +++ b/plugins/card-resources/src/components/navigator/CreateSpace.svelte @@ -62,11 +62,32 @@ const asMixin = hierarchy.as(space, core.mixin.SpacesTypeData) - return roles.reduce((prev, { _id }) => { + const res = roles.reduce((prev, { _id }) => { prev[_id] = (asMixin as any)[_id] ?? [] return prev }, {}) + return res + } + + function getCurrentRolesAssignment (): RolesAssignment { + if (space === undefined) { + return {} + } + + const asMixin = hierarchy.as(space, core.mixin.SpacesTypeData) + const allRoles = client.getModel().findAllSync(card.class.Role, {}) + + const res: RolesAssignment = {} + + for (const role of allRoles) { + const curr = (asMixin as any)[role._id] + if (curr !== undefined) { + res[role._id] = curr + } + } + + return res } async function handleSave (): Promise { @@ -100,7 +121,7 @@ const data = getData() await client.diffUpdate(space, data) - if (rolesAssignment && !deepEqual(rolesAssignment, getRolesAssignment(roles))) { + if (rolesAssignment && !deepEqual(rolesAssignment, getCurrentRolesAssignment())) { await client.updateMixin(space._id, space._class, core.space.Space, core.mixin.SpacesTypeData, rolesAssignment) } @@ -112,7 +133,7 @@ const id = await client.createDoc(card.class.CardSpace, core.space.Space, data) - if (rolesAssignment && !deepEqual(rolesAssignment, getRolesAssignment(roles))) { + if (rolesAssignment && !deepEqual(rolesAssignment, getCurrentRolesAssignment())) { await client.updateMixin(id, card.class.CardSpace, core.space.Space, core.mixin.SpacesTypeData, rolesAssignment) } diff --git a/plugins/card-resources/src/components/settings/EditRole.svelte b/plugins/card-resources/src/components/settings/EditRole.svelte index 520feb6f3c..f90aea1c09 100644 --- a/plugins/card-resources/src/components/settings/EditRole.svelte +++ b/plugins/card-resources/src/components/settings/EditRole.svelte @@ -14,7 +14,15 @@ --> diff --git a/plugins/card-resources/src/components/settings/ProperitiesSection.svelte b/plugins/card-resources/src/components/settings/ProperitiesSection.svelte index 9ced3fc1ae..49db15d634 100644 --- a/plugins/card-resources/src/components/settings/ProperitiesSection.svelte +++ b/plugins/card-resources/src/components/settings/ProperitiesSection.svelte @@ -15,9 +15,81 @@ +> +
+ +
+
diff --git a/plugins/card/src/index.ts b/plugins/card/src/index.ts index bea2657e80..2e5674adff 100644 --- a/plugins/card/src/index.ts +++ b/plugins/card/src/index.ts @@ -170,7 +170,8 @@ const cardPlugin = plugin(cardId, { Expand: '' as Asset, Feed: '' as Asset, All: '' as Asset, - Duplicate: '' as Asset + Duplicate: '' as Asset, + Lock: '' as Asset }, extensions: { EditCardExtension: '' as ComponentExtensionId, diff --git a/plugins/setting-resources/src/components/ClassAttributes.svelte b/plugins/setting-resources/src/components/ClassAttributes.svelte index bb38e6f07d..f3cf1d6bef 100644 --- a/plugins/setting-resources/src/components/ClassAttributes.svelte +++ b/plugins/setting-resources/src/components/ClassAttributes.svelte @@ -187,6 +187,8 @@ {/if} + {:else if $$slots.header} + {:else}
{/if} diff --git a/plugins/setting-resources/src/components/presenters/AttributePermissionPresenter.svelte b/plugins/setting-resources/src/components/presenters/AttributePermissionPresenter.svelte index 0103fee787..12a65c021b 100644 --- a/plugins/setting-resources/src/components/presenters/AttributePermissionPresenter.svelte +++ b/plugins/setting-resources/src/components/presenters/AttributePermissionPresenter.svelte @@ -1,5 +1,4 @@ + + +{#if value} +
+
+{/if} diff --git a/plugins/setting-resources/src/index.ts b/plugins/setting-resources/src/index.ts index 755b6a2df1..e198b4ec10 100644 --- a/plugins/setting-resources/src/index.ts +++ b/plugins/setting-resources/src/index.ts @@ -38,6 +38,7 @@ import Configure from './components/Configure.svelte' import InviteSetting from './components/InviteSetting.svelte' import PermissionPresenter from './components/presenters/PermissionPresenter.svelte' import AttributePermissionPresenter from './components/presenters/AttributePermissionPresenter.svelte' +import ClassPermissionPresenter from './components/presenters/ClassPermissionPresenter.svelte' import SpaceTypeDescriptorPresenter from './components/presenters/SpaceTypeDescriptorPresenter.svelte' import Spaces from './components/Spaces.svelte' import SpaceTypeGeneralSectionEditor from './components/spaceTypes/editor/SpaceTypeGeneralSectionEditor.svelte' @@ -144,6 +145,7 @@ export default async (): Promise => ({ ManageSpaceTypeContent, PermissionPresenter, AttributePermissionPresenter, + ClassPermissionPresenter, SpaceTypeDescriptorPresenter, SpaceTypeGeneralSectionEditor, SpaceTypePropertiesSectionEditor, diff --git a/plugins/setting/src/index.ts b/plugins/setting/src/index.ts index 134fe37b1d..77435db6b1 100644 --- a/plugins/setting/src/index.ts +++ b/plugins/setting/src/index.ts @@ -200,6 +200,7 @@ export default plugin(settingId, { ClassSetting: '' as AnyComponent, PermissionPresenter: '' as AnyComponent, AttributePermissionPresenter: '' as AnyComponent, + ClassPermissionPresenter: '' as AnyComponent, SpaceTypeDescriptorPresenter: '' as AnyComponent, SpaceTypeGeneralSectionEditor: '' as AnyComponent, SpaceTypePropertiesSectionEditor: '' as AnyComponent, diff --git a/plugins/view-resources/src/permissions.ts b/plugins/view-resources/src/permissions.ts index 0679e47957..83f563e64a 100644 --- a/plugins/view-resources/src/permissions.ts +++ b/plugins/view-resources/src/permissions.ts @@ -31,6 +31,18 @@ export function canChangeAttribute ( return true } + const target = attr.attributeOf + const forbiddenClId = `${target}_forbidden` as Ref + const forbiddenCl = store.ps[space]?.has(forbiddenClId) + if (forbiddenCl) { + return false + } + const allowedClId = `${target}_allowed` as Ref + const allowedCl = store.ps[space]?.has(allowedClId) + if (allowedCl) { + return true + } + return canChangeDoc(_class ?? attr.attributeOf, space, store) }