From cb48b608c93c63a2b3aa248e0e64f19fdb4f506d Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Thu, 1 Oct 2026 22:51:21 +0000 Subject: [PATCH 1/3] feat(core): object-scoped permissions - Permission.scope 'object', ObjectRole class, Collaborator.role First step of the unified permission model discussed in #10966. Model primitives only - no enforcement path reads them yet: - Permission.scope accepts 'object' (core interface and TPermission) - new ObjectRole class (DOMAIN_MODEL): a named, app-declared set of object-scoped permissions for documents of a given class; reuses the core.string.Role label like TRole - optional Collaborator.role: Ref; undefined keeps today's structural collaborator semantics. Stored in the JSONB data column on Postgres, so no schema change or migration is needed. Tests: model-core builds the class into the hierarchy; postgres isDataField routes Collaborator.role to the data column. Signed-off-by: Michael Uray --- foundations/core/packages/core/src/classes.ts | 37 +++++++++- .../core/packages/core/src/component.ts | 2 + .../postgres/src/__tests__/utils.spec.ts | 8 ++- models/core/src/__tests__/objectRole.test.ts | 72 +++++++++++++++++++ models/core/src/core.ts | 4 ++ models/core/src/index.ts | 2 + models/core/src/security.ts | 28 +++++++- 7 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 models/core/src/__tests__/objectRole.test.ts diff --git a/foundations/core/packages/core/src/classes.ts b/foundations/core/packages/core/src/classes.ts index 84a9555caa2..7b72b2e4ceb 100644 --- a/foundations/core/packages/core/src/classes.ts +++ b/foundations/core/packages/core/src/classes.ts @@ -557,6 +557,30 @@ export interface Role extends AttachedDoc { permissions: Ref[] } +/** + * @public + * ObjectRole is a named, app-declared set of object-scoped permissions + * (`Permission.scope === 'object'`) for documents of `objectClass`. + * It is granted to an account on a single document via `Collaborator.role`. + * + * Read access semantics: an object role implies read access to the document, so + * there is no separate read permission. Only a collaborator record that carries a + * `role` grants it; structural collaborators (no `role`) grant nothing beyond + * today's behaviour. Per-action permissions describe the write path. + * + * Object roles live in `DOMAIN_MODEL` intentionally: like space `Role`s they are + * declared by apps in the model, and workspace-defined roles can later be created + * via model transactions in the same way as space roles. + * + * Declarations only for now - no middleware evaluates object roles yet. + */ +export interface ObjectRole extends Doc { + name: IntlString + description?: IntlString + objectClass: Ref> + permissions: Ref[] +} + /** * @public * Defines assignment of employees to a role within a space @@ -572,7 +596,13 @@ export interface Permission extends Doc { txClass?: Ref> forbid?: boolean objectClass?: Ref> - scope?: 'space' | 'workspace' + /** + * - 'space': granted through a space role + * - 'workspace': applies workspace-wide + * - 'object': granted on a single document through an `ObjectRole` (`Collaborator.role`); + * not evaluated by any enforcement path yet + */ + scope?: 'space' | 'workspace' | 'object' txMatch?: DocumentQuery description?: IntlString icon?: Asset @@ -1043,6 +1073,11 @@ export interface ClassCollaborators extends Doc { export interface Collaborator extends AttachedDoc { collaborator: AccountUuid + /** + * Object role granted to the collaborator on the attached document. + * `undefined` keeps today's structural collaborator semantics (fields, notifications, mentions). + */ + role?: Ref } /** diff --git a/foundations/core/packages/core/src/component.ts b/foundations/core/packages/core/src/component.ts index 0fd395cf6e1..5c93f3bf9fc 100644 --- a/foundations/core/packages/core/src/component.ts +++ b/foundations/core/packages/core/src/component.ts @@ -45,6 +45,7 @@ import type { Mixin, ModulePermissionGroup, Obj, + ObjectRole, Permission, PersonId, PluginConfiguration, @@ -133,6 +134,7 @@ export default plugin(coreId, { SpaceTypeDescriptor: '' as Ref>, SpaceType: '' as Ref>, Role: '' as Ref>, + ObjectRole: '' as Ref>, Permission: '' as Ref>, AttributePermission: '' as Ref>, ClassPermission: '' as Ref>, diff --git a/foundations/server/packages/postgres/src/__tests__/utils.spec.ts b/foundations/server/packages/postgres/src/__tests__/utils.spec.ts index 1940cc8d7ab..4287b71e68a 100644 --- a/foundations/server/packages/postgres/src/__tests__/utils.spec.ts +++ b/foundations/server/packages/postgres/src/__tests__/utils.spec.ts @@ -1,4 +1,4 @@ -import { type DocumentUpdate, type Ref, type Space, type WorkspaceUuid } from '@hcengineering/core' +import { DOMAIN_COLLABORATOR, type DocumentUpdate, type Ref, type Space, type WorkspaceUuid } from '@hcengineering/core' import { convertArrayParams, convertDoc, @@ -765,6 +765,12 @@ describe('utils - isDataField', () => { it('should handle attachedTo field', () => { expect(isDataField('pg_testing', 'attachedTo')).toBe(false) }) + + it('should store Collaborator.role in the data column (no schema column, no migration)', () => { + expect(isDataField(DOMAIN_COLLABORATOR, 'role')).toBe(true) + expect(isDataField(DOMAIN_COLLABORATOR, 'collaborator')).toBe(false) + expect(isDataField(DOMAIN_COLLABORATOR, 'attachedTo')).toBe(false) + }) }) describe('utils - edge cases and potential bugs', () => { diff --git a/models/core/src/__tests__/objectRole.test.ts b/models/core/src/__tests__/objectRole.test.ts new file mode 100644 index 00000000000..5f432563f92 --- /dev/null +++ b/models/core/src/__tests__/objectRole.test.ts @@ -0,0 +1,72 @@ +// +// Copyright © 2026 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. +// + +import core, { + DOMAIN_MODEL, + Hierarchy, + type ArrOf, + type Class, + type Doc, + type ObjectRole, + type Permission, + type Ref, + type RefTo +} from '@hcengineering/core' +import { Builder } from '@hcengineering/model' + +import { createModel } from '..' + +function buildHierarchy (): Hierarchy { + const builder = new Builder() + createModel(builder) + const hierarchy = new Hierarchy() + for (const tx of builder.getTxes()) { + hierarchy.tx(tx) + } + return hierarchy +} + +describe('ObjectRole model', () => { + const hierarchy = buildHierarchy() + + it('registers ObjectRole as a model class derived from Doc', () => { + expect(hierarchy.hasClass(core.class.ObjectRole)).toBe(true) + expect(hierarchy.isDerived(core.class.ObjectRole, core.class.Doc)).toBe(true) + expect(hierarchy.findDomain(core.class.ObjectRole)).toBe(DOMAIN_MODEL) + expect(hierarchy.getClass(core.class.ObjectRole).label).toBe(core.string.Role) + }) + + it('declares ObjectRole.permissions as an array of Permission refs', () => { + const attr = hierarchy.findAttribute(core.class.ObjectRole, 'permissions') + expect(attr).toBeDefined() + expect(attr?.type._class).toBe(core.class.ArrOf) + expect(((attr?.type as ArrOf>).of as RefTo).to).toBe(core.class.Permission) + }) + + it('declares ObjectRole name, description and objectClass as model attributes', () => { + const name = hierarchy.findAttribute(core.class.ObjectRole, 'name') + expect(name?.type._class).toBe(core.class.TypeIntlString) + expect(name?.label).toBe(core.string.Name) + + const description = hierarchy.findAttribute(core.class.ObjectRole, 'description') + expect(description?.type._class).toBe(core.class.TypeIntlString) + expect(description?.label).toBe(core.string.Description) + + const objectClass = hierarchy.findAttribute(core.class.ObjectRole, 'objectClass') + expect(objectClass?.type._class).toBe(core.class.RefTo) + expect((objectClass?.type as RefTo>).to).toBe(core.class.Class) + expect(objectClass?.label).toBe(core.string.Class) + }) + + it('declares Collaborator.role as a reference to ObjectRole', () => { + const role = hierarchy.findAttribute(core.class.Collaborator, 'role') + expect(role).toBeDefined() + expect(role?.type._class).toBe(core.class.RefTo) + expect((role?.type as RefTo).to).toBe(core.class.ObjectRole) + expect(role?.label).toBe(core.string.Role) + }) +}) diff --git a/models/core/src/core.ts b/models/core/src/core.ts index d6f4588a905..d0f3cc5ca26 100644 --- a/models/core/src/core.ts +++ b/models/core/src/core.ts @@ -48,6 +48,7 @@ import { type MigrationState, type Mixin, type Obj, + type ObjectRole, type PersonId, type PluginConfiguration, type Ref, @@ -443,6 +444,9 @@ export class TClassCollaborators extends TDoc implements ClassCollaborators @Model(core.class.Collaborator, core.class.Doc, DOMAIN_COLLABORATOR) export class TCollaborator extends TAttachedDoc implements Collaborator { collaborator!: AccountUuid + + @Prop(TypeRef(core.class.ObjectRole), core.string.Role) + role?: Ref } @MMixin(core.mixin.VersionableClass, core.class.Class) diff --git a/models/core/src/index.ts b/models/core/src/index.ts index cebdc42862f..4f42a09efeb 100644 --- a/models/core/src/index.ts +++ b/models/core/src/index.ts @@ -82,6 +82,7 @@ import { TAttributePermission, TModulePermissionGroup, TClassPermission, + TObjectRole, TPermission, TRole, TSpace, @@ -136,6 +137,7 @@ export function createModel (builder: Builder): void { TSpaceType, TSpaceTypeDescriptor, TRole, + TObjectRole, TPermission, TModulePermissionGroup, TAttributePermission, diff --git a/models/core/src/security.ts b/models/core/src/security.ts index be3c300db4b..3f6170a8aba 100644 --- a/models/core/src/security.ts +++ b/models/core/src/security.ts @@ -26,6 +26,7 @@ import { type ClassPermission, type CollectionSize, type Doc, + type ObjectRole, type Permission, type Ref, type Role, @@ -47,6 +48,7 @@ import { Prop, TypeAccountUuid, TypeBoolean, + TypeIntlString, TypeNumber, TypeRef, TypeString, @@ -161,6 +163,30 @@ export class TRole extends TAttachedDoc implements Role { permissions!: Ref[] } +/** + * App-declared, named set of object-scoped permissions, granted per document via `Collaborator.role`. + * An object role implies read access; only a collaborator record carrying a `role` grants it, + * structural collaborators without `role` grant nothing beyond today's behaviour. + * Stored in `DOMAIN_MODEL` intentionally: declared by apps/model like space `Role`; workspace-defined + * roles can later be created via model transactions like space roles. + * Not evaluated by any enforcement path yet. + */ +@Model(core.class.ObjectRole, core.class.Doc, DOMAIN_MODEL) +@UX(core.string.Role, undefined, undefined, 'name') +export class TObjectRole extends TDoc implements ObjectRole { + @Prop(TypeIntlString(), core.string.Name) + name!: IntlString + + @Prop(TypeIntlString(), core.string.Description) + description?: IntlString + + @Prop(TypeRef(core.class.Class), core.string.Class) + objectClass!: Ref> + + @Prop(ArrOf(TypeRef(core.class.Permission)), core.string.Permission) + permissions!: Ref[] +} + @Model(core.class.Permission, core.class.Doc, DOMAIN_MODEL) @UX(core.string.Permission) export class TPermission extends TDoc implements Permission { @@ -168,7 +194,7 @@ export class TPermission extends TDoc implements Permission { txClass?: Ref> forbid?: boolean objectClass?: Ref>> - scope?: 'space' | 'workspace' + scope?: 'space' | 'workspace' | 'object' description?: IntlString icon?: Asset } From 65323ff46433232a8e44e2b7c7e3efe65f6a6463 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Thu, 1 Oct 2026 22:55:42 +0000 Subject: [PATCH 2/3] feat(tracker): declare object-level issue permissions (no enforcement) Declare four object-scoped Issue permissions - CommentOnIssue, EditIssue, TransitionIssue, DeleteIssue - with scope 'object' and objectClass tracker.class.Issue, plus labels and descriptions in all 14 tracker locales. There is no separate read permission: an object role implies read access to the document. The declarations intentionally carry no txClass, txMatch or forbid: SpacePermissionsMiddleware treats any Permission with a matching objectClass and txClass as a restriction in restricted spaces, so these declarations cannot affect existing access decisions. They are not added to the project type's availablePermissions or to any ModulePermissionGroup, so they do not appear in the role editors either. Write-path matching and ObjectRole instances follow with enforcement. Signed-off-by: Michael Uray --- .../tracker/src/__tests__/permissions.test.ts | 70 +++++++++++++++++++ models/tracker/src/permissions.ts | 49 ++++++++++++- plugins/tracker-assets/lang/cs.json | 8 +++ plugins/tracker-assets/lang/de.json | 8 +++ plugins/tracker-assets/lang/en.json | 8 +++ plugins/tracker-assets/lang/es.json | 8 +++ plugins/tracker-assets/lang/fr.json | 8 +++ plugins/tracker-assets/lang/it.json | 8 +++ plugins/tracker-assets/lang/ja.json | 8 +++ plugins/tracker-assets/lang/ko.json | 8 +++ plugins/tracker-assets/lang/pl.json | 8 +++ plugins/tracker-assets/lang/pt-br.json | 8 +++ plugins/tracker-assets/lang/pt.json | 8 +++ plugins/tracker-assets/lang/ru.json | 8 +++ plugins/tracker-assets/lang/tr.json | 8 +++ plugins/tracker-assets/lang/zh.json | 8 +++ plugins/tracker/src/index.ts | 14 +++- 17 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 models/tracker/src/__tests__/permissions.test.ts diff --git a/models/tracker/src/__tests__/permissions.test.ts b/models/tracker/src/__tests__/permissions.test.ts new file mode 100644 index 00000000000..c6de49fa33b --- /dev/null +++ b/models/tracker/src/__tests__/permissions.test.ts @@ -0,0 +1,70 @@ +// +// Copyright © 2026 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. +// + +import core, { type Permission, type Ref, type TxCreateDoc } from '@hcengineering/core' +import { Builder } from '@hcengineering/model' +import tracker from '@hcengineering/tracker' + +import { definePermissions } from '../permissions' + +function declaredPermissions (): Array> { + return builderTxes() + .filter((tx): tx is TxCreateDoc => tx._class === core.class.TxCreateDoc) + .filter((tx) => tx.objectClass === core.class.Permission) +} + +const issuePermissions: Array> = [ + tracker.permission.CommentOnIssue, + tracker.permission.EditIssue, + tracker.permission.TransitionIssue, + tracker.permission.DeleteIssue +] + +describe('tracker definePermissions', () => { + const txes = declaredPermissions() + + it.each(issuePermissions)('declares %s once as an object-scoped Issue permission without enforcement hooks', (id) => { + const matches = txes.filter((tx) => tx.objectId === id) + expect(matches).toHaveLength(1) + const attrs = matches[0].attributes + expect(attrs.scope).toBe('object') + expect(attrs.objectClass).toBe(tracker.class.Issue) + expect(attrs.label).toBeDefined() + expect(attrs.description).toBeDefined() + // No txClass/txMatch/forbid: SpacePermissionsMiddleware would otherwise treat them + // as restrictions in restricted spaces. Enforcement comes in a follow-up. + expect(attrs.txClass).toBeUndefined() + expect(attrs.txMatch).toBeUndefined() + expect(attrs.forbid).toBeUndefined() + }) + + it('does not create ObjectRole instances', () => { + const objectRoles = builderTxes().filter( + (tx) => tx._class === core.class.TxCreateDoc && (tx as TxCreateDoc).objectClass === core.class.ObjectRole + ) + expect(objectRoles).toHaveLength(0) + }) + + it('keeps ForbidCreateProject unchanged', () => { + const matches = txes.filter((tx) => tx.objectId === tracker.permission.ForbidCreateProject) + expect(matches).toHaveLength(1) + expect(matches[0].attributes).toEqual({ + label: tracker.string.ForbidCreateProjectPermission, + txClass: core.class.TxCreateDoc, + objectClass: tracker.class.Project, + forbid: true, + scope: 'workspace', + description: tracker.string.ForbidCreateProjectPermissionDescription + }) + }) +}) + +function builderTxes (): ReturnType { + const builder = new Builder() + definePermissions(builder) + return builder.getTxes() +} diff --git a/models/tracker/src/permissions.ts b/models/tracker/src/permissions.ts index 6ebddfedeb6..a04bf98d751 100644 --- a/models/tracker/src/permissions.ts +++ b/models/tracker/src/permissions.ts @@ -1,5 +1,6 @@ import type { Builder } from '@hcengineering/model' -import core from '@hcengineering/core' +import core, { type Permission, type Ref } from '@hcengineering/core' +import { type IntlString } from '@hcengineering/platform' import tracker from '@hcengineering/tracker' export function definePermissions (builder: Builder): void { @@ -16,4 +17,50 @@ export function definePermissions (builder: Builder): void { }, tracker.permission.ForbidCreateProject ) + + defineIssuePermissions(builder) +} + +/** + * Object-scoped Issue permissions, intended to be grouped into ObjectRoles and + * granted on a single issue via Collaborator.role. + * + * Declarations only: no txClass/txMatch/forbid on purpose. SpacePermissionsMiddleware + * treats any Permission with a matching objectClass and txClass as a restriction in + * restricted spaces, so adding txClass here would change behaviour. Write-path matching + * (txClass/txMatch) is added together with enforcement. + */ +function defineIssuePermissions (builder: Builder): void { + const issuePermissions: Array<[Ref, IntlString, IntlString]> = [ + [ + tracker.permission.CommentOnIssue, + tracker.string.CommentOnIssuePermission, + tracker.string.CommentOnIssuePermissionDescription + ], + [tracker.permission.EditIssue, tracker.string.EditIssuePermission, tracker.string.EditIssuePermissionDescription], + [ + tracker.permission.TransitionIssue, + tracker.string.TransitionIssuePermission, + tracker.string.TransitionIssuePermissionDescription + ], + [ + tracker.permission.DeleteIssue, + tracker.string.DeleteIssuePermission, + tracker.string.DeleteIssuePermissionDescription + ] + ] + + for (const [id, label, description] of issuePermissions) { + builder.createDoc( + core.class.Permission, + core.space.Model, + { + label, + description, + scope: 'object', + objectClass: tracker.class.Issue + }, + id + ) + } } diff --git a/plugins/tracker-assets/lang/cs.json b/plugins/tracker-assets/lang/cs.json index 4d785162d80..a634a98a882 100644 --- a/plugins/tracker-assets/lang/cs.json +++ b/plugins/tracker-assets/lang/cs.json @@ -283,6 +283,14 @@ "UnsetParentIssue": "Odebrat nadřazený úkol", "ForbidCreateProjectPermission": "Zakázat vytvoření projektu", "ForbidCreateProjectPermissionDescription": "Zakazuje uživatelům vytvářet nové projekty", + "CommentOnIssuePermission": "Komentovat úkol", + "CommentOnIssuePermissionDescription": "Umožňuje komentovat jednotlivý úkol", + "EditIssuePermission": "Upravit úkol", + "EditIssuePermissionDescription": "Umožňuje upravit jednotlivý úkol", + "TransitionIssuePermission": "Změnit stav úkolu", + "TransitionIssuePermissionDescription": "Umožňuje změnit stav jednotlivého úkolu", + "DeleteIssuePermission": "Smazat úkol", + "DeleteIssuePermissionDescription": "Umožňuje smazat jednotlivý úkol", "AllowCreatingIssues": "Povolit vytváření úkolů", "Day": "Day", "Week": "Week", diff --git a/plugins/tracker-assets/lang/de.json b/plugins/tracker-assets/lang/de.json index 4886ccce28d..87499b0a825 100644 --- a/plugins/tracker-assets/lang/de.json +++ b/plugins/tracker-assets/lang/de.json @@ -324,6 +324,14 @@ "UnsetParentIssue": "Übergeordnete Aufgabe entfernen", "ForbidCreateProjectPermission": "Projekterstellung verbieten", "ForbidCreateProjectPermissionDescription": "Verbietet Benutzern das Erstellen neuer Projekte", + "CommentOnIssuePermission": "Aufgabe kommentieren", + "CommentOnIssuePermissionDescription": "Erlaubt das Kommentieren einer einzelnen Aufgabe", + "EditIssuePermission": "Aufgabe bearbeiten", + "EditIssuePermissionDescription": "Erlaubt das Bearbeiten einer einzelnen Aufgabe", + "TransitionIssuePermission": "Aufgabenstatus ändern", + "TransitionIssuePermissionDescription": "Erlaubt das Ändern des Status einer einzelnen Aufgabe", + "DeleteIssuePermission": "Aufgabe löschen", + "DeleteIssuePermissionDescription": "Erlaubt das Löschen einer einzelnen Aufgabe", "Deadline": "Deadline", "BarLabelNone": "Keine", "BarLabelTitle": "Titel", diff --git a/plugins/tracker-assets/lang/en.json b/plugins/tracker-assets/lang/en.json index d9dc6a07ca5..8e6ff25057a 100644 --- a/plugins/tracker-assets/lang/en.json +++ b/plugins/tracker-assets/lang/en.json @@ -310,6 +310,14 @@ "UnsetParentIssue": "Unset parent issue", "ForbidCreateProjectPermission": "Forbid create project", "ForbidCreateProjectPermissionDescription": "Forbid users creating new projects", + "CommentOnIssuePermission": "Comment on issue", + "CommentOnIssuePermissionDescription": "Allows commenting on an individual issue", + "EditIssuePermission": "Edit issue", + "EditIssuePermissionDescription": "Allows editing an individual issue", + "TransitionIssuePermission": "Change issue status", + "TransitionIssuePermissionDescription": "Allows changing the status of an individual issue", + "DeleteIssuePermission": "Delete issue", + "DeleteIssuePermissionDescription": "Allows deleting an individual issue", "Deadline": "Deadline", "BarLabelNone": "None", "BarLabelTitle": "Title", diff --git a/plugins/tracker-assets/lang/es.json b/plugins/tracker-assets/lang/es.json index 1ff5a8a77bc..505dbb56192 100644 --- a/plugins/tracker-assets/lang/es.json +++ b/plugins/tracker-assets/lang/es.json @@ -276,6 +276,14 @@ "UnsetParentIssue": "Unset parent issue", "ForbidCreateProjectPermission": "Prohibir crear proyecto", "ForbidCreateProjectPermissionDescription": "Prohíbe a los usuarios crear nuevos proyectos", + "CommentOnIssuePermission": "Comentar tarea", + "CommentOnIssuePermissionDescription": "Permite comentar una tarea individual", + "EditIssuePermission": "Editar tarea", + "EditIssuePermissionDescription": "Permite editar una tarea individual", + "TransitionIssuePermission": "Cambiar estado de la tarea", + "TransitionIssuePermissionDescription": "Permite cambiar el estado de una tarea individual", + "DeleteIssuePermission": "Eliminar tarea", + "DeleteIssuePermissionDescription": "Permite eliminar una tarea individual", "AllowCreatingIssues": "Permitir crear incidencias", "Day": "Day", "Week": "Week", diff --git a/plugins/tracker-assets/lang/fr.json b/plugins/tracker-assets/lang/fr.json index df9e6094c23..238249d2a3c 100644 --- a/plugins/tracker-assets/lang/fr.json +++ b/plugins/tracker-assets/lang/fr.json @@ -276,6 +276,14 @@ "UnsetParentIssue": "Désélectionner l'issue parent", "ForbidCreateProjectPermission": "Interdire la création de projet", "ForbidCreateProjectPermissionDescription": "Interdit aux utilisateurs de créer de nouveaux projets", + "CommentOnIssuePermission": "Commenter l'issue", + "CommentOnIssuePermissionDescription": "Permet de commenter une issue individuelle", + "EditIssuePermission": "Modifier l'issue", + "EditIssuePermissionDescription": "Permet de modifier une issue individuelle", + "TransitionIssuePermission": "Changer le statut de l'issue", + "TransitionIssuePermissionDescription": "Permet de changer le statut d'une issue individuelle", + "DeleteIssuePermission": "Supprimer l'issue", + "DeleteIssuePermissionDescription": "Permet de supprimer une issue individuelle", "AllowCreatingIssues": "Autoriser la création d'issues", "Day": "Day", "Week": "Week", diff --git a/plugins/tracker-assets/lang/it.json b/plugins/tracker-assets/lang/it.json index 318f9326f71..70788119be5 100644 --- a/plugins/tracker-assets/lang/it.json +++ b/plugins/tracker-assets/lang/it.json @@ -276,6 +276,14 @@ "UnsetParentIssue": "Annulla l'issue genitore", "ForbidCreateProjectPermission": "Vieta creazione progetto", "ForbidCreateProjectPermissionDescription": "Vieta agli utenti di creare nuovi progetti", + "CommentOnIssuePermission": "Commenta issue", + "CommentOnIssuePermissionDescription": "Consente di commentare una singola issue", + "EditIssuePermission": "Modifica issue", + "EditIssuePermissionDescription": "Consente di modificare una singola issue", + "TransitionIssuePermission": "Cambia stato issue", + "TransitionIssuePermissionDescription": "Consente di cambiare lo stato di una singola issue", + "DeleteIssuePermission": "Elimina issue", + "DeleteIssuePermissionDescription": "Consente di eliminare una singola issue", "AllowCreatingIssues": "Consenti la creazione di issue", "Day": "Day", "Week": "Week", diff --git a/plugins/tracker-assets/lang/ja.json b/plugins/tracker-assets/lang/ja.json index d060f6f83ea..302af9c3e34 100644 --- a/plugins/tracker-assets/lang/ja.json +++ b/plugins/tracker-assets/lang/ja.json @@ -276,6 +276,14 @@ "UnsetParentIssue": "親イシューの設定を解除", "ForbidCreateProjectPermission": "プロジェクト作成禁止", "ForbidCreateProjectPermissionDescription": "ユーザーが新しいプロジェクトを作成することを禁止します", + "CommentOnIssuePermission": "イシューへのコメント", + "CommentOnIssuePermissionDescription": "個別のイシューへのコメントを許可します", + "EditIssuePermission": "イシューの編集", + "EditIssuePermissionDescription": "個別のイシューの編集を許可します", + "TransitionIssuePermission": "イシューのステータス変更", + "TransitionIssuePermissionDescription": "個別のイシューのステータス変更を許可します", + "DeleteIssuePermission": "イシューの削除", + "DeleteIssuePermissionDescription": "個別のイシューの削除を許可します", "AllowCreatingIssues": "イシューの作成を許可", "Day": "Day", "Week": "Week", diff --git a/plugins/tracker-assets/lang/ko.json b/plugins/tracker-assets/lang/ko.json index 0d3bba943ee..2ee0afc9e92 100644 --- a/plugins/tracker-assets/lang/ko.json +++ b/plugins/tracker-assets/lang/ko.json @@ -276,6 +276,14 @@ "UnsetParentIssue": "상위 이슈 설정 해제", "ForbidCreateProjectPermission": "프로젝트 생성 금지", "ForbidCreateProjectPermissionDescription": "사용자의 새 프로젝트 생성을 금지", + "CommentOnIssuePermission": "이슈에 댓글 달기", + "CommentOnIssuePermissionDescription": "개별 이슈에 댓글 달기를 허용", + "EditIssuePermission": "이슈 편집", + "EditIssuePermissionDescription": "개별 이슈 편집을 허용", + "TransitionIssuePermission": "이슈 상태 변경", + "TransitionIssuePermissionDescription": "개별 이슈의 상태 변경을 허용", + "DeleteIssuePermission": "이슈 삭제", + "DeleteIssuePermissionDescription": "개별 이슈 삭제를 허용", "AllowCreatingIssues": "이슈 생성 허용", "Day": "Day", "Week": "Week", diff --git a/plugins/tracker-assets/lang/pl.json b/plugins/tracker-assets/lang/pl.json index fa2e8555275..2a69d92cf8d 100644 --- a/plugins/tracker-assets/lang/pl.json +++ b/plugins/tracker-assets/lang/pl.json @@ -290,6 +290,14 @@ "UnsetParentIssue": "Wyczyść zagadnienie nadrzędne", "ForbidCreateProjectPermission": "Zakaż tworzenia projektów", "ForbidCreateProjectPermissionDescription": "Zakaż użytkownikom tworzenia nowych projektów.", + "CommentOnIssuePermission": "Komentowanie zadania", + "CommentOnIssuePermissionDescription": "Pozwala komentować pojedyncze zadanie", + "EditIssuePermission": "Edycja zadania", + "EditIssuePermissionDescription": "Pozwala edytować pojedyncze zadanie", + "TransitionIssuePermission": "Zmiana statusu zadania", + "TransitionIssuePermissionDescription": "Pozwala zmieniać status pojedynczego zadania", + "DeleteIssuePermission": "Usuwanie zadania", + "DeleteIssuePermissionDescription": "Pozwala usuwać pojedyncze zadanie", "AllowCreatingIssues": "Zezwól na tworzenie zadań" }, "status": {} diff --git a/plugins/tracker-assets/lang/pt-br.json b/plugins/tracker-assets/lang/pt-br.json index fb3ea902261..c3d4dbea70b 100644 --- a/plugins/tracker-assets/lang/pt-br.json +++ b/plugins/tracker-assets/lang/pt-br.json @@ -276,6 +276,14 @@ "UnsetParentIssue": "Desmarcar problema pai", "ForbidCreateProjectPermission": "Proibir criação de projeto", "ForbidCreateProjectPermissionDescription": "Proíbe os usuários de criar novos projetos", + "CommentOnIssuePermission": "Comentar tarefa", + "CommentOnIssuePermissionDescription": "Permite comentar uma tarefa individual", + "EditIssuePermission": "Editar tarefa", + "EditIssuePermissionDescription": "Permite editar uma tarefa individual", + "TransitionIssuePermission": "Alterar estado da tarefa", + "TransitionIssuePermissionDescription": "Permite alterar o estado de uma tarefa individual", + "DeleteIssuePermission": "Apagar tarefa", + "DeleteIssuePermissionDescription": "Permite apagar uma tarefa individual", "AllowCreatingIssues": "Permitir criar problemas", "Day": "Day", "Week": "Week", diff --git a/plugins/tracker-assets/lang/pt.json b/plugins/tracker-assets/lang/pt.json index 4e5ffcb65a1..5930e475d9e 100644 --- a/plugins/tracker-assets/lang/pt.json +++ b/plugins/tracker-assets/lang/pt.json @@ -276,6 +276,14 @@ "UnsetParentIssue": "Desmarcar problema pai", "ForbidCreateProjectPermission": "Proibir criação de projeto", "ForbidCreateProjectPermissionDescription": "Proíbe os usuários de criar novos projetos", + "CommentOnIssuePermission": "Comentar tarefa", + "CommentOnIssuePermissionDescription": "Permite comentar uma tarefa individual", + "EditIssuePermission": "Editar tarefa", + "EditIssuePermissionDescription": "Permite editar uma tarefa individual", + "TransitionIssuePermission": "Alterar estado da tarefa", + "TransitionIssuePermissionDescription": "Permite alterar o estado de uma tarefa individual", + "DeleteIssuePermission": "Apagar tarefa", + "DeleteIssuePermissionDescription": "Permite apagar uma tarefa individual", "AllowCreatingIssues": "Permitir criar problemas", "Day": "Day", "Week": "Week", diff --git a/plugins/tracker-assets/lang/ru.json b/plugins/tracker-assets/lang/ru.json index a3e3bacfa6d..9f88162b5fc 100644 --- a/plugins/tracker-assets/lang/ru.json +++ b/plugins/tracker-assets/lang/ru.json @@ -310,6 +310,14 @@ "UnsetParentIssue": "Снять родительскую задачу", "ForbidCreateProjectPermission": "Запретить создание проекта", "ForbidCreateProjectPermissionDescription": "Запрещает пользователям создавать новые проекты", + "CommentOnIssuePermission": "Комментирование задачи", + "CommentOnIssuePermissionDescription": "Разрешает комментировать отдельную задачу", + "EditIssuePermission": "Редактирование задачи", + "EditIssuePermissionDescription": "Разрешает редактировать отдельную задачу", + "TransitionIssuePermission": "Изменение статуса задачи", + "TransitionIssuePermissionDescription": "Разрешает изменять статус отдельной задачи", + "DeleteIssuePermission": "Удаление задачи", + "DeleteIssuePermissionDescription": "Разрешает удалять отдельную задачу", "Deadline": "Крайний срок", "BarLabelNone": "Нет", "BarLabelTitle": "Заголовок", diff --git a/plugins/tracker-assets/lang/tr.json b/plugins/tracker-assets/lang/tr.json index 34aa6ad5d5d..42b28f290c6 100644 --- a/plugins/tracker-assets/lang/tr.json +++ b/plugins/tracker-assets/lang/tr.json @@ -293,6 +293,14 @@ "UnsetParentIssue": "Üst sorunu kaldır", "ForbidCreateProjectPermission": "Proje oluşturmayı yasakla", "ForbidCreateProjectPermissionDescription": "Kullanıcıların yeni proje oluşturmasını yasaklar", + "CommentOnIssuePermission": "Soruna yorum yap", + "CommentOnIssuePermissionDescription": "Tek bir soruna yorum yapılmasına izin verir", + "EditIssuePermission": "Sorunu düzenle", + "EditIssuePermissionDescription": "Tek bir sorunun düzenlenmesine izin verir", + "TransitionIssuePermission": "Sorun durumunu değiştir", + "TransitionIssuePermissionDescription": "Tek bir sorunun durumunun değiştirilmesine izin verir", + "DeleteIssuePermission": "Sorunu sil", + "DeleteIssuePermissionDescription": "Tek bir sorunun silinmesine izin verir", "Deadline": "Son tarih", "BarLabelNone": "Yok", "BarLabelTitle": "Başlık", diff --git a/plugins/tracker-assets/lang/zh.json b/plugins/tracker-assets/lang/zh.json index 09b80894b23..99ebc65568a 100644 --- a/plugins/tracker-assets/lang/zh.json +++ b/plugins/tracker-assets/lang/zh.json @@ -293,6 +293,14 @@ "UnsetParentIssue": "取消父问题", "ForbidCreateProjectPermission": "禁止创建项目", "ForbidCreateProjectPermissionDescription": "禁止用户创建新项目", + "CommentOnIssuePermission": "评论问题", + "CommentOnIssuePermissionDescription": "允许评论单个问题", + "EditIssuePermission": "编辑问题", + "EditIssuePermissionDescription": "允许编辑单个问题", + "TransitionIssuePermission": "更改问题状态", + "TransitionIssuePermissionDescription": "允许更改单个问题的状态", + "DeleteIssuePermission": "删除问题", + "DeleteIssuePermissionDescription": "允许删除单个问题", "AllowCreatingIssues": "允许创建问题", "Day": "Day", "Week": "Week", diff --git a/plugins/tracker/src/index.ts b/plugins/tracker/src/index.ts index 6636bacb1f6..504fb1cc0bb 100644 --- a/plugins/tracker/src/index.ts +++ b/plugins/tracker/src/index.ts @@ -782,6 +782,14 @@ const pluginState = plugin(trackerId, { UnsetParentIssue: '' as IntlString, ForbidCreateProjectPermission: '' as IntlString, ForbidCreateProjectPermissionDescription: '' as IntlString, + CommentOnIssuePermission: '' as IntlString, + CommentOnIssuePermissionDescription: '' as IntlString, + EditIssuePermission: '' as IntlString, + EditIssuePermissionDescription: '' as IntlString, + TransitionIssuePermission: '' as IntlString, + TransitionIssuePermissionDescription: '' as IntlString, + DeleteIssuePermission: '' as IntlString, + DeleteIssuePermissionDescription: '' as IntlString, SchedulingMode: '' as IntlString, SchedulingModeAuto: '' as IntlString, SchedulingModeManual: '' as IntlString, @@ -821,7 +829,11 @@ const pluginState = plugin(trackerId, { SubIssue: '' as Ref }, permission: { - ForbidCreateProject: '' as Ref + ForbidCreateProject: '' as Ref, + CommentOnIssue: '' as Ref, + EditIssue: '' as Ref, + TransitionIssue: '' as Ref, + DeleteIssue: '' as Ref } }) export default pluginState From 7336c38b9a10a87299b3f15fe33653023a945560 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Thu, 1 Oct 2026 22:58:12 +0000 Subject: [PATCH 3/3] test(middleware): space-permissions regression - object-scoped permissions without txClass never match Evaluate SpacePermissionsMiddleware over a matrix of TxCreateDoc, TxUpdateDoc, TxRemoveDoc and TxMixin (non-empty attributes) for a user without a space role and a user whose role references an object-scoped permission, in restricted and unrestricted spaces. The decisions against a model with the object-scoped declarations (scope 'object', no txClass/txMatch/forbid) must equal the decisions without them, and the baseline is pinned to the current behaviour. Covers both the restricted-space fallback and the role path, including the TxMixin branch of isTxClassMatched. Negative control: the same declarations with txClass TxCreateDoc must make the matrix diverge (restricted space, user without a role: create flips from allow to deny), so the comparison demonstrably detects a behaviour-changing declaration. Signed-off-by: Michael Uray --- .../src/tests/spacePermissions.test.ts | 353 ++++++++++++++++++ 1 file changed, 353 insertions(+) create mode 100644 foundations/server/packages/middleware/src/tests/spacePermissions.test.ts diff --git a/foundations/server/packages/middleware/src/tests/spacePermissions.test.ts b/foundations/server/packages/middleware/src/tests/spacePermissions.test.ts new file mode 100644 index 00000000000..f27d8978043 --- /dev/null +++ b/foundations/server/packages/middleware/src/tests/spacePermissions.test.ts @@ -0,0 +1,353 @@ +// +// Copyright © 2026 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. +// + +/** + * Regression tests for SpacePermissionsMiddleware and object-scoped permissions. + * + * Object-scoped permissions (`scope: 'object'`) are declared without `txClass`/`txMatch`/`forbid` + * (see models/tracker/src/permissions.ts). This suite proves that adding such declarations to the + * model does not change any allow/deny decision of the space permission check: + * - restricted-space fallback (user without a role): a Permission without txClass never matches, + * including the TxMixin branch of isTxClassMatched; + * - role path (user whose space role references an object-scoped permission): the permission + * never matches, so the decision falls through exactly as before. + * + * The matrix (4 tx kinds x 2 users x 2 space variants) is evaluated against a model without and with + * the object-scoped declarations; results must be identical and equal to the expected develop baseline. + * + * Negative control: the same declarations with a behaviour-changing `txClass` (TxCreateDoc) must make the + * matrix diverge, so the comparison demonstrably detects a change in the declarations. + */ + +import core, { + type Account, + type AccountUuid, + type Class, + ClassifierKind, + type Doc, + DOMAIN_MODEL, + generateId, + Hierarchy, + MeasureMetricsContext, + type MeasureContext, + type Mixin, + ModelDb, + type Obj, + type Permission, + type PersonId, + type Ref, + type Role, + type SessionData, + type Space, + type SpaceType, + type Tx, + TxFactory, + type TypedSpace +} from '@hcengineering/core' +import platform, { type IntlString, PlatformError } from '@hcengineering/platform' +import type { PipelineContext } from '@hcengineering/server-core' +import { SpacePermissionsMiddleware } from '../spacePermissions' + +// Test model mirroring the tracker shapes (the middleware package does not depend on tracker). +const ISSUE = 'test:class:Issue' as Ref> +const ISSUE_MIXIN = 'test:mixin:IssueExt' as Ref> +const PROJECT = 'test:class:Project' as Ref> +const PROJECT_TYPE_MIXIN = 'test:mixin:ProjectTypeData' as Ref> +const SPACE_TYPE = 'test:spaceType:Project' as Ref +const ROLE = 'test:role:Member' as Ref + +// Baseline (develop) space permissions on issues. +const PERM_UPDATE = 'test:permission:UpdateIssue' as Ref +const PERM_REMOVE = 'test:permission:RemoveIssue' as Ref + +// Object-scoped declarations, same shape as tracker.permission.* in models/tracker/src/permissions.ts. +const OBJECT_PERMISSIONS: Array> = [ + 'tracker:permission:CommentOnIssue' as Ref, + 'tracker:permission:EditIssue' as Ref, + 'tracker:permission:TransitionIssue' as Ref, + 'tracker:permission:DeleteIssue' as Ref +] +const EDIT_ISSUE = OBJECT_PERMISSIONS[1] + +const RESTRICTED_SPACE = 'test:space:Restricted' as Ref +const OPEN_SPACE = 'test:space:Open' as Ref + +const USER_WITHOUT_ROLE = 'user-without-role' as AccountUuid +const USER_WITH_ROLE = 'user-with-role' as AccountUuid + +const txFactory = new TxFactory(core.account.System) +const userTxFactory = new TxFactory('test:social:user' as PersonId) + +type TxKind = 'TxCreateDoc' | 'TxUpdateDoc' | 'TxRemoveDoc' | 'TxMixin' +const TX_KINDS: TxKind[] = ['TxCreateDoc', 'TxUpdateDoc', 'TxRemoveDoc', 'TxMixin'] +type Decision = 'allow' | 'deny' + +function createClassTx ( + _id: Ref>, + _extends: Ref> | undefined, + kind: ClassifierKind = ClassifierKind.CLASS +): Tx { + return txFactory.createTxCreateDoc( + core.class.Class, + core.space.Model, + { label: _id as unknown as IntlString, extends: _extends, kind, domain: DOMAIN_MODEL } as any, + _id + ) +} + +function createModelDocTx (_class: Ref>, _id: Ref, attributes: Record): Tx { + return txFactory.createTxCreateDoc(_class, core.space.Model, attributes as any, _id) +} + +function buildModel ( + withObjectPermissions: boolean, + objectPermissionTxClass?: Ref> +): { hierarchy: Hierarchy, modelDb: ModelDb } { + const txes: Tx[] = [ + createClassTx(core.class.Obj, undefined), + createClassTx(core.class.Doc, core.class.Obj), + createClassTx(core.class.Class, core.class.Doc), + createClassTx(core.class.Mixin, core.class.Class), + createClassTx(core.class.AttachedDoc, core.class.Doc), + createClassTx(core.class.Space, core.class.Doc), + createClassTx(core.class.TypedSpace, core.class.Space), + createClassTx(core.class.SpaceType, core.class.Doc), + createClassTx(core.class.Role, core.class.AttachedDoc), + createClassTx(core.class.Permission, core.class.Doc), + createClassTx(PROJECT, core.class.TypedSpace), + createClassTx(PROJECT_TYPE_MIXIN, PROJECT, ClassifierKind.MIXIN), + createClassTx(ISSUE, core.class.AttachedDoc), + createClassTx(ISSUE_MIXIN, ISSUE, ClassifierKind.MIXIN), + createModelDocTx(core.class.SpaceType, SPACE_TYPE, { + name: 'Project type', + descriptor: 'test:descriptor:Project', + targetClass: PROJECT_TYPE_MIXIN, + roles: 1 + }), + createModelDocTx(core.class.Role, ROLE, { + attachedTo: SPACE_TYPE, + attachedToClass: core.class.SpaceType, + collection: 'roles', + name: 'Member', + // The role references an object-scoped permission; without the declarations the ref dangles. + permissions: [PERM_UPDATE, EDIT_ISSUE] + }), + createModelDocTx(core.class.Permission, PERM_UPDATE, { + label: 'UpdateIssue', + txClass: core.class.TxUpdateDoc, + objectClass: ISSUE, + scope: 'space' + }), + createModelDocTx(core.class.Permission, PERM_REMOVE, { + label: 'RemoveIssue', + txClass: core.class.TxRemoveDoc, + objectClass: ISSUE, + scope: 'space' + }) + ] + + if (withObjectPermissions) { + for (const id of OBJECT_PERMISSIONS) { + txes.push( + createModelDocTx(core.class.Permission, id, { + label: `${id}Label`, + description: `${id}Description`, + scope: 'object', + objectClass: ISSUE, + // Only set by the negative control below; the real declarations have no txClass. + ...(objectPermissionTxClass !== undefined ? { txClass: objectPermissionTxClass } : {}) + }) + ) + } + } + + const hierarchy = new Hierarchy() + for (const tx of txes) { + hierarchy.tx(tx) + } + const modelDb = new ModelDb(hierarchy) + modelDb.addTxes(new MeasureMetricsContext('test', {}), txes, false) + return { hierarchy, modelDb } +} + +function makeSpace (_id: Ref, restricted: boolean): TypedSpace { + return { + _id, + _class: PROJECT, + space: core.space.Space, + modifiedOn: 0, + modifiedBy: core.account.System, + name: _id, + description: '', + private: false, + archived: false, + members: [USER_WITHOUT_ROLE, USER_WITH_ROLE], + type: SPACE_TYPE, + restricted, + [PROJECT_TYPE_MIXIN]: { [ROLE]: [USER_WITH_ROLE] } + } as any +} + +function makeMiddleware ( + withObjectPermissions: boolean, + objectPermissionTxClass?: Ref>, + nextTx: () => Promise = async () => ({}) +): SpacePermissionsMiddleware { + const { hierarchy, modelDb } = buildModel(withObjectPermissions, objectPermissionTxClass) + const spaces = [makeSpace(RESTRICTED_SPACE, true), makeSpace(OPEN_SPACE, false)] + const context: PipelineContext = { + workspace: { uuid: 'test-workspace' as any, url: 'test', dataId: 'test' as any }, + hierarchy, + modelDb, + branding: null, + adapterManager: {} as any, + storageAdapter: {} as any, + contextVars: {}, + lastTx: '', + lastHash: '', + broadcastEvent: async () => {} + } as any + const next: any = { + findAll: async (_ctx: MeasureContext, _class: Ref>) => (_class === core.class.Space ? spaces : []), + tx: nextTx + } + return new (SpacePermissionsMiddleware as any)(context, next) +} + +function makeCtx (uuid: AccountUuid): MeasureContext { + const account: Account = { + uuid, + role: 'USER' as any, + primarySocialId: 'test:social:user' as PersonId, + socialIds: ['test:social:user' as PersonId], + fullSocialIds: [] + } + const ctx = new MeasureMetricsContext('test', {}) as MeasureContext + ctx.contextData = { account, broadcast: { txes: [], queue: [], sessions: {} } } as any + return ctx +} + +function makeTx (kind: TxKind, space: Ref): Tx { + const issueId = generateId() + switch (kind) { + case 'TxCreateDoc': + return userTxFactory.createTxCreateDoc(ISSUE, space, { title: 'new' } as any, issueId) + case 'TxUpdateDoc': + return userTxFactory.createTxUpdateDoc(ISSUE, space, issueId, { title: 'changed' } as any) + case 'TxRemoveDoc': + return userTxFactory.createTxRemoveDoc(ISSUE, space, issueId) + case 'TxMixin': + // Non-empty attributes: hits the isMixinUpdateTx branch of isTxClassMatched. + return userTxFactory.createTxMixin(issueId, ISSUE, space, ISSUE_MIXIN, { estimation: 1 } as any) + } +} + +async function decide ( + mw: SpacePermissionsMiddleware, + user: AccountUuid, + space: Ref, + kind: TxKind +): Promise { + try { + await mw.tx(makeCtx(user), [makeTx(kind, space)]) + return 'allow' + } catch (err: any) { + // Only a real Forbidden counts as deny; anything else is a broken setup and must fail the test. + if (err instanceof PlatformError && err.status.code === platform.status.Forbidden) { + return 'deny' + } + throw err + } +} + +async function evaluateMatrix ( + withObjectPermissions: boolean, + objectPermissionTxClass?: Ref> +): Promise> { + const mw = makeMiddleware(withObjectPermissions, objectPermissionTxClass) + const result: Record = {} + for (const space of [RESTRICTED_SPACE, OPEN_SPACE]) { + for (const user of [USER_WITHOUT_ROLE, USER_WITH_ROLE]) { + for (const kind of TX_KINDS) { + result[`${space} | ${user} | ${kind}`] = await decide(mw, user, space, kind) + } + } + } + return result +} + +// Decisions on develop (no object-scoped declarations in the model). +const EXPECTED_BASELINE: Record = { + [`${RESTRICTED_SPACE} | ${USER_WITHOUT_ROLE} | TxCreateDoc`]: 'allow', + [`${RESTRICTED_SPACE} | ${USER_WITHOUT_ROLE} | TxUpdateDoc`]: 'deny', + [`${RESTRICTED_SPACE} | ${USER_WITHOUT_ROLE} | TxRemoveDoc`]: 'deny', + [`${RESTRICTED_SPACE} | ${USER_WITHOUT_ROLE} | TxMixin`]: 'deny', + [`${RESTRICTED_SPACE} | ${USER_WITH_ROLE} | TxCreateDoc`]: 'allow', + [`${RESTRICTED_SPACE} | ${USER_WITH_ROLE} | TxUpdateDoc`]: 'allow', + [`${RESTRICTED_SPACE} | ${USER_WITH_ROLE} | TxRemoveDoc`]: 'deny', + [`${RESTRICTED_SPACE} | ${USER_WITH_ROLE} | TxMixin`]: 'allow', + [`${OPEN_SPACE} | ${USER_WITHOUT_ROLE} | TxCreateDoc`]: 'allow', + [`${OPEN_SPACE} | ${USER_WITHOUT_ROLE} | TxUpdateDoc`]: 'allow', + [`${OPEN_SPACE} | ${USER_WITHOUT_ROLE} | TxRemoveDoc`]: 'allow', + [`${OPEN_SPACE} | ${USER_WITHOUT_ROLE} | TxMixin`]: 'allow', + [`${OPEN_SPACE} | ${USER_WITH_ROLE} | TxCreateDoc`]: 'allow', + [`${OPEN_SPACE} | ${USER_WITH_ROLE} | TxUpdateDoc`]: 'allow', + [`${OPEN_SPACE} | ${USER_WITH_ROLE} | TxRemoveDoc`]: 'allow', + [`${OPEN_SPACE} | ${USER_WITH_ROLE} | TxMixin`]: 'allow' +} + +describe('SpacePermissionsMiddleware - object-scoped permissions without txClass', () => { + it('model fixture: object-scoped declarations are present only in the "with" model', () => { + const without = buildModel(false).modelDb.findAllSync(core.class.Permission, { scope: 'object' }) + const withDecl = buildModel(true).modelDb.findAllSync(core.class.Permission, { scope: 'object' }) + expect(without).toHaveLength(0) + expect(withDecl.map((p) => p._id).sort()).toEqual([...OBJECT_PERMISSIONS].sort()) + for (const p of withDecl) { + expect(p.txClass).toBeUndefined() + expect(p.txMatch).toBeUndefined() + expect(p.forbid).toBeUndefined() + } + }) + + it('decide() rethrows errors other than Forbidden instead of counting them as deny', async () => { + const broken = makeMiddleware(false, undefined, async () => { + throw new TypeError('broken setup') + }) + await expect(decide(broken, USER_WITH_ROLE, OPEN_SPACE, 'TxCreateDoc')).rejects.toThrow(TypeError) + }) + + it('baseline decisions without object-scoped declarations match develop behaviour', async () => { + expect(await evaluateMatrix(false)).toEqual(EXPECTED_BASELINE) + }) + + it('decisions are identical after adding object-scoped declarations', async () => { + const before = await evaluateMatrix(false) + const after = await evaluateMatrix(true) + expect(Object.keys(after)).toHaveLength(TX_KINDS.length * 2 * 2) + expect(after).toEqual(before) + }) + + it('negative control: the same declarations with txClass TxCreateDoc change the decisions', async () => { + const before = await evaluateMatrix(false) + const mutated = await evaluateMatrix(true, core.class.TxCreateDoc) + expect(mutated).not.toEqual(before) + + // In a restricted space, a user without a role now hits the fallback restriction on create. + const changed = Object.keys(before).filter((key) => before[key] !== mutated[key]) + expect(changed).toEqual([`${RESTRICTED_SPACE} | ${USER_WITHOUT_ROLE} | TxCreateDoc`]) + expect(before[changed[0]]).toBe('allow') + expect(mutated[changed[0]]).toBe('deny') + }) +})