RBAC per class (#10314)

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov
2025-12-18 21:10:29 +05:00
committed by GitHub
parent 67a3fadbac
commit b49722b6bf
20 changed files with 268 additions and 65 deletions
+1 -1
View File
@@ -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",
@@ -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<AnyAttribute>
}
export interface ClassPermission extends Permission {
targetClass: Ref<Class<Doc>>
}
/**
* @public
*/
@@ -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<Class<Role>>,
Permission: '' as Ref<Class<Permission>>,
AttributePermission: '' as Ref<Class<AttributePermission>>,
ClassPermission: '' as Ref<Class<ClassPermission>>,
Type: '' as Ref<Class<Type<any>>>,
TypeRelation: '' as Ref<Class<Type<string>>>,
TypeString: '' as Ref<Class<Type<string>>>,
@@ -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<Class<Tx>> {
let _class = tx._class
if (tx._class === core.class.TxMixin && Object.keys((tx as TxMixin<Doc, Doc>).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<Doc, Doc>).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<Doc>): Ref<Class<Doc>> {
+4 -1
View File
@@ -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
)
+16 -14
View File
@@ -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,
+15 -8
View File
@@ -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<AnyAttribute>
}
@Model(core.class.ClassPermission, core.class.Permission)
@UX(core.string.Permission)
export class TClassPermission extends TPermission implements ClassPermission {
targetClass!: Ref<Class<Doc>>
}
@Mixin(core.mixin.SpacesTypeData, core.class.Space)
@UX(getEmbeddedLabel("All spaces' type")) // TODO: add icon?
export class TSpacesTypeData extends TSpace implements RolesAssignment {
+4
View File
@@ -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 }]
+3
View File
@@ -73,4 +73,7 @@
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 3C5 1.89543 5.89543 1 7 1H12C13.1046 1 14 1.89543 14 3V10C14 11.1046 13.1046 12 12 12H7C5.89543 12 5 11.1046 5 10V3ZM7 2C6.44772 2 6 2.44772 6 3V10C6 10.5523 6.44772 11 7 11H12C12.5523 11 13 10.5523 13 10V3C13 2.44772 12.5523 2 12 2H7Z" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 5C3.44772 5 3 5.44772 3 6V13C3 13.5523 3.44772 14 4 14H9C9.55228 14 10 13.5523 10 13V12H11V13C11 14.1046 10.1046 15 9 15H4C2.89543 15 2 14.1046 2 13V6C2 4.89543 2.89543 4 4 4H5V5H4Z" />
</symbol>
<symbol id="lock" viewBox="0 0 16 16">
<path d="M4.69141 14.6338H11.3018C12.2178 14.6338 12.6689 14.1826 12.6689 13.1914V8.08496C12.6689 7.18945 12.293 6.72461 11.541 6.64941V4.96094C11.541 2.36328 9.81152 1.1123 7.99316 1.1123C6.18164 1.1123 4.45215 2.36328 4.45215 4.96094V6.67676C3.74805 6.78613 3.32422 7.2373 3.32422 8.08496V13.1914C3.32422 14.1826 3.77539 14.6338 4.69141 14.6338ZM5.75098 4.83105C5.75098 3.22461 6.76953 2.35645 7.99316 2.35645C9.2168 2.35645 10.2422 3.22461 10.2422 4.83105V6.64258L5.75098 6.64941V4.83105Z" />
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+2 -1
View File
@@ -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`
})
@@ -62,11 +62,32 @@
const asMixin = hierarchy.as(space, core.mixin.SpacesTypeData)
return roles.reduce<RolesAssignment>((prev, { _id }) => {
const res = roles.reduce<RolesAssignment>((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<void> {
@@ -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)
}
@@ -14,7 +14,15 @@
-->
<script lang="ts">
import contact from '@hcengineering/contact'
import core, { AnyAttribute, AttributePermission, Permission, Ref } from '@hcengineering/core'
import core, {
AnyAttribute,
AttributePermission,
Class,
ClassPermission,
Doc,
Permission,
Ref
} from '@hcengineering/core'
import { AttributeEditor, MessageBox, createQuery, getClient } from '@hcengineering/presentation'
import {
ButtonIcon,
@@ -179,13 +187,20 @@
return permission._class === core.class.AttributePermission
}
function isClassPermission (permission: Permission): permission is ClassPermission {
return permission._class === core.class.ClassPermission
}
function getAttributePermissionLabel (permission: Permission): IntlString | undefined {
const isAttribute = isAttributePermission(permission)
if (!isAttribute) {
return undefined
if (isAttribute) {
const attr = client.getModel().findObject(permission.attribute) as AnyAttribute
return attr?.label
}
if (isClassPermission(permission)) {
const _class = client.getModel().findObject(permission.targetClass) as Class<Doc>
return _class?.label
}
const attr = client.getModel().findObject(permission.attribute) as AnyAttribute
return attr?.label
}
</script>
@@ -15,9 +15,81 @@
<script lang="ts">
import { MasterTag } from '@hcengineering/card'
import { ClassAttributes } from '@hcengineering/setting-resources'
import setting from '@hcengineering/setting-resources/src/plugin'
import { ButtonIcon, showPopup } from '@hcengineering/ui'
import card from '../../plugin'
import { getClient, MessageBox } from '@hcengineering/presentation'
import core, { ClassPermission, Ref } from '@hcengineering/core'
import view from '@hcengineering/view'
export let masterTag: MasterTag
const client = getClient()
const hierarchy = client.getHierarchy()
let isRestricted: boolean =
client.getModel().findObject(getPermissionRef(false)) !== undefined ||
client.getModel().findObject(getPermissionRef(true)) !== undefined
function getPermissionRef (forbidden: boolean): Ref<ClassPermission> {
return `${masterTag._id}_${forbidden ? 'forbidden' : 'allowed'}` as Ref<ClassPermission>
}
function changeRestricted (): void {
showPopup(
MessageBox,
{
label: setting.string.Restricted,
message: setting.string.RestrictedAttributeWarning,
action: async () => {
isRestricted = true
const isMixin = hierarchy.isMixin(masterTag._id)
const objectClass = hierarchy.getBaseClass(masterTag._id)
const txClass = isMixin ? core.class.TxMixin : core.class.TxUpdateDoc
await client.createDoc(
core.class.ClassPermission,
core.space.Model,
{
objectClass,
txClass,
txMatch: {
[isMixin ? 'mixin' : 'objectClass']: masterTag._id
},
scope: 'space',
forbid: false,
label: view.string.AllowAttributeChanges,
description: masterTag.label,
targetClass: masterTag._id
},
getPermissionRef(false)
)
await client.createDoc(
core.class.ClassPermission,
core.space.Model,
{
objectClass,
txClass,
txMatch: {
[isMixin ? 'mixin' : 'objectClass']: masterTag._id
},
scope: 'space',
forbid: true,
label: view.string.ForbidAttributeChanges,
description: masterTag.label,
targetClass: masterTag._id
},
getPermissionRef(true)
)
}
},
'top',
(res) => {
if (res !== undefined) {
isRestricted = res
}
}
)
}
</script>
<ClassAttributes
@@ -27,4 +99,17 @@
showHeader={false}
disabled={false}
isCard
/>
>
<div slot="header">
<ButtonIcon
kind={'secondary'}
icon={card.icon.Lock}
size={'small'}
tooltip={{
label: setting.string.Restricted
}}
disabled={isRestricted}
on:click={changeRestricted}
/>
</div>
</ClassAttributes>
+2 -1
View File
@@ -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,
@@ -187,6 +187,8 @@
<IconSettings size={'small'} />
<span><Label label={settings.string.ClassProperties} /></span>
{/if}
{:else if $$slots.header}
<slot name="header" />
{:else}
<div></div>
{/if}
@@ -1,5 +1,4 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
@@ -0,0 +1,34 @@
<!--
// 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.
-->
<script lang="ts">
import { Class, ClassPermission, Doc } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { Label } from '@hcengineering/ui'
export let value: ClassPermission
export let inline: boolean = false
const client = getClient()
$: _class = client.getModel().findObject(value?.targetClass) as Class<Doc>
</script>
{#if value}
<div class="flex-presenter" class:inline-presenter={inline}>
<Label label={value.label} />
{#if _class}
<Label label={_class.label} />
{/if}
</div>
{/if}
+2
View File
@@ -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<Resources> => ({
ManageSpaceTypeContent,
PermissionPresenter,
AttributePermissionPresenter,
ClassPermissionPresenter,
SpaceTypeDescriptorPresenter,
SpaceTypeGeneralSectionEditor,
SpaceTypePropertiesSectionEditor,
+1
View File
@@ -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,
+12
View File
@@ -31,6 +31,18 @@ export function canChangeAttribute (
return true
}
const target = attr.attributeOf
const forbiddenClId = `${target}_forbidden` as Ref<Permission>
const forbiddenCl = store.ps[space]?.has(forbiddenClId)
if (forbiddenCl) {
return false
}
const allowedClId = `${target}_allowed` as Ref<Permission>
const allowedCl = store.ps[space]?.has(allowedClId)
if (allowedCl) {
return true
}
return canChangeDoc(_class ?? attr.attributeOf, space, store)
}