Strict RBAC for spaces (#10261)

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov
2025-11-30 18:28:33 +05:00
committed by GitHub
parent 2c2a217b1e
commit a129aef8d1
16 changed files with 84 additions and 17 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.301",
"MODEL_VERSION": "0.7.312",
// "version": "0.7.0",
"COMMUNICATION_API_ENABLED": "true",
"ELASTIC_INDEX_NAME": "local_storage_index",
@@ -61,6 +61,8 @@
"ArchiveSpaceDescription": "Umožňuje uživatelům archivovat prostor",
"AutoJoin": "Automatické připojení",
"AutoJoinDescr": "Automaticky připojit nové zaměstnance k tomuto prostoru",
"RBAC": "Řízení přístupu na základě rolí",
"RBACDescr": "Vyžadovat řízení přístupu na základě rolí pro provádění akcí v tomto prostoru",
"BlobSize": "Velikost",
"BlobContentType": "Typ obsahu",
"Relation": "Vztah",
@@ -61,6 +61,8 @@
"ArchiveSpaceDescription": "Gewährt Benutzern die Möglichkeit, den Arbeitsbereich zu archivieren",
"AutoJoin": "Automatisch beitreten",
"AutoJoinDescr": "Neue Mitarbeiter automatisch diesem Arbeitsbereich hinzufügen",
"RBAC": "Rollenbasierte Zugriffskontrolle",
"RBACDescr": "Erfordert rollenbasierten Zugriff, um Aktionen in diesem Arbeitsbereich auszuführen",
"BlobSize": "Größe",
"BlobContentType": "Inhaltstyp",
"Relation": "Beziehung",
@@ -61,6 +61,8 @@
"ArchiveSpaceDescription": "Grants users ability to archive the space",
"AutoJoin": "Auto join",
"AutoJoinDescr": "Automatically join new employees to this space",
"RBAC": "Role-based access control",
"RBACDescr": "Require role-based access to perform actions in this space",
"BlobSize": "Size",
"BlobContentType": "Content type",
"Relation": "Relation",
@@ -54,6 +54,8 @@
"ArchiveSpaceDescription": "Concede a los usuarios la capacidad de archivar el espacio",
"AutoJoin": "Auto unirse",
"AutoJoinDescr": "Unirse automáticamente a los nuevos empleados a este espacio",
"RBAC": "Control de acceso basado en roles",
"RBACDescr": "Requerir acceso basado en roles para realizar acciones en este espacio",
"BlobSize": "Tamaño",
"BlobContentType": "Tipo de contenido",
"Relation": "Relación",
@@ -61,6 +61,8 @@
"ArchiveSpaceDescription": "Accorde aux utilisateurs la capacité d'archiver l'espace",
"AutoJoin": "Rejoindre automatiquement",
"AutoJoinDescr": "Ajouter automatiquement les nouveaux employés à cet espace",
"RBAC": "Contrôle d'accès basé sur les rôles",
"RBACDescr": "Exiger un accès basé sur les rôles pour effectuer des actions dans cet espace",
"BlobSize": "Taille",
"BlobContentType": "Type de contenu",
"Relation": "Relation",
@@ -61,6 +61,8 @@
"ArchiveSpaceDescription": "Concede agli utenti la possibilità di archiviare lo spazio",
"AutoJoin": "Partecipazione automatica",
"AutoJoinDescr": "Aggiungi automaticamente i nuovi dipendenti a questo spazio",
"RBAC": "Controllo accessi basato sui ruoli",
"RBACDescr": "Richiedi accesso basato sui ruoli per eseguire azioni in questo spazio",
"BlobSize": "Dimensione",
"BlobContentType": "Tipo di contenuto",
"Relation": "Relazione",
@@ -61,6 +61,8 @@
"ArchiveSpaceDescription": "ユーザーにスペースをアーカイブする権限を付与します",
"AutoJoin": "自動参加",
"AutoJoinDescr": "新しいユーザーを自動的にこのスペースに参加させます",
"RBAC": "ロールベースアクセス制御",
"RBACDescr": "このスペースでの操作を実行するためにロールベースのアクセスを要求します",
"BlobSize": "サイズ",
"BlobContentType": "コンテンツタイプ",
"Relation": "関係",
@@ -54,6 +54,8 @@
"ArchiveSpaceDescription": "Concede aos usuários a capacidade de arquivar o espaço",
"AutoJoin": "Auto adesão",
"AutoJoinDescr": "Adesão automática de novos funcionários a este espaço",
"RBAC": "Controle de acesso baseado em funções",
"RBACDescr": "Exigir acesso baseado em funções para executar ações neste espaço",
"BlobSize": "Tamanho",
"BlobContentType": "Tipo de conteúdo",
"Relation": "Relação",
@@ -61,6 +61,8 @@
"ArchiveSpaceDescription": "Дает пользователям разрешение архивировать пространство",
"AutoJoin": "Автоприсоединение",
"AutoJoinDescr": "Автоматически присоединять новых сотрудников к этому пространству",
"RBAC": "Ролевой доступ",
"RBACDescr": "Требовать ролевой доступ для выполнения действий в этом пространстве",
"BlobSize": "Размер",
"BlobContentType": "Тип контента",
"Relation": "Связь",
@@ -61,6 +61,8 @@
"ArchiveSpaceDescription": "Kullanıcılara alanı arşivleme yetkisi verir",
"AutoJoin": "Otomatik katıl",
"AutoJoinDescr": "Yeni çalışanları bu alana otomatik olarak ekle",
"RBAC": "Rol tabanlı erişim kontrolü",
"RBACDescr": "Bu alanda işlemleri gerçekleştirmek için rol tabanlı erişim gerektir",
"BlobSize": "Boyut",
"BlobContentType": "İçerik türü",
"Relation": "İlişki",
@@ -61,6 +61,8 @@
"ArchiveSpaceDescription": "授予用户归档空间的权限",
"AutoJoin": "自动加入",
"AutoJoinDescr": "自动将新员工加入此空间",
"RBAC": "基于角色的访问控制",
"RBACDescr": "要求基于角色的访问权限以在此空间中执行操作",
"BlobSize": "大小",
"BlobContentType": "內容類型",
"Relation": "关系",
@@ -490,6 +490,7 @@ export interface SystemSpace extends Space {}
*/
export interface TypedSpace extends Space {
type: Ref<SpaceType>
restricted?: boolean // if true user must have permission to any txes
}
/**
@@ -289,7 +289,9 @@ export default plugin(coreId, {
UpdateSpaceDescription: '' as IntlString,
ArchiveSpaceDescription: '' as IntlString,
AutoJoin: '' as IntlString,
AutoJoinDescr: '' as IntlString
AutoJoinDescr: '' as IntlString,
RBAC: '' as IntlString,
RBACDescr: '' as IntlString
},
descriptor: {
SpacesType: '' as Ref<SpaceTypeDescriptor>
@@ -45,6 +45,7 @@ import { BaseMiddleware } from '@hcengineering/server-core'
*/
export class SpacePermissionsMiddleware extends BaseMiddleware implements Middleware {
private whitelistSpaces = new Set<Ref<Space>>()
private readonly restrictedSpaces = new Set<Ref<Space>>()
private assignmentBySpace: Record<Ref<Space>, RolesAssignment> = {}
private permissionsBySpace: Record<Ref<Space>, Record<AccountUuid, Set<Permission>>> = {}
private typeBySpace: Record<Ref<Space>, Ref<SpaceType>> = {}
@@ -121,6 +122,12 @@ export class SpacePermissionsMiddleware extends BaseMiddleware implements Middle
return
}
if (space.restricted === true) {
this.restrictedSpaces.add(space._id)
} else {
this.restrictedSpaces.delete(space._id)
}
this.typeBySpace[space._id] = space.type
const asMixin: RolesAssignment = this.context.hierarchy.as(
@@ -157,7 +164,12 @@ export class SpacePermissionsMiddleware extends BaseMiddleware implements Middle
*
* Checks if the required permission is present in the space for the given context
*/
private checkPermission (ctx: MeasureContext<SessionData>, space: Ref<TypedSpace>, tx: TxCUD<Doc>): boolean {
private checkPermission (
ctx: MeasureContext<SessionData>,
space: Ref<TypedSpace>,
tx: TxCUD<Doc>,
isSpace: boolean
): boolean {
const account = ctx.contextData.account
const permissions = this.permissionsBySpace[space]?.[account.uuid] ?? []
let withoutMatch: Permission | undefined
@@ -185,7 +197,7 @@ export class SpacePermissionsMiddleware extends BaseMiddleware implements Middle
return withoutMatch.forbid !== undefined ? !withoutMatch.forbid : true
}
return true
return isSpace || !this.restrictedSpaces.has(space)
}
private throwForbidden (): void {
@@ -273,6 +285,7 @@ export class SpacePermissionsMiddleware extends BaseMiddleware implements Middle
delete this.typeBySpace[tx.objectId]
this.whitelistSpaces.delete(tx.objectId)
this.restrictedSpaces.delete(tx.objectId)
}
private isSpaceTxCUD (tx: TxCUD<Doc>): tx is TxCUD<Space> {
@@ -337,9 +350,8 @@ export class SpacePermissionsMiddleware extends BaseMiddleware implements Middle
if (this.isSpaceTxCUD(tx)) {
if (tx._class === core.class.TxCreateDoc) {
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.TxUpdateDoc) {
this.handleSpaceUpdate(tx)
} else if (tx._class === core.class.TxMixin) {
this.handleMixin(tx)
} else if (tx._class === core.class.TxRemoveDoc) {
@@ -350,6 +362,21 @@ export class SpacePermissionsMiddleware extends BaseMiddleware implements Middle
this.handlePermissionsUpdatesFromRoleTx(ctx, tx)
}
private handleSpaceUpdate (tx: TxCUD<Space>): void {
if (!this.isTypedSpaceClass(tx.objectClass)) {
return
}
const updateTx = tx as TxUpdateDoc<TypedSpace>
if (updateTx.operations.restricted !== undefined) {
if (updateTx.operations.restricted) {
this.restrictedSpaces.add(tx.objectId)
} else {
this.restrictedSpaces.delete(tx.objectId)
}
}
}
private processPermissionsUpdatesFromTx (ctx: MeasureContext, tx: Tx): void {
if (!TxProcessor.isExtendsCUD(tx._class)) {
return
@@ -362,8 +389,8 @@ export class SpacePermissionsMiddleware extends BaseMiddleware implements Middle
async tx (ctx: MeasureContext<SessionData>, txes: Tx[]): Promise<TxMiddlewareResult> {
await this.init(ctx)
for (const tx of txes) {
this.processPermissionsUpdatesFromTx(ctx, tx)
this.checkPermissions(ctx, tx)
this.processPermissionsUpdatesFromTx(ctx, tx)
}
const res = await this.provideTx(ctx, txes)
for (const txd of ctx.contextData.broadcast.txes) {
@@ -388,17 +415,22 @@ export class SpacePermissionsMiddleware extends BaseMiddleware implements Middle
this.checkSpacePermissions(ctx, cudTx, cudTx.objectSpace)
if (isSpace) {
this.checkSpacePermissions(ctx, cudTx, cudTx.objectId as Ref<Space>)
this.checkSpacePermissions(ctx, cudTx, cudTx.objectId as Ref<Space>, true)
}
}
private checkSpacePermissions (ctx: MeasureContext, cudTx: TxCUD<Doc>, targetSpaceId: Ref<Space>): void {
private checkSpacePermissions (
ctx: MeasureContext,
cudTx: TxCUD<Doc>,
targetSpaceId: Ref<Space>,
isSpace: boolean = false
): void {
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 (!this.checkPermission(ctx, targetSpaceId as Ref<TypedSpace>, cudTx)) {
if (!this.checkPermission(ctx, targetSpaceId as Ref<TypedSpace>, cudTx, isSpace)) {
this.throwForbidden()
}
}
@@ -39,12 +39,13 @@
let types: Ref<MasterTag>[] =
space?.types !== undefined ? hierarchy.clone(space.types) : topLevelTypes.map((it) => it._id)
let roles = client.getModel().findAllSync(card.class.Role, { type: { $in: types } })
$: roles = client.getModel().findAllSync(card.class.Role, { type: { $in: types } })
let roles = client.getModel().findAllSync(card.class.Role, { types: { $in: types } })
$: roles = client.getModel().findAllSync(card.class.Role, { types: { $in: types } })
let name: string = space?.name ?? ''
let isPrivate: boolean = space?.private ?? false
let restricted: boolean = space?.restricted ?? false
let members: AccountUuid[] =
space?.members !== undefined ? hierarchy.clone(space.members) : [getCurrentAccount().uuid]
let owners: AccountUuid[] = space?.owners !== undefined ? hierarchy.clone(space.owners) : [getCurrentAccount().uuid]
@@ -86,7 +87,8 @@
autoJoin,
archived: false,
type: card.spaceType.SpaceType,
types
types,
restricted
}
}
@@ -106,9 +108,9 @@
}
async function create (): Promise<void> {
const teamspaceData = getData()
const data = getData()
const id = await client.createDoc(card.class.CardSpace, core.space.Space, { ...teamspaceData })
const id = await client.createDoc(card.class.CardSpace, core.space.Space, data)
if (rolesAssignment && !deepEqual(rolesAssignment, getRolesAssignment(roles))) {
await client.updateMixin(id, card.class.CardSpace, core.space.Space, core.mixin.SpacesTypeData, rolesAssignment)
@@ -221,7 +223,15 @@
<Label label={core.string.AutoJoin} />
<span><Label label={core.string.AutoJoinDescr} /></span>
</div>
<Toggle id={'teamspace-autoJoin'} bind:on={autoJoin} />
<Toggle id={'space-autoJoin'} bind:on={autoJoin} />
</div>
<div class="antiGrid-row">
<div class="antiGrid-row__header withDesciption">
<Label label={core.string.RBAC} />
<span><Label label={core.string.RBACDescr} /></span>
</div>
<Toggle id={'space-restricted'} bind:on={restricted} />
</div>
{#each roles as role}