diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 57a469ad1fa..2f31a74ef08 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -2039,6 +2039,12 @@ importers: '@hcengineering/s3': specifier: workspace:^0.7.18 version: link:../../foundations/server/packages/s3 + '@hcengineering/server-access-group': + specifier: workspace:^0.7.0 + version: link:../../server-plugins/access-group + '@hcengineering/server-access-group-resources': + specifier: workspace:^0.7.0 + version: link:../../server-plugins/access-group-resources '@hcengineering/server-activity': specifier: workspace:^0.7.0 version: link:../../server-plugins/activity @@ -6076,6 +6082,9 @@ importers: '@hcengineering/model-request': specifier: workspace:^0.7.0 version: link:../request + '@hcengineering/model-server-access-group': + specifier: workspace:^0.7.0 + version: link:../server-access-group '@hcengineering/model-server-activity': specifier: workspace:^0.7.0 version: link:../server-activity @@ -10107,6 +10116,67 @@ importers: specifier: ^5.9.3 version: 5.9.3 + ../../models/server-access-group: + dependencies: + '@hcengineering/core': + specifier: workspace:^0.7.26 + version: link:../../foundations/core/packages/core + '@hcengineering/model': + specifier: workspace:^0.7.17 + version: link:../../foundations/core/packages/model + '@hcengineering/platform': + specifier: workspace:^0.7.20 + version: link:../../foundations/core/packages/platform + '@hcengineering/server-access-group': + specifier: workspace:^0.7.0 + version: link:../../server-plugins/access-group + '@hcengineering/server-core': + specifier: workspace:^0.7.19 + version: link:../../foundations/server/packages/core + devDependencies: + '@hcengineering/platform-rig': + specifier: workspace:^0.7.21 + version: link:../../foundations/utils/packages/platform-rig + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.18.1 + version: 22.19.0 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + eslint: + specifier: ^8.54.0 + version: 8.57.1 + eslint-config-standard-with-typescript: + specifier: ^40.0.0 + version: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3) + eslint-plugin-import: + specifier: ^2.26.0 + version: 2.32.0(eslint@8.57.1) + eslint-plugin-n: + specifier: ^15.4.0 + version: 15.7.0(eslint@8.57.1) + eslint-plugin-promise: + specifier: ^6.1.1 + version: 6.6.0(eslint@8.57.1) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.19.0)(ts-node@10.9.2(@types/node@22.19.0)(typescript@5.9.3)) + prettier: + specifier: ^3.6.2 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@30.2.0)(babel-jest@29.7.0(@babel/core@7.28.5))(esbuild@0.25.12)(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.0))(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + ../../models/server-activity: dependencies: '@hcengineering/activity': @@ -18056,6 +18126,9 @@ importers: '@hcengineering/text': specifier: workspace:^0.7.19 version: link:../../foundations/core/packages/text + '@hcengineering/text-core': + specifier: workspace:^0.7.19 + version: link:../../foundations/core/packages/text-core '@hcengineering/text-editor': specifier: workspace:^0.7.0 version: link:../text-editor @@ -31649,6 +31722,116 @@ importers: specifier: ^5.9.3 version: 5.9.3 + ../../server-plugins/access-group: + dependencies: + '@hcengineering/core': + specifier: workspace:^0.7.26 + version: link:../../foundations/core/packages/core + '@hcengineering/platform': + specifier: workspace:^0.7.20 + version: link:../../foundations/core/packages/platform + '@hcengineering/server-core': + specifier: workspace:^0.7.19 + version: link:../../foundations/server/packages/core + devDependencies: + '@hcengineering/platform-rig': + specifier: workspace:^0.7.21 + version: link:../../foundations/utils/packages/platform-rig + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.18.1 + version: 22.19.0 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + eslint: + specifier: ^8.54.0 + version: 8.57.1 + eslint-config-standard-with-typescript: + specifier: ^40.0.0 + version: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3) + eslint-plugin-import: + specifier: ^2.26.0 + version: 2.32.0(eslint@8.57.1) + eslint-plugin-n: + specifier: ^15.4.0 + version: 15.7.0(eslint@8.57.1) + eslint-plugin-promise: + specifier: ^6.1.1 + version: 6.6.0(eslint@8.57.1) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.19.0)(ts-node@10.9.2(@types/node@22.19.0)(typescript@5.9.3)) + prettier: + specifier: ^3.6.2 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@30.2.0)(babel-jest@29.7.0(@babel/core@7.28.5))(esbuild@0.25.12)(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.0))(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + ../../server-plugins/access-group-resources: + dependencies: + '@hcengineering/core': + specifier: workspace:^0.7.26 + version: link:../../foundations/core/packages/core + '@hcengineering/platform': + specifier: workspace:^0.7.20 + version: link:../../foundations/core/packages/platform + '@hcengineering/server-access-group': + specifier: workspace:^0.7.0 + version: link:../access-group + '@hcengineering/server-core': + specifier: workspace:^0.7.19 + version: link:../../foundations/server/packages/core + devDependencies: + '@hcengineering/platform-rig': + specifier: workspace:^0.7.21 + version: link:../../foundations/utils/packages/platform-rig + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + eslint: + specifier: ^8.54.0 + version: 8.57.1 + eslint-config-standard-with-typescript: + specifier: ^40.0.0 + version: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3) + eslint-plugin-import: + specifier: ^2.26.0 + version: 2.32.0(eslint@8.57.1) + eslint-plugin-n: + specifier: ^15.4.0 + version: 15.7.0(eslint@8.57.1) + eslint-plugin-promise: + specifier: ^6.1.1 + version: 6.6.0(eslint@8.57.1) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.19.0)(ts-node@10.9.2(@types/node@22.19.0)(typescript@5.9.3)) + prettier: + specifier: ^3.6.2 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@30.2.0)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@29.7.0)(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + ../../server-plugins/activity: dependencies: '@hcengineering/core': @@ -36752,6 +36935,12 @@ importers: '@hcengineering/server': specifier: workspace:^0.7.19 version: link:../../foundations/server/packages/server + '@hcengineering/server-access-group': + specifier: workspace:^0.7.0 + version: link:../../server-plugins/access-group + '@hcengineering/server-access-group-resources': + specifier: workspace:^0.7.0 + version: link:../../server-plugins/access-group-resources '@hcengineering/server-activity': specifier: workspace:^0.7.0 version: link:../../server-plugins/activity @@ -61713,7 +61902,7 @@ snapshots: node-loader@2.0.0(webpack@5.102.1): dependencies: loader-utils: 2.0.4 - webpack: 5.102.1 + webpack: 5.102.1(esbuild@0.25.12)(webpack-cli@5.1.4) node-localstorage@2.2.1: dependencies: diff --git a/dev/tool/package.json b/dev/tool/package.json index a09ae9febf4..27192c337d2 100644 --- a/dev/tool/package.json +++ b/dev/tool/package.json @@ -114,6 +114,8 @@ "@hcengineering/server-card-resources": "workspace:^0.7.0", "@hcengineering/server-chunter": "workspace:^0.7.0", "@hcengineering/server-chunter-resources": "workspace:^0.7.0", + "@hcengineering/server-access-group": "workspace:^0.7.0", + "@hcengineering/server-access-group-resources": "workspace:^0.7.0", "@hcengineering/server-contact": "workspace:^0.7.0", "@hcengineering/server-contact-resources": "workspace:^0.7.0", "@hcengineering/server-core": "workspace:^0.7.19", diff --git a/dev/tool/src/__start.ts b/dev/tool/src/__start.ts index ef9e25bb722..6f85a85908d 100644 --- a/dev/tool/src/__start.ts +++ b/dev/tool/src/__start.ts @@ -25,6 +25,7 @@ import { serverActivityId } from '@hcengineering/server-activity' import { serverAiBotId } from '@hcengineering/server-ai-bot' import { serverAttachmentId } from '@hcengineering/server-attachment' import { serverCalendarId } from '@hcengineering/server-calendar' +import { serverAccessGroupId } from '@hcengineering/server-access-group' import { serverCardId } from '@hcengineering/server-card' import { serverChunterId } from '@hcengineering/server-chunter' import { serverCollaborationId } from '@hcengineering/server-collaboration' @@ -53,6 +54,7 @@ addLocation(serverCollaborationId, () => import('@hcengineering/server-collabora addLocation(serverContactId, () => import('@hcengineering/server-contact-resources')) addLocation(serverNotificationId, () => import('@hcengineering/server-notification-resources')) addLocation(serverChunterId, () => import('@hcengineering/server-chunter-resources')) +addLocation(serverAccessGroupId, () => import('@hcengineering/server-access-group-resources')) addLocation(serverInventoryId, () => import('@hcengineering/server-inventory-resources')) addLocation(serverLeadId, () => import('@hcengineering/server-lead-resources')) addLocation(serverRecruitId, () => import('@hcengineering/server-recruit-resources')) diff --git a/foundations/core/packages/core/src/__tests__/accessLevel.test.ts b/foundations/core/packages/core/src/__tests__/accessLevel.test.ts new file mode 100644 index 00000000000..b259adc4e72 --- /dev/null +++ b/foundations/core/packages/core/src/__tests__/accessLevel.test.ts @@ -0,0 +1,60 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { accessLevelRank, hasAtLeast } from '../accessLevel' + +describe('accessLevel helper', () => { + describe('accessLevelRank', () => { + it('ranks read < write < admin', () => { + expect(accessLevelRank('read')).toBe(1) + expect(accessLevelRank('write')).toBe(2) + expect(accessLevelRank('admin')).toBe(3) + }) + + it('treats undefined as read (rank 1)', () => { + expect(accessLevelRank(undefined)).toBe(1) + }) + }) + + describe('hasAtLeast', () => { + it('read does not satisfy write', () => { + expect(hasAtLeast('read', 'write')).toBe(false) + }) + + it('write satisfies write', () => { + expect(hasAtLeast('write', 'write')).toBe(true) + }) + + it('admin satisfies write', () => { + expect(hasAtLeast('admin', 'write')).toBe(true) + }) + + it('undefined satisfies read (minimal)', () => { + expect(hasAtLeast(undefined, 'read')).toBe(true) + }) + + it('undefined does not satisfy write', () => { + expect(hasAtLeast(undefined, 'write')).toBe(false) + }) + + it('admin satisfies admin', () => { + expect(hasAtLeast('admin', 'admin')).toBe(true) + }) + + it('write does not satisfy admin', () => { + expect(hasAtLeast('write', 'admin')).toBe(false) + }) + }) +}) diff --git a/foundations/core/packages/core/src/accessLevel.ts b/foundations/core/packages/core/src/accessLevel.ts new file mode 100644 index 00000000000..af6b7e9c172 --- /dev/null +++ b/foundations/core/packages/core/src/accessLevel.ts @@ -0,0 +1,50 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { AccessLevel } from './classes' + +/** + * Ordinal rank of an access level: read < write < admin. + * + * `undefined` (missing level at a grant record) is treated as `read` — the + * fail-safe minimal grant. This is the single place that maps the string-enum + * to a comparable number, which is the evolution seam described in the design: + * when `level` later becomes a `Ref`, only this helper (and hasAtLeast) + * changes — every enforcement call-site keeps comparing via these helpers, not + * via string equality. + * + * @public + */ +export function accessLevelRank (level?: AccessLevel): number { + switch (level) { + case 'admin': + return 3 + case 'write': + return 2 + default: + // 'read' | undefined | any unexpected value → minimal + return 1 + } +} + +/** + * True iff `level` grants at least the authority of `min` (ordinal compare). + * Higher levels include lower ones (admin ⊇ write ⊇ read). + * + * @public + */ +export function hasAtLeast (level: AccessLevel | undefined, min: AccessLevel): boolean { + return accessLevelRank(level) >= accessLevelRank(min) +} diff --git a/foundations/core/packages/core/src/classes.ts b/foundations/core/packages/core/src/classes.ts index b9c62466d90..94e867163f5 100644 --- a/foundations/core/packages/core/src/classes.ts +++ b/foundations/core/packages/core/src/classes.ts @@ -468,6 +468,14 @@ export const DOMAIN_RELATION = 'relation' as Domain */ export const DOMAIN_COLLABORATOR = 'collaborator' as Domain +/** + * Domain for AccessGroup and GroupGrant documents. Not the collaborator table + * (whose notNull columns attachedTo/collaborator do not apply here) — falls + * back to the postgres defaultSchema. + * @public + */ +export const DOMAIN_ACCESS_GROUP = 'access_group' as Domain + /** * @public */ @@ -1010,12 +1018,78 @@ export interface ClassCollaborators extends Doc { attachedTo: Ref> allFields?: boolean // for all (PersonId | Ref | PersonId[] | Ref[]) attributes fields: (keyof T)[] // PersonId | Ref | PersonId[] | Ref[] - provideSecurity?: boolean // If true, will provide security for collaborators + // If true, Collaborator status grants read visibility on the doc, + // bypassing space-membership. Writes are governed by the class's + // TxAccessLevel and any pre-commit middleware (see GuestPermissions). + provideSecurity?: boolean provideAttachedSecurity?: boolean // If true, will provide security for collaborators of attached doc + // If true, @-mentions in chat/activity messages on this doc auto-create + // Collaborator records (mention = explicit, disclosed grant). Has no + // effect unless provideSecurity is also true. + mentionsGrantAccess?: boolean } +/** + * Provenance of a Collaborator grant. Distinguishes explicit, revocable + * access-grants from structural (fields-derived / notification / legacy) + * collaborators. `undefined` === structural collaborator — such records are + * NEVER touched by revocation logic. + * @public + */ +export type CollaboratorProvenance = 'mention' | 'manual' | 'group' + +/** + * Ordinal access-level of a grant: read < write < admin. + * v1 as string-enum; designed to be evolvable to a `Ref` of a per-issue + * role definition without a schema break — consumers compare via the + * accessLevel.ts helper (accessLevelRank / hasAtLeast), never by string + * equality, so the later switch stays a helper-only change. + * @public + */ +export type AccessLevel = 'read' | 'write' | 'admin' + export interface Collaborator extends AttachedDoc { collaborator: AccountUuid + // Provenance of the grant. undefined === structural collaborator + // (fields-derived / notification / legacy) — never removed automatically. + grantedVia?: CollaboratorProvenance + grantedBy?: AccountUuid // actor account that triggered the grant + grantedByMessage?: Ref // only when grantedVia === 'mention' + // only when grantedVia === 'group'; references the GroupGrant (not the group) + // so two grants of the same group on the same doc stay distinguishable. + grantedByGroup?: Ref + // Access level of this grant. undefined at structural records === no explicit + // grant (visibility comes from space membership). At grantedVia records: + // missing ⇒ interpreted as 'read' (fail-safe minimal). + level?: AccessLevel +} + +/** + * A workspace-wide named group of accounts. Not a Space and not a security + * carrier by itself — it only becomes an access-grant once a GroupGrant + * references it on a secured doc, at which point a server trigger materializes + * one Collaborator per member (design section 2.2 / 2.8, G2 = materialized). + * @public + */ +export interface AccessGroup extends Doc { + name: string + description?: string + members: AccountUuid[] + owners: AccountUuid[] // may edit the group (membership / name) +} + +/** + * A group access-grant attached to a secured doc (e.g. an Issue). The reconcile + * trigger materializes one `grantedVia: 'group'` Collaborator per group member, + * copying `level` onto each derived record. Unlike Collaborator records, + * `GroupGrant.level` is mutable (guarded) — the trigger propagates the new + * level to the derived collaborators. + * @public + */ +export interface GroupGrant extends AttachedDoc { + group: Ref + grantedBy: AccountUuid + level?: AccessLevel // default 'read' } /** diff --git a/foundations/core/packages/core/src/collaborators.ts b/foundations/core/packages/core/src/collaborators.ts index 1574a2c1401..7df032a9b63 100644 --- a/foundations/core/packages/core/src/collaborators.ts +++ b/foundations/core/packages/core/src/collaborators.ts @@ -13,7 +13,7 @@ // limitations under the License. // -import core, { Class, ClassCollaborators, Doc, Hierarchy, ModelDb, Ref } from '.' +import core, { AttachedDoc, Class, ClassCollaborators, Doc, DocumentQuery, Hierarchy, ModelDb, Ref } from '.' export function getClassCollaborators ( model: ModelDb, @@ -35,3 +35,51 @@ export function getClassCollaborators ( } } } + +/** + * Walk a Doc's attachedTo chain to find the nearest ancestor (including the + * Doc itself) whose ClassCollaborators has BOTH provideSecurity===true AND + * mentionsGrantAccess===true. Returns that ancestor Doc as the grant target, + * or null if no such class is reached within the depth cap. + * + * Used by both the chunter mention-trigger (server) and the warning popup + * (client) so the disclosure UX matches the actual server-side grant. The + * helper is isomorphic via the findAll dependency injection — server passes + * `(cls, q) => control.findAll(control.ctx, cls, q)`, client passes + * `(cls, q) => getClient().findAll(cls, q)`. + * + * The ClassCollaborators lookup is exact-class (not inherited via ancestors) + * — adequate for tracker.class.Issue and avoids surprising matches on + * abstract base classes like AttachedDoc. If future opt-in classes need + * inherited semantics, switch to `getClassCollaborators(model, hierarchy, _class)` + * here (requires plumbing ModelDb + Hierarchy through the dependency + * injection — kept out for now to keep the helper isomorphic without + * the ModelDb tax on the client). + * + * Depth cap (8) defends against pathological attachedTo cycles. + */ +export async function resolveMentionGrantTarget ( + start: Doc, + findAll: (cls: Ref>, q: DocumentQuery) => Promise +): Promise { + let cur: Doc | undefined = start + for (let i = 0; i < 8 && cur != null; i++) { + const ccQuery: DocumentQuery> = { + attachedTo: cur._class + } + const cc = (await findAll(core.class.ClassCollaborators, ccQuery))[0] + if (cc?.provideSecurity === true && cc.mentionsGrantAccess === true) { + return cur + } + const attached = cur as AttachedDoc + if (attached.attachedTo == null || attached.attachedToClass == null) { + return null + } + const parentQuery: DocumentQuery = { + _id: attached.attachedTo + } + const parent = (await findAll(attached.attachedToClass, parentQuery))[0] + cur = parent + } + return null +} diff --git a/foundations/core/packages/core/src/component.ts b/foundations/core/packages/core/src/component.ts index 0fd395cf6e1..db04fd7b8ee 100644 --- a/foundations/core/packages/core/src/component.ts +++ b/foundations/core/packages/core/src/component.ts @@ -17,6 +17,7 @@ import { plugin } from '@hcengineering/platform' import type { BenchmarkDoc } from './benchmark' import type { Account, + AccessGroup, AccountUuid, AnyAttribute, ArrOf, @@ -28,6 +29,7 @@ import type { ClassCollaborators, ClassPermission, Collaborator, + GroupGrant, Collection, Configuration, ConfigurationElement, @@ -182,6 +184,8 @@ export default plugin(coreId, { CustomSequence: '' as Ref>, ClassCollaborators: '' as Ref>>, Collaborator: '' as Ref>, + AccessGroup: '' as Ref>, + GroupGrant: '' as Ref>, ModulePermissionGroup: '' as Ref> }, icon: { diff --git a/foundations/core/packages/core/src/index.ts b/foundations/core/packages/core/src/index.ts index ddafa5c49d5..4439178ca3a 100644 --- a/foundations/core/packages/core/src/index.ts +++ b/foundations/core/packages/core/src/index.ts @@ -15,6 +15,7 @@ import core from './component' export * from './classes' +export * from './accessLevel' export * from './autoJoinRoles' export * from './client' export * from './collaboration' diff --git a/foundations/core/packages/text-core/src/markup/__tests__/reference.test.ts b/foundations/core/packages/text-core/src/markup/__tests__/reference.test.ts new file mode 100644 index 00000000000..167ac76e1cf --- /dev/null +++ b/foundations/core/packages/text-core/src/markup/__tests__/reference.test.ts @@ -0,0 +1,74 @@ +import { extractReferences } from '../reference' +import { MarkupNodeType, type MarkupNode } from '../model' + +function refNode (id: string, label: string, objectclass: string, grantsAccess?: 'true' | 'false'): MarkupNode { + return { + type: MarkupNodeType.reference, + attrs: { id, label, objectclass, ...(grantsAccess !== undefined ? { grantsAccess } : {}) } + } +} + +function doc (...children: MarkupNode[]): MarkupNode { + return { type: MarkupNodeType.doc, content: children } +} + +describe('extractReferences grantsAccess', () => { + test('surfaces grantsAccess from a reference node', () => { + const refs = extractReferences(doc(refNode('p1', 'Alice', 'contact:class:Person', 'true'))) + expect(refs).toHaveLength(1) + expect(refs[0].objectId).toBe('p1') + expect(refs[0].grantsAccess).toBe('true') + }) + + test('grantsAccess undefined when attr absent (default = grant)', () => { + const refs = extractReferences(doc(refNode('p1', 'Alice', 'contact:class:Person'))) + expect(refs[0].grantsAccess).toBeUndefined() + }) + + test('any-wins: false then true => true', () => { + const refs = extractReferences( + doc( + refNode('p1', 'Alice', 'contact:class:Person', 'false'), + refNode('p1', 'Alice', 'contact:class:Person', 'true') + ) + ) + expect(refs).toHaveLength(1) + expect(refs[0].grantsAccess).toBe('true') + }) + + test('any-wins: true then false => true', () => { + const refs = extractReferences( + doc( + refNode('p1', 'Alice', 'contact:class:Person', 'true'), + refNode('p1', 'Alice', 'contact:class:Person', 'false') + ) + ) + expect(refs[0].grantsAccess).toBe('true') + }) + + test('any-wins: false then undefined => not false (undefined grants)', () => { + const refs = extractReferences( + doc(refNode('p1', 'Alice', 'contact:class:Person', 'false'), refNode('p1', 'Alice', 'contact:class:Person')) + ) + expect(refs[0].grantsAccess).not.toBe('false') + }) + + test('all explicitly false => false', () => { + const refs = extractReferences( + doc( + refNode('p1', 'Alice', 'contact:class:Person', 'false'), + refNode('p1', 'Alice', 'contact:class:Person', 'false') + ) + ) + expect(refs[0].grantsAccess).toBe('false') + }) + + test('different persons kept separate', () => { + const refs = extractReferences( + doc(refNode('p1', 'Alice', 'contact:class:Person', 'true'), refNode('p2', 'Bob', 'contact:class:Person', 'false')) + ) + expect(refs).toHaveLength(2) + expect(refs.find((r) => r.objectId === 'p1')?.grantsAccess).toBe('true') + expect(refs.find((r) => r.objectId === 'p2')?.grantsAccess).toBe('false') + }) +}) diff --git a/foundations/core/packages/text-core/src/markup/model.ts b/foundations/core/packages/text-core/src/markup/model.ts index 9b275c14f0c..cc5b581914a 100644 --- a/foundations/core/packages/text-core/src/markup/model.ts +++ b/foundations/core/packages/text-core/src/markup/model.ts @@ -93,5 +93,11 @@ export interface LinkMark extends MarkupMark { /** @public */ export interface ReferenceMarkupNode extends MarkupNode { type: MarkupNodeType.reference - attrs: { id: string, label: string, objectclass: string } + /** + * grantsAccess (V3): when 'false', the chunter mention-grants trigger skips + * Collaborator creation for this mention. undefined means "grant" (default, + * preserves pre-V3 behaviour). Stored as a string because markup attrs + * serialize through HTML where boolean coercion is unreliable. + */ + attrs: { id: string, label: string, objectclass: string, grantsAccess?: 'true' | 'false' } } diff --git a/foundations/core/packages/text-core/src/markup/reference.ts b/foundations/core/packages/text-core/src/markup/reference.ts index a9e4c64261a..cccf9f35025 100644 --- a/foundations/core/packages/text-core/src/markup/reference.ts +++ b/foundations/core/packages/text-core/src/markup/reference.ts @@ -24,6 +24,9 @@ export interface Reference { objectId: Ref objectClass: Ref> parentNode: MarkupNode | null + // V3: undefined = grant (default); 'false' = explicit deny. Merged any-wins + // across duplicate references to the same object (see extractReferences). + grantsAccess?: 'true' | 'false' } /** @@ -37,9 +40,19 @@ export function extractReferences (content: MarkupNode): Array { const reference = node as ReferenceMarkupNode const objectId = reference.attrs.id as Ref const objectClass = reference.attrs.objectclass as Ref> + const grantsAccess = reference.attrs.grantsAccess const e = result.find((e) => e.objectId === objectId && e.objectClass === objectClass) if (e === undefined) { - result.push({ objectId, objectClass, parentNode: parent ?? node }) + result.push({ objectId, objectClass, parentNode: parent ?? node, grantsAccess }) + } else { + // any-wins: a person is denied only if EVERY reference is explicitly + // 'false'. Upgrade away from 'false' as soon as a non-'false' (grant + // or default) reference appears; upgrade default->'true' cosmetically. + if (e.grantsAccess === 'false' && grantsAccess !== 'false') { + e.grantsAccess = grantsAccess + } else if (e.grantsAccess === undefined && grantsAccess === 'true') { + e.grantsAccess = 'true' + } } } return true diff --git a/foundations/core/packages/text/src/nodes/reference.ts b/foundations/core/packages/text/src/nodes/reference.ts index 8a9bbf685ab..02fa7240df5 100644 --- a/foundations/core/packages/text/src/nodes/reference.ts +++ b/foundations/core/packages/text/src/nodes/reference.ts @@ -22,6 +22,7 @@ export interface ReferenceNodeProps { id: Ref objectclass: Ref> label: string + grantsAccess?: 'true' | 'false' fixed?: boolean } @@ -44,6 +45,17 @@ export const ReferenceNode = Node.create({ id: getDataAttribute('id'), objectclass: getDataAttribute('objectclass'), label: getDataAttribute('label'), + grantsAccess: { + // V3: keep the deny flag across edit round-trips. parseHTML normalises + // a missing attribute to `undefined` rather than `null` so the runtime + // value matches the public type `'true' | 'false' | undefined` declared + // on ReferenceMarkupNode.attrs — a `null` would break strict + // `=== undefined` checks downstream. + default: undefined, + parseHTML: (element) => element.getAttribute('data-grants-access') ?? undefined, + renderHTML: (attributes) => + attributes.grantsAccess == null ? null : { 'data-grants-access': attributes.grantsAccess } + }, fixed: { default: null, parseHTML: (element) => element.getAttribute('data-fixed') === 'true', @@ -90,6 +102,11 @@ export const ReferenceNode = Node.create({ 'data-id': node.attrs.id, 'data-objectclass': node.attrs.objectclass, 'data-label': node.attrs.label, + // V3: belt-and-suspenders — explicitly emit data-grants-access here + // (in addition to the per-attribute renderHTML in addAttributes) so + // the deny flag is rendered even if the attribute-renderer merge + // path is bypassed by a downstream override. + ...(node.attrs.grantsAccess != null ? { 'data-grants-access': node.attrs.grantsAccess } : {}), class: 'antiMention' }, this.options.HTMLAttributes, @@ -110,10 +127,13 @@ function getAttrs (el: HTMLSpanElement): Attrs | false { return false } + const grantsAccess = el.dataset.grantsAccess + return { id, label, objectclass, + ...(grantsAccess !== undefined ? { grantsAccess } : {}), fixed: fixed || null } } diff --git a/foundations/server/packages/core/src/adapter.ts b/foundations/server/packages/core/src/adapter.ts index 89776f06fbf..0c27a3c0c31 100644 --- a/foundations/server/packages/core/src/adapter.ts +++ b/foundations/server/packages/core/src/adapter.ts @@ -70,6 +70,18 @@ export interface RawFindIterator { * @public */ export interface DbAdapter extends LowLevelStorage { + // Backend capability flag. When true, this adapter enforces collaborator-grant + // read security at the storage layer (e.g. the Postgres addSecurity collab + // OR-branch that joins the Collaborator domain into the visibility check). + // + // The space-security middleware only drops the space filter for collab-read + // bypasses (Guests reading docs they collaborate on, Collaborator self-reads, + // collab-only space listing) when the adapter serving the domain sets this to + // true. Absent/false is treated as fail-closed: the middleware keeps the normal + // space filter, so a backend without an equivalent clause (e.g. Mongo, which has + // no adapter-side space security) cannot leak docs across spaces. + supportsCollaboratorSecurity?: boolean + init?: ( ctx: MeasureContext, contextVars: Record, diff --git a/foundations/server/packages/middleware/src/collaboratorGuard.ts b/foundations/server/packages/middleware/src/collaboratorGuard.ts new file mode 100644 index 00000000000..16350ef5c02 --- /dev/null +++ b/foundations/server/packages/middleware/src/collaboratorGuard.ts @@ -0,0 +1,430 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { + BaseMiddleware, + type Middleware, + type PipelineContext, + type TxMiddlewareResult +} from '@hcengineering/server-core' +import core, { + type AccessGroup, + type Account, + AccountRole, + type AccessLevel, + type Class, + type Collaborator, + type Doc, + getClassCollaborators, + type GroupGrant, + hasAccountRole, + hasAtLeast, + type MeasureContext, + type Ref, + type SessionData, + type Space, + type Tx, + type TxApplyIf, + type TxCreateDoc, + type TxCUD, + TxProcessor, + type TxUpdateDoc +} from '@hcengineering/core' +import platform, { PlatformError, Severity, Status } from '@hcengineering/platform' + +import { isSystem } from './utils' + +const VALID_LEVELS: ReadonlySet = new Set(['read', 'write', 'admin']) + +function forbidden (): PlatformError> { + return new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) +} + +/** + * Fail-closed gate for client-initiated writes to access-granting Collaborator + * records. + * + * A Collaborator record on a class whose ClassCollaborators has + * `provideSecurity === true` is not merely a notification subscription — it + * grants read visibility on the doc, bypassing space membership, and (via + * `level`) write/admin authority. Such records must therefore only be created + * or removed by an authorized caller. This middleware enforces the + * authorization rules of the design (section 2.4): + * + * - System (trigger) writes pass through untouched — structural, + * mention-materialized and group-materialized collaborators are written by + * server triggers. + * - Client TxCreateDoc on a secured class must be a `grantedVia: 'manual'` + * grant, self-attributed (`grantedBy === caller`), with a valid `level`, + * authorized by space membership / space-owner / workspace-maintainer+ / + * an admin-level grant the caller already holds on the doc. + * - Client TxRemoveDoc on a secured collaborator is allowed for the granting + * actor, the grantee themselves (self-decline, grantedVia records only), + * space-owners, workspace-maintainer+, or an admin-level grantee of the doc. + * Structural records (grantedVia === undefined) may only be removed by + * space-owners / workspace-maintainer+. + * - Any other CUD (TxUpdateDoc / TxMixin) on a secured collaborator is + * rejected: grant records are immutable, a level change is Remove + Create. + * - GroupGrant CUD is gated too (P4.1): create/remove/re-level only by + * space-owners / workspace-Maintainer+ / an admin-level grantee of the doc. + * A GroupGrant `level` update is allowed (it is the one mutable field — the + * reconcile trigger propagates it); every other GroupGrant update is rejected. + * - AccessGroup CUD is gated (P4.1/4.3): membership is security-relevant, so + * edits are restricted to the group's owners / Maintainer+, and a group with + * live GroupGrants cannot be deleted. + * + * Collaborators of non-secured classes (channels etc.) are untouched. + * + * Registered directly after GuestPermissionsMiddleware in the pipeline. + */ +export class CollaboratorGuardMiddleware extends BaseMiddleware implements Middleware { + static async create ( + ctx: MeasureContext, + context: PipelineContext, + next: Middleware | undefined + ): Promise { + return new CollaboratorGuardMiddleware(context, next) + } + + async tx (ctx: MeasureContext, txes: Tx[]): Promise { + const account = ctx.contextData.account + // Trigger / service path: unrestricted. All non-manual provenance + // (mention/group/structural) is written exclusively here. + if (!isSystem(account, ctx)) { + for (const tx of txes) { + await this.checkTx(ctx, tx, account) + } + } + return await this.provideTx(ctx, txes) + } + + private async checkTx (ctx: MeasureContext, tx: Tx, account: Account): Promise { + if (tx._class === core.class.TxApplyIf) { + for (const inner of (tx as TxApplyIf).txes) { + await this.checkTx(ctx, inner, account) + } + return + } + if (!TxProcessor.isExtendsCUD(tx._class)) return + const cud = tx as TxCUD + if (this.context.hierarchy.isDerived(cud.objectClass, core.class.GroupGrant)) { + await this.checkGroupGrantTx(ctx, cud, account) + return + } + if (this.context.hierarchy.isDerived(cud.objectClass, core.class.AccessGroup)) { + await this.checkAccessGroupTx(ctx, cud, account) + return + } + if (!this.context.hierarchy.isDerived(cud.objectClass, core.class.Collaborator)) return + + if (cud._class === core.class.TxCreateDoc) { + const createTx = cud as TxCreateDoc + const target = createTx.attachedToClass ?? (createTx.attributes as any)?.attachedToClass + // fail-closed: an unclassifiable create of a Collaborator is rejected. + if (target === undefined) throw forbidden() + if (!this.isSecuredClass(target)) return // notification-only collaborator: unrestricted + await this.checkCreate(ctx, createTx, account) + return + } + + // Remove / update / mixin: resolve the existing record to classify it. + const existing = ( + await this.findAll( + ctx, + core.class.Collaborator, + { _id: cud.objectId as Ref }, + { limit: 1 } + ) + )[0] + // fail-closed: cannot resolve the record we are asked to mutate → reject. + if (existing === undefined) throw forbidden() + if (!this.isSecuredClass(existing.attachedToClass)) return // notification-only: unrestricted + + if (cud._class === core.class.TxRemoveDoc) { + await this.checkRemove(ctx, existing, account) + return + } + // TxUpdateDoc / TxMixin on a secured grant record: immutable → reject. + throw forbidden() + } + + /** True iff the class opts into collaborator-based security (provideSecurity). */ + private isSecuredClass (target: Ref> | undefined): boolean { + if (target === undefined) return false + const collabSec = getClassCollaborators(this.context.modelDb, this.context.hierarchy, target) + return collabSec?.provideSecurity === true + } + + private async checkCreate ( + ctx: MeasureContext, + createTx: TxCreateDoc, + account: Account + ): Promise { + const attrs = createTx.attributes as Partial + + // Clients may only create explicit MANUAL grants. mention/group provenance + // and structural (undefined) records are the exclusive domain of triggers. + if (attrs.grantedVia !== 'manual') throw forbidden() + // The grant must be self-attributed — no impersonating another granter. + if (attrs.grantedBy !== account.uuid) throw forbidden() + // Level: absent ⇒ read (fail-safe). Present ⇒ must be a known level. + if (attrs.level !== undefined && !VALID_LEVELS.has(attrs.level)) throw forbidden() + + const attachedTo = createTx.attachedTo ?? (createTx.attributes as any)?.attachedTo + const attachedToClass = createTx.attachedToClass ?? (createTx.attributes as any)?.attachedToClass + // C-01: authorize against the target doc's REAL space, never the client-chosen + // objectSpace. The Postgres visibility grant keys on `attachedTo` alone, so a + // grant written into a space the caller controls would otherwise confer access + // to a doc that lives in a foreign space. resolveTargetSpace is fail-closed. + const targetSpace = await this.resolveTargetSpace(ctx, createTx.objectSpace, attachedTo, attachedToClass) + if (targetSpace === undefined) throw forbidden() + const authorized = await this.canGrant(ctx, targetSpace, attachedTo, account) + if (!authorized) throw forbidden() + + // H-NEW-01: cap the granted level against the granter's own authority. A plain + // member (authorized above via the ≥User member path of canGrant) must NOT be able + // to self-mint an 'admin' grant — that would make hasAdminGrant() true and unlock + // canGrantGroup() and foreign-revoke, powers reserved for owners / maintainers / + // admin-grantees. Only an already-privileged caller may grant 'admin'. + if (attrs.level === 'admin' && !(await this.canGrantAdminLevel(ctx, targetSpace, attachedTo, account))) { + throw forbidden() + } + } + + private async checkRemove (ctx: MeasureContext, record: Collaborator, account: Account): Promise { + // Grantee self-decline: only for actual grant records (grantedVia set), + // never for structural collaborators (assignee/createdBy). + if (record.grantedVia != null && record.collaborator === account.uuid) return + // The actor who created the grant may revoke it. + if (record.grantedVia != null && record.grantedBy === account.uuid) return + + const space = await this.loadSpace(ctx, record.space) + // fail-closed: cannot resolve the space to prove ownership → reject. + if (space === undefined) throw forbidden() + if (space.owners?.includes(account.uuid) === true) return + if (hasAccountRole(account, AccountRole.Maintainer)) return + + // Admin-level grantee of the doc may revoke grants on that doc, but NOT + // structural records (those stay owner/maintainer-only). + if (record.grantedVia != null && (await this.hasAdminGrant(ctx, record.attachedTo, account))) return + + throw forbidden() + } + + /** + * GroupGrant CUD gate (design 2.4 / P4.1). GroupGrants are strictly stronger + * than manual grants (future members inherit access), so authority is NOT + * lowered to ≥ User members: only space-owners, workspace-Maintainer+, or an + * admin-level grantee of the doc may create/remove/modify one. + * + * Unlike Collaborator grant records, a GroupGrant's `level` IS mutable — the + * reconcile trigger propagates a level change onto the derived collaborators. + * A TxUpdateDoc is therefore allowed, but only when it touches nothing but + * `level` (with a valid value). Any other field update, or a TxMixin, is + * rejected. + */ + private async checkGroupGrantTx (ctx: MeasureContext, cud: TxCUD, account: Account): Promise { + if (cud._class === core.class.TxCreateDoc) { + const createTx = cud as TxCreateDoc + const attrs = createTx.attributes as Partial + if (attrs.level !== undefined && !VALID_LEVELS.has(attrs.level)) throw forbidden() + const target = createTx.attachedToClass ?? (createTx.attributes as any)?.attachedToClass + // fail-closed: a GroupGrant only has meaning on a secured class. + if (!this.isSecuredClass(target)) throw forbidden() + const attachedTo = createTx.attachedTo ?? (createTx.attributes as any)?.attachedTo + // C-01: authorize against the target doc's REAL space, not the client-chosen + // objectSpace (same cross-space-laundering vector as manual grants). + const targetSpace = await this.resolveTargetSpace(ctx, createTx.objectSpace, attachedTo, target) + if (targetSpace === undefined) throw forbidden() + if (!(await this.canGrantGroup(ctx, targetSpace, attachedTo, account))) throw forbidden() + return + } + + // Remove / update / mixin: resolve the existing grant. + const existing = ( + await this.findAll(ctx, core.class.GroupGrant, { _id: cud.objectId as Ref }, { limit: 1 }) + )[0] + // fail-closed: cannot resolve the grant we are asked to mutate → reject. + if (existing === undefined) throw forbidden() + + if (cud._class === core.class.TxUpdateDoc) { + const upd = cud as TxUpdateDoc + // Only a pure level change is permitted; any other operation is rejected. + const ops = upd.operations as Record + const keys = Object.keys(ops) + if (keys.length !== 1 || keys[0] !== 'level') throw forbidden() + if (ops.level !== undefined && !VALID_LEVELS.has(ops.level)) throw forbidden() + if (!(await this.canGrantGroup(ctx, existing.space, existing.attachedTo, account))) throw forbidden() + return + } + + if (cud._class === core.class.TxRemoveDoc) { + if (!(await this.canGrantGroup(ctx, existing.space, existing.attachedTo, account))) throw forbidden() + return + } + // TxMixin (or anything else) on a GroupGrant: reject. + throw forbidden() + } + + /** + * AccessGroup CUD gate (design 2.8 `owners` semantics / P4.1+4.3). An + * AccessGroup's membership is security-relevant: adding oneself to a group that + * already holds grants would escalate access. So editing is restricted to the + * group's `owners` (or workspace-Maintainer+). Fail-closed throughout. + * + * - Create: allowed for a real account (≥ User) that lists itself in `owners` + * (no orphan/unowned groups), or any Maintainer+. + * - Update: only an owner of the existing group, or Maintainer+. + * - Remove: only an owner / Maintainer+, AND only when no GroupGrant still + * references the group (a live grant must be revoked first). + */ + private async checkAccessGroupTx (ctx: MeasureContext, cud: TxCUD, account: Account): Promise { + if (cud._class === core.class.TxCreateDoc) { + if (hasAccountRole(account, AccountRole.Maintainer)) return + const attrs = (cud as TxCreateDoc).attributes as Partial + // ≥ User and self-owned: the creator must be able to manage what they make. + if (hasAccountRole(account, AccountRole.User) && attrs.owners?.includes(account.uuid) === true) return + throw forbidden() + } + + const existing = ( + await this.findAll( + ctx, + core.class.AccessGroup, + { _id: cud.objectId as Ref }, + { limit: 1 } + ) + )[0] + // fail-closed: cannot resolve the group we are asked to mutate → reject. + if (existing === undefined) throw forbidden() + const isOwner = existing.owners?.includes(account.uuid) || hasAccountRole(account, AccountRole.Maintainer) + if (!isOwner) throw forbidden() + + if (cud._class === core.class.TxRemoveDoc) { + // A group with live grants may not be deleted (grants materialize access). + const grants = await this.findAll(ctx, core.class.GroupGrant, { group: existing._id }, { limit: 1 }) + if (grants.length > 0) throw forbidden() + } + // TxUpdateDoc / TxMixin by an owner: allowed. + } + + /** + * Authority to create / remove / re-level a GroupGrant on the doc: + * - workspace Maintainer+, OR + * - space owner, OR + * - caller already holds an admin-level grant on the target doc. + * Fail-closed: unresolvable space → not authorized. (No ≥ User member path.) + */ + private async canGrantGroup ( + ctx: MeasureContext, + space: Ref, + attachedTo: Ref | undefined, + account: Account + ): Promise { + if (hasAccountRole(account, AccountRole.Maintainer)) return true + const spaceDoc = await this.loadSpace(ctx, space) + if (spaceDoc === undefined) return false + if (spaceDoc.owners?.includes(account.uuid) === true) return true + return await this.hasAdminGrant(ctx, attachedTo, account) + } + + /** + * Authority to create a MANUAL grant on the doc, per design 2.4: + * - space member with workspace role ≥ User, OR + * - space owner, OR + * - workspace Maintainer+, OR + * - caller already holds an admin-level grant on the target doc. + * Fail-closed: unresolvable space → not authorized. + */ + private async canGrant ( + ctx: MeasureContext, + space: Ref, + attachedTo: Ref | undefined, + account: Account + ): Promise { + if (hasAccountRole(account, AccountRole.Maintainer)) return true + + const spaceDoc = await this.loadSpace(ctx, space) + if (spaceDoc === undefined) return false + if (spaceDoc.owners?.includes(account.uuid) === true) return true + if (hasAccountRole(account, AccountRole.User) && spaceDoc.members?.includes(account.uuid)) return true + + return await this.hasAdminGrant(ctx, attachedTo, account) + } + + /** + * H-NEW-01: authority to grant an 'admin'-level Collaborator. This is canGrant() + * WITHOUT the plain ≥User member path: an admin grant confers authority + * (hasAdminGrant → canGrantGroup + foreign-revoke), so it must only be mintable by a + * caller who already holds admin-equivalent authority (maintainer / space owner / + * pre-existing admin-grantee). The new record is not yet persisted, so hasAdminGrant() + * below reflects only pre-existing grants — no self-reference. + */ + private async canGrantAdminLevel ( + ctx: MeasureContext, + space: Ref, + attachedTo: Ref | undefined, + account: Account + ): Promise { + if (hasAccountRole(account, AccountRole.Maintainer)) return true + const spaceDoc = await this.loadSpace(ctx, space) + if (spaceDoc === undefined) return false + if (spaceDoc.owners?.includes(account.uuid) === true) return true + return await this.hasAdminGrant(ctx, attachedTo, account) + } + + /** True iff the caller holds a level ≥ admin Collaborator grant on the doc. */ + private async hasAdminGrant ( + ctx: MeasureContext, + attachedTo: Ref | undefined, + account: Account + ): Promise { + if (attachedTo === undefined) return false + const grants = await this.findAll(ctx, core.class.Collaborator, { + attachedTo, + collaborator: account.uuid + }) + return grants.some((g) => hasAtLeast(g.level as AccessLevel | undefined, 'admin')) + } + + private async loadSpace (ctx: MeasureContext, space: Ref): Promise { + return (await this.findAll(ctx, core.class.Space, { _id: space }, { limit: 1 }))[0] + } + + /** + * Resolve the target doc's REAL space and enforce that the grant record's own + * space (`objectSpace`) mirrors it (C-01). The Postgres visibility grant keys on + * `attachedTo` alone (see postgres addSecurity: `collab_sec.attachedTo = + * domain._id`), independent of the grant record's own space — so a grant whose + * objectSpace does not match the target doc's space would confer cross-space + * visibility. Authority must therefore be judged against the returned real space, + * never the client-supplied objectSpace. + * + * Fail-closed → undefined (caller rejects) when: attachedTo/class missing, the + * target doc cannot be loaded, or the record's space does not mirror it. + */ + private async resolveTargetSpace ( + ctx: MeasureContext, + recordSpace: Ref, + attachedTo: Ref | undefined, + attachedToClass: Ref> | undefined + ): Promise | undefined> { + if (attachedTo === undefined || attachedToClass === undefined) return undefined + const targetDoc = (await this.findAll(ctx, attachedToClass, { _id: attachedTo }, { limit: 1 }))[0] + if (targetDoc === undefined) return undefined + if (recordSpace !== targetDoc.space) return undefined + return targetDoc.space + } +} diff --git a/foundations/server/packages/middleware/src/guestPermissions.ts b/foundations/server/packages/middleware/src/guestPermissions.ts index 077d546a84d..0dabe5033da 100644 --- a/foundations/server/packages/middleware/src/guestPermissions.ts +++ b/foundations/server/packages/middleware/src/guestPermissions.ts @@ -8,8 +8,11 @@ import core, { type Account, AccountRole, type Class, + type Collaborator, type Doc, type ClassPermission, + getClassCollaborators, + hasAtLeast, type Permission, hasAccountRole, type MeasureContext, @@ -121,6 +124,15 @@ export class GuestPermissionsMiddleware extends BaseMiddleware implements Middle async tx (ctx: MeasureContext, txes: Tx[]): Promise { const account = ctx.contextData.account + + // C-02: the collab-only field-update / remove veto is level-aware and role-agnostic + // by construction — it passes space members and Maintainer+, and only vetoes a + // non-member who reaches the doc through a read-level grant on a PRIVATE space. It + // MUST run for every account, not just guests: it previously lived inside processTx, + // which the >= User early-return below skips, so the grant level went unenforced for + // regular users (a read-grant holder could edit fields). + await this.checkCollabOnlyGrantVeto(ctx, txes, account) + if (hasAccountRole(account, AccountRole.User)) { this.invalidateCacheIfNeeded(txes) return await this.provideTx(ctx, txes) @@ -157,9 +169,113 @@ export class GuestPermissionsMiddleware extends BaseMiddleware implements Middle } else if (cudTx.space !== core.space.DerivedTx && (await this.isForbiddenTx(ctx, cudTx, account))) { throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) } + // C-02: the collab-only field-update / remove veto moved to checkCollabOnlyGrantVeto, + // which runs for ALL roles at the top of tx(); it no longer lives in this guest-only + // path (the >= User early-return used to skip it). + } + } + + /** + * C-02: run the collab-only field-update / remove veto for EVERY account, not just the + * guest path. Recurses into TxApplyIf so a grant cannot be smuggled inside a batch. + */ + private async checkCollabOnlyGrantVeto ( + ctx: MeasureContext, + txes: Tx[], + account: Account + ): Promise { + for (const tx of txes) { + await this.checkCollabOnlyGrantVetoForTx(ctx, tx, account) + } + } + + private async checkCollabOnlyGrantVetoForTx ( + ctx: MeasureContext, + tx: Tx, + account: Account + ): Promise { + if (tx._class === core.class.TxApplyIf) { + for (const t of (tx as TxApplyIf).txes) { + await this.checkCollabOnlyGrantVetoForTx(ctx, t, account) + } + return + } + if (TxProcessor.isExtendsCUD(tx._class)) { + if (await this.isForbiddenCollabOnlyGuestFieldUpdate(ctx, tx as TxCUD, account)) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } } } + /** + * Class-agnostic, level-aware veto for field updates / removes on docs whose + * class has opted into access-granting collaborators (provideSecurity: true, + * mentionsGrantAccess: true on the ClassCollaborators model entry). + * + * The caller is "collab-only" on the target doc when their only route to it is + * a per-doc Collaborator grant, i.e. they are NOT a space member and: + * - they are a guest-tier account, OR + * - they are a regular User and the space is PRIVATE (a public space is a + * normal collaborative space reached without a grant → unchanged behavior). + * Workspace-privileged accounts (Maintainer+) and space members always pass. + * + * For a collab-only caller the grant LEVEL decides (P2.3, ordinal read= write on the doc (max over all their records); + * a read-only grant (or no grant) is vetoed. + * - TxRemoveDoc (L-RM): deleting the doc itself is reserved for space + * members/owners and is vetoed regardless of grant level. + * - Comments via chunter.class.ChatMessage keep flowing (createAccessLevel). + * + * L-GP fail-closed: an unresolvable space forbids. If the class has not opted + * in, this veto is a no-op (returns false). + */ + private async isForbiddenCollabOnlyGuestFieldUpdate ( + ctx: MeasureContext, + cudTx: TxCUD, + account: Account + ): Promise { + // L-RM: veto covers both field-updates AND removes. + if (cudTx._class !== core.class.TxUpdateDoc && cudTx._class !== core.class.TxRemoveDoc) return false + + const classCollab = getClassCollaborators(this.context.modelDb, this.context.hierarchy, cudTx.objectClass) + if (classCollab?.provideSecurity !== true) return false + if (classCollab.mentionsGrantAccess !== true) return false + + const space = (await this.findAll(ctx, core.class.Space, { _id: cudTx.objectSpace }))[0] + // L-GP: fail-closed — if the doc's space cannot be resolved we cannot prove + // the caller is a space member, so the veto must FORBID. + if (space === undefined) return true + if (space.members?.includes(account.uuid)) return false + + // Non-member. Decide whether the caller is "collab-only" (subject to the veto). + const isGuest = + account.role === AccountRole.Guest || + account.role === AccountRole.DocGuest || + account.role === AccountRole.ReadOnlyGuest + if (!isGuest) { + // Workspace-privileged accounts keep broad authority (unchanged). + if (hasAccountRole(account, AccountRole.Maintainer)) return false + // A regular User is only collab-only on a PRIVATE space; on a public space + // they participate through normal visibility (no grant) → unchanged pass. + // Fail-closed: treat anything other than an explicit public flag as private. + if (!space.private) return false + } + + // Collab-only caller. Field updates require level >= write; removes stay + // vetoed. A person may hold several records (e.g. mention:read + manual:write) + // — the highest level wins. + if (cudTx._class === core.class.TxUpdateDoc) { + const grants = await this.findAll(ctx, core.class.Collaborator, { + attachedTo: cudTx.objectId, + collaborator: account.uuid + }) + if (grants.some((g) => hasAtLeast(g.level, 'write'))) return false + } + + return true + } + /** * Returns the covered-class ancestor of the objectClass if one exists in the new permissions model, * or undefined if the class is not covered. diff --git a/foundations/server/packages/middleware/src/index.ts b/foundations/server/packages/middleware/src/index.ts index 061b25ab566..9c20bcd5a42 100644 --- a/foundations/server/packages/middleware/src/index.ts +++ b/foundations/server/packages/middleware/src/index.ts @@ -31,6 +31,7 @@ export * from './modified' export * from './private' export * from './queryJoin' export * from './guestPermissions' +export * from './collaboratorGuard' export * from './identifier' export * from './spacePermissions' export * from './spaceSecurity' diff --git a/foundations/server/packages/middleware/src/spaceSecurity.ts b/foundations/server/packages/middleware/src/spaceSecurity.ts index 4c8bd4223e1..1ffb0ba0828 100644 --- a/foundations/server/packages/middleware/src/spaceSecurity.ts +++ b/foundations/server/packages/middleware/src/spaceSecurity.ts @@ -613,6 +613,27 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar return domain === 'tx' ? 'objectSpace' : domain === 'space' ? '_id' : 'space' } + // H5 (Option A, fail-closed): the collab-read bypasses in findAll drop the space + // filter and delegate the restriction to the adapter's collab OR-branch, which + // only exists in the Postgres adapter (see postgres/src/storage.ts addSecurity). + // On a backend without an equivalent clause (e.g. Mongo) dropping the space + // filter would leak docs across spaces. So the bypasses may only fire when the + // adapter serving this domain declares supportsCollaboratorSecurity. Unknown or + // unsupported backend => false => keep the normal space filter (no leak; the + // cross-space collab feature is simply inactive there). + private isCollabBackendSupported (domain: Domain): boolean { + const mgr = this.context.adapterManager + if (mgr === undefined) { + return false + } + try { + const adapter = mgr.getAdapterByName(mgr.getAdapterName(domain), false) + return adapter?.supportsCollaboratorSecurity === true + } catch { + return false + } + } + override async findAll( ctx: MeasureContext, _class: Ref>, @@ -630,7 +651,67 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar let clientFilterSpaces: Set> | undefined - if (!isSystem(account, ctx) && account.role !== AccountRole.DocGuest && domain !== DOMAIN_MODEL) { + // When a class opts into collaborator-grants-read security AND the caller is a Guest/ReadOnlyGuest, + // we deliberately skip the middleware-level space filter. The Postgres adapter's `collabRes` + // OR-branch (see postgres/src/storage.ts, getSecurityClause) joins Collaborator records into the + // visibility check, so Guests can read individual docs they were added to as Collaborator even + // when they are not members of the owning Space. Filtering by space here would strip those docs + // before the adapter ever sees the query. + const collabSec = + domain !== DOMAIN_MODEL ? getClassCollaborators(this.context.modelDb, this.context.hierarchy, _class) : undefined + // H5 (fail-closed): a collab-read bypass drops the space filter and relies on + // the adapter's collab OR-branch to re-restrict visibility. That branch only + // exists on backends that declare supportsCollaboratorSecurity (Postgres / + // CockroachDB). On any other backend the bypass would leak across spaces, so we + // gate all three bypasses behind this single check. Unknown backend => false => + // the normal space filter stays in place. + const collabBackendSupported = domain !== DOMAIN_MODEL && this.isCollabBackendSupported(domain) + // P2.2: per-doc collaborator grants must be effective for regular members, + // not just guests. The bypass therefore fires for every non-admin role + // (Admin already gets full visibility through the normal path; DocGuest stays + // excluded — it has its own guest handling). The backend-support gate above + // is preserved, so widening the roles never re-opens the Mongo cross-space + // leak (H5-A fail-closed): without an adapter collab-branch the bypass simply + // does not fire and the normal space filter stays in place. + // M-01: the original guest-scope collab-read applies to EVERY provideSecurity + // class; the P2.2 widening to regular member roles (User/Maintainer/Owner) is + // confined to classes that opt into grantable access (mentionsGrantAccess) — + // the same scope as the guestPermissions write-veto. Without this a regular + // member who is only a *structural* collaborator (createdBy/assignee) on a + // love/QMS doc in a space they are not a member of would newly gain read + // visibility (love and controlled-documents set provideSecurity but NOT + // mentionsGrantAccess; only tracker opts into grants). + const isGuestRole = account.role === AccountRole.Guest || account.role === AccountRole.ReadOnlyGuest + const collabReadBypass = + collabBackendSupported && + (collabSec?.provideSecurity === true || collabSec?.provideAttachedSecurity === true) && + account.role !== AccountRole.Admin && + account.role !== AccountRole.DocGuest && + (isGuestRole || collabSec?.mentionsGrantAccess === true) + // Self-Collaborator visibility: let the Postgres adapter's self-collab OR-branch + // (storage.ts, addSecurity) fire for any non-System caller when reading the + // Collaborator class itself. Required for queries like the tracker "Subscribed" + // tab `{collaborator: self, attachedToClass: Issue}`, which must surface the + // user's own subscriptions even on docs in non-member spaces. + const selfCollabBypass = collabBackendSupported && this.context.hierarchy.isDerived(_class, core.class.Collaborator) + // Containing-Space visibility for collab-only Guests: let the Postgres adapter's + // space-collab OR-branch surface Spaces that host docs the caller is a + // Collaborator on. Required so the project/space nav tree can list projects + // where the user is collab-only (no member status). + // P2.2: widened to all non-admin roles (see collabReadBypass) so the space/ + // project nav tree can surface a private space that a regular member only + // reaches through a per-doc collaborator grant. Still backend-gated. + const spaceCollabBypass = + collabBackendSupported && isSpace && account.role !== AccountRole.Admin && account.role !== AccountRole.DocGuest + + if ( + !isSystem(account, ctx) && + account.role !== AccountRole.DocGuest && + domain !== DOMAIN_MODEL && + !collabReadBypass && + !selfCollabBypass && + !spaceCollabBypass + ) { if (!isOwner(account, ctx) || !isSpace || !showArchived) { if (newQuery[field] !== undefined) { const res = await this.mergeQuery(ctx, account, newQuery[field], domain, isSpace, showArchived) diff --git a/foundations/server/packages/middleware/src/tests/collabSpaceSecurity.test.ts b/foundations/server/packages/middleware/src/tests/collabSpaceSecurity.test.ts new file mode 100644 index 00000000000..05366cfdc79 --- /dev/null +++ b/foundations/server/packages/middleware/src/tests/collabSpaceSecurity.test.ts @@ -0,0 +1,381 @@ +// +// Copyright © 2025 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. +// + +/** + * H5 - Collab-read bypass must be backend-guarded (fail-closed). + * + * The three collab-read bypasses in SpaceSecurityMiddleware.findAll drop the + * space filter and delegate the restriction to the adapter's collab OR-branch, + * which only exists in the Postgres adapter (postgres/src/storage.ts addSecurity). + * On a backend without an equivalent clause (Mongo has none) dropping the space + * filter would leak docs across spaces. + * + * These tests drive findAll with a mocked pipeline and capture the query that + * leaves the middleware towards the adapter: + * - backend supports collab security -> bypass fires -> space filter dropped + * - backend does NOT (Mongo/unknown) -> bypass skipped -> space filter kept + */ + +import core, { + AccountRole, + generateId, + MeasureMetricsContext, + toFindResult, + type Account, + type Class, + type Doc, + type Domain, + type MeasureContext, + type PersonId, + type Ref, + type SessionData, + type Space +} from '@hcengineering/core' +import type { PipelineContext } from '@hcengineering/server-core' +import { SpaceSecurityMiddleware } from '../spaceSecurity' + +const ISSUE_CLASS = 'test:class:Issue' as Ref> +const ISSUE_DOMAIN = 'test-issue' as Domain +const SPACE_CLASS = core.class.Space +const COLLAB_CLASS = core.class.Collaborator + +const S1 = 'test:space:S1' as Ref // guest IS a member +const S2 = 'test:space:S2' as Ref // guest is NOT a member (collab-only) + +function makeGuest (): Account { + return makeAccount(AccountRole.Guest) +} + +function makeAccount (role: AccountRole): Account { + return { + uuid: generateId() as any, + role, + primarySocialId: 'test' as PersonId, + socialIds: ['test' as PersonId], + fullSocialIds: [] + } +} + +function makeCtx (account: Account): MeasureContext { + const ctx = new MeasureMetricsContext('test', {}) as MeasureContext + ctx.contextData = { + account, + socialStringsToUsers: new Map(), + broadcast: { txes: [], queue: [], sessions: {} } + } as any + return ctx +} + +interface Captured { + query?: any +} + +function admittedSpaces (filterValue: any): Array> { + if (filterValue === undefined) return [] + if (typeof filterValue === 'object' && Array.isArray(filterValue.$in)) return filterValue.$in + return [filterValue] +} + +/** + * Build a SpaceSecurityMiddleware wired to a mocked pipeline. + * + * backendSupportsCollab: whether the adapter serving the domain declares + * supportsCollaboratorSecurity (true = Postgres-like, false = Mongo-like). + * noAdapterManager: simulate a pipeline without an adapter manager. + * provideSecurity: whether the queried class opts into collab read security. + * derivedFromSpace / derivedFromCollaborator: how `_class` reports isDerived. + */ +function makeMiddleware (opts: { + backendSupportsCollab?: boolean + noAdapterManager?: boolean + provideSecurity?: boolean + // M-01: only classes that opt into grantable access widen the collab-read bypass + // to regular member roles. Defaults false (models love/QMS: provideSecurity only). + mentionsGrantAccess?: boolean + derivedFromSpace?: boolean + derivedFromCollaborator?: boolean +}): { mw: SpaceSecurityMiddleware, captured: Captured } { + const captured: Captured = {} + + const collabConfig = + opts.provideSecurity === true + ? [ + { + attachedTo: ISSUE_CLASS, + provideSecurity: true, + mentionsGrantAccess: opts.mentionsGrantAccess === true + } as any + ] + : [] + + const hierarchy = { + getDomain: (_class: Ref>): Domain => { + if (_class === SPACE_CLASS) return 'space' as Domain + if (_class === COLLAB_CLASS) return 'collaborator' as Domain + return ISSUE_DOMAIN + }, + isDerived: (a: Ref>, b: Ref>): boolean => { + if (b === core.class.Space) return opts.derivedFromSpace === true + if (b === core.class.Collaborator) return opts.derivedFromCollaborator === true + return a === b + }, + getAncestors: (_class: Ref>): Array>> => [_class] + } + + const modelDb = { + findAllSync: (_class: Ref>): any[] => { + if (_class === core.class.ClassCollaborators) return collabConfig + return [] + } + } + + const adapterManager = + opts.noAdapterManager === true + ? undefined + : { + getAdapterName: (_domain: Domain): string => 'default', + getAdapterByName: (_name: string): any => ({ + supportsCollaboratorSecurity: opts.backendSupportsCollab === true ? true : undefined + }) + } + + const context = { + workspace: { uuid: 'test-workspace' as any, url: 'test', dataId: 'test' as any }, + hierarchy, + modelDb, + branding: null, + adapterManager, + contextVars: {} + } as unknown as PipelineContext + + const next = { + findAll: async (_ctx: MeasureContext, _class: Ref>, query: any) => { + captured.query = query + return toFindResult([]) + }, + // Domain spaces for the queried domain: S1 and S2 both host docs. + groupBy: async () => + new Map, number>([ + [S1, 1], + [S2, 1] + ]) + } + + const mw = new (SpaceSecurityMiddleware as any)(false, context, next) + // Skip async init (would call next.findAll for Space); we seed state directly. + mw.wasInit = true + + return { mw, captured } +} + +describe('SpaceSecurityMiddleware - collab-read backend guard (H5)', () => { + describe('collabReadBypass (Guest reading a collab-secured Doc class)', () => { + it('Mongo/unknown backend: does NOT drop the space filter (fail-closed, no cross-space leak)', async () => { + const { mw, captured } = makeMiddleware({ backendSupportsCollab: false, provideSecurity: true }) + const account = makeGuest() + ;(mw as any).allowedSpaces = { [account.uuid]: [S1] } // member of S1 only + + await mw.findAll(makeCtx(account), ISSUE_CLASS, {}) + + // Space filter must be present and must NOT admit S2 (the collab-only space). + expect(captured.query.space).toBeDefined() + const admitted = admittedSpaces(captured.query.space) + expect(admitted).not.toContain(S2) + expect(admitted).toContain(S1) + }) + + it('Postgres backend: drops the space filter so the adapter collab-branch can widen visibility', async () => { + const { mw, captured } = makeMiddleware({ backendSupportsCollab: true, provideSecurity: true }) + const account = makeGuest() + ;(mw as any).allowedSpaces = { [account.uuid]: [S1] } + + await mw.findAll(makeCtx(account), ISSUE_CLASS, {}) + + // Bypass fired: no middleware-level space filter (adapter enforces collab security). + expect(captured.query.space).toBeUndefined() + }) + }) + + describe('selfCollabBypass (reading the Collaborator class)', () => { + it('Mongo/unknown backend: keeps the space filter', async () => { + const { mw, captured } = makeMiddleware({ backendSupportsCollab: false, derivedFromCollaborator: true }) + const account = makeGuest() + ;(mw as any).allowedSpaces = { [account.uuid]: [S1] } + + await mw.findAll(makeCtx(account), COLLAB_CLASS, {}) + + expect(captured.query.space).toBeDefined() + expect(admittedSpaces(captured.query.space)).not.toContain(S2) + }) + + it('Postgres backend: drops the space filter', async () => { + const { mw, captured } = makeMiddleware({ backendSupportsCollab: true, derivedFromCollaborator: true }) + const account = makeGuest() + ;(mw as any).allowedSpaces = { [account.uuid]: [S1] } + + await mw.findAll(makeCtx(account), COLLAB_CLASS, {}) + + expect(captured.query.space).toBeUndefined() + }) + }) + + describe('spaceCollabBypass (Guest listing Spaces)', () => { + it('Mongo/unknown backend: keeps the _id space filter', async () => { + const { mw, captured } = makeMiddleware({ backendSupportsCollab: false, derivedFromSpace: true }) + const account = makeGuest() + ;(mw as any).allowedSpaces = { [account.uuid]: [S1] } + + await mw.findAll(makeCtx(account), SPACE_CLASS, {}) + + // For the space domain the filter field is `_id`. + expect(captured.query._id).toBeDefined() + expect(admittedSpaces(captured.query._id)).not.toContain(S2) + }) + + it('Postgres backend: drops the _id space filter', async () => { + const { mw, captured } = makeMiddleware({ backendSupportsCollab: true, derivedFromSpace: true }) + const account = makeGuest() + ;(mw as any).allowedSpaces = { [account.uuid]: [S1] } + + await mw.findAll(makeCtx(account), SPACE_CLASS, {}) + + expect(captured.query._id).toBeUndefined() + }) + }) + + // ─── P2.2: collab-read bypass fires for ALL non-admin roles (per-doc grants) ── + // Before P2 the bypass only fired for Guest/ReadOnlyGuest, so a grant to a + // regular User was invisible (space filter blocked the granted doc). The role + // condition is widened to `role !== Admin && role !== DocGuest`, but stays + // gated behind the backend-support (H5-A) check so Mongo remains fail-closed. + describe('P2.2 role-widening of collabReadBypass', () => { + it('role User with backend support: drops the space filter (grant becomes effective)', async () => { + const { mw, captured } = makeMiddleware({ + backendSupportsCollab: true, + provideSecurity: true, + mentionsGrantAccess: true + }) + const account = makeAccount(AccountRole.User) + ;(mw as any).allowedSpaces = { [account.uuid]: [S1] } // member of S1 only + + await mw.findAll(makeCtx(account), ISSUE_CLASS, {}) + + // Bypass now fires for User → adapter collab-branch widens to granted docs. + expect(captured.query.space).toBeUndefined() + }) + + it('role Maintainer with backend support: drops the space filter', async () => { + const { mw, captured } = makeMiddleware({ + backendSupportsCollab: true, + provideSecurity: true, + mentionsGrantAccess: true + }) + const account = makeAccount(AccountRole.Maintainer) + ;(mw as any).allowedSpaces = { [account.uuid]: [S1] } + + await mw.findAll(makeCtx(account), ISSUE_CLASS, {}) + + expect(captured.query.space).toBeUndefined() + }) + + it('M-01: role User on a provideSecurity class WITHOUT mentionsGrantAccess (love/QMS): KEEPS the space filter', async () => { + // love and controlled-documents opt into provideSecurity but not mentionsGrantAccess. + // A regular member who is only a structural collaborator there must NOT gain + // cross-space read from the P2.2 widening — the widening is grant-classes only. + const { mw, captured } = makeMiddleware({ + backendSupportsCollab: true, + provideSecurity: true, + mentionsGrantAccess: false + }) + const account = makeAccount(AccountRole.User) + ;(mw as any).allowedSpaces = { [account.uuid]: [S1] } + + await mw.findAll(makeCtx(account), ISSUE_CLASS, {}) + + expect(captured.query.space).toBeDefined() + const admitted = admittedSpaces(captured.query.space) + expect(admitted).not.toContain(S2) + expect(admitted).toContain(S1) + }) + + it('M-01: role Guest still gets collab-read on a non-mention provideSecurity class (love/QMS)', async () => { + // The original guest-scope bypass is preserved for every provideSecurity class. + const { mw, captured } = makeMiddleware({ + backendSupportsCollab: true, + provideSecurity: true, + mentionsGrantAccess: false + }) + const account = makeGuest() + ;(mw as any).allowedSpaces = { [account.uuid]: [S1] } + + await mw.findAll(makeCtx(account), ISSUE_CLASS, {}) + + expect(captured.query.space).toBeUndefined() + }) + + it('role User on Mongo/unknown backend: KEEPS the space filter (no cross-space leak)', async () => { + const { mw, captured } = makeMiddleware({ + backendSupportsCollab: false, + provideSecurity: true, + mentionsGrantAccess: true + }) + const account = makeAccount(AccountRole.User) + ;(mw as any).allowedSpaces = { [account.uuid]: [S1] } + + await mw.findAll(makeCtx(account), ISSUE_CLASS, {}) + + // No adapter clause → bypass must NOT fire → space filter kept, S2 excluded. + expect(captured.query.space).toBeDefined() + const admitted = admittedSpaces(captured.query.space) + expect(admitted).not.toContain(S2) + expect(admitted).toContain(S1) + }) + + it('role User with backend support but non-collab class: keeps the space filter', async () => { + // provideSecurity=false → not an access-secured class → normal space filter. + const { mw, captured } = makeMiddleware({ backendSupportsCollab: true, provideSecurity: false }) + const account = makeAccount(AccountRole.User) + ;(mw as any).allowedSpaces = { [account.uuid]: [S1] } + + await mw.findAll(makeCtx(account), ISSUE_CLASS, {}) + + expect(captured.query.space).toBeDefined() + expect(admittedSpaces(captured.query.space)).not.toContain(S2) + }) + + it('role User listing Spaces with backend support: drops the _id filter (spaceCollabBypass)', async () => { + const { mw, captured } = makeMiddleware({ backendSupportsCollab: true, derivedFromSpace: true }) + const account = makeAccount(AccountRole.User) + ;(mw as any).allowedSpaces = { [account.uuid]: [S1] } + + await mw.findAll(makeCtx(account), SPACE_CLASS, {}) + + expect(captured.query._id).toBeUndefined() + }) + }) + + describe('fail-closed when no adapterManager is available', () => { + it('keeps the space filter even for a collab-secured class', async () => { + const { mw, captured } = makeMiddleware({ noAdapterManager: true, provideSecurity: true }) + const account = makeGuest() + ;(mw as any).allowedSpaces = { [account.uuid]: [S1] } + + await mw.findAll(makeCtx(account), ISSUE_CLASS, {}) + + expect(captured.query.space).toBeDefined() + expect(admittedSpaces(captured.query.space)).not.toContain(S2) + }) + }) +}) diff --git a/foundations/server/packages/middleware/src/tests/collaboratorGuard.test.ts b/foundations/server/packages/middleware/src/tests/collaboratorGuard.test.ts new file mode 100644 index 00000000000..381bd0673fd --- /dev/null +++ b/foundations/server/packages/middleware/src/tests/collaboratorGuard.test.ts @@ -0,0 +1,737 @@ +// +// Copyright © 2025 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. +// + +/** + * Tests for CollaboratorGuardMiddleware (design section 2.4). + * + * Verifies fail-closed authorization of client-initiated writes to + * access-granting Collaborator records on provideSecurity classes, while + * leaving System (trigger) writes and non-secured (notification) collaborators + * untouched. + */ + +import core, { + AccountRole, + generateId, + Hierarchy, + MeasureMetricsContext, + systemAccountUuid, + TxFactory, + type Account, + type AccountUuid, + type Class, + type Collaborator, + type AccessGroup, + type Doc, + type GroupGrant, + type MeasureContext, + type PersonId, + type Ref, + type SessionData, + type Space, + type Tx +} from '@hcengineering/core' +import type { PipelineContext, TxMiddlewareResult } from '@hcengineering/server-core' +import { CollaboratorGuardMiddleware } from '../collaboratorGuard' + +const SECURED_CLASS = 'test:class:Issue' as Ref> +const CHANNEL_CLASS = 'test:class:Channel' as Ref> +const ISSUE_SPACE = 'test:space:Issue' as Ref +const ISSUE_ID = 'test:doc:Issue1' as Ref + +type FindAllFn = (ctx: MeasureContext, _class: Ref>, query: any, options?: object) => Promise + +function uuid (): AccountUuid { + return generateId() as unknown as AccountUuid +} + +function makeAccount (role: AccountRole, id?: AccountUuid): Account { + const u = id ?? uuid() + return { + uuid: u, + role, + primarySocialId: 'test' as PersonId, + socialIds: ['test' as PersonId], + fullSocialIds: [] + } +} + +function makeCtx (account: Account): MeasureContext { + const ctx = new MeasureMetricsContext('test', {}) as MeasureContext + ctx.contextData = { + account, + broadcast: { txes: [], queue: [], sessions: {} } + } as any + return ctx +} + +function makePipelineContext (): PipelineContext { + const hierarchy = new Hierarchy() + const model = { findAllSync: (_class: any, _query: any) => [] } as any + return { + workspace: { uuid: 'test-workspace' as any, url: 'test', dataId: 'test' as any }, + hierarchy, + modelDb: model, + branding: null as any, + adapterManager: {} as any, + storageAdapter: {} as any, + contextVars: {}, + lastTx: '', + lastHash: '', + broadcastEvent: async () => {} + } as any +} + +/** + * Build a guard whose model opts SECURED_CLASS into provideSecurity (but NOT + * CHANNEL_CLASS). `findAll` supplies space + collaborator lookups. + */ +function makeMw ( + findAll: FindAllFn, + nextFn?: (ctx: MeasureContext, txes: Tx[]) => Promise +): CollaboratorGuardMiddleware { + const context = makePipelineContext() + const next = nextFn !== undefined ? { tx: nextFn } : { tx: async (_c: MeasureContext, _t: Tx[]) => ({}) } + const mw = new (CollaboratorGuardMiddleware as any)(context, next) + mw.findAll = findAll + mw.context.hierarchy.isDerived = (a: any, b: any) => a === b + mw.context.hierarchy.getAncestors = (id: any) => [id] + mw.context.modelDb = { + findAllSync: (_class: any, _q: any) => [{ attachedTo: SECURED_CLASS, provideSecurity: true }] + } + return mw +} + +function makeSpace (members: AccountUuid[], owners: AccountUuid[] = []): Space { + return { _id: ISSUE_SPACE, members, owners } as any +} + +/** findAll that serves an optional space and optional collaborators / group grants / access groups. */ +function serve (opts: { + space?: Space + /** The attachedTo target doc the guard loads to authorize against its REAL space. + * Defaults to an Issue living in ISSUE_SPACE (mirrors the tx objectSpace). */ + targetDoc?: Doc + collaborators?: Collaborator[] + groupGrants?: GroupGrant[] + accessGroups?: AccessGroup[] +}): FindAllFn { + return async (_ctx, _class, query) => { + if (_class === core.class.Space) { + return opts.space !== undefined && query?._id === opts.space._id ? [opts.space] : [] + } + if (_class === SECURED_CLASS) { + // The target doc the grant attaches to. Default lives in ISSUE_SPACE so the + // grant record's objectSpace (ISSUE_SPACE) mirrors it (the non-exploit case). + const doc = opts.targetDoc ?? ({ _id: ISSUE_ID, _class: SECURED_CLASS, space: ISSUE_SPACE } as any) + return query?._id === doc._id ? [doc] : [] + } + if (_class === core.class.Collaborator) { + let list = opts.collaborators ?? [] + if (query?._id !== undefined) list = list.filter((c) => c._id === query._id) + if (query?.attachedTo !== undefined) list = list.filter((c) => c.attachedTo === query.attachedTo) + if (query?.collaborator !== undefined) list = list.filter((c) => c.collaborator === query.collaborator) + return list as any + } + if (_class === core.class.GroupGrant) { + let list = opts.groupGrants ?? [] + if (query?._id !== undefined) list = list.filter((g) => g._id === query._id) + if (query?.group !== undefined) list = list.filter((g) => (g as any).group === query.group) + return list as any + } + if (_class === core.class.AccessGroup) { + let list = opts.accessGroups ?? [] + if (query?._id !== undefined) list = list.filter((g) => g._id === query._id) + return list as any + } + return [] + } +} + +function makeCreateTx ( + account: Account, + attrs: Partial, + targetClass: Ref> = SECURED_CLASS, + attachedTo: Ref = ISSUE_ID +): Tx { + const factory = new TxFactory(account.primarySocialId) + const inner = factory.createTxCreateDoc(core.class.Collaborator, ISSUE_SPACE, attrs as any) + return factory.createTxCollectionCUD(targetClass, attachedTo as any, ISSUE_SPACE, 'collaborators', inner) +} + +function makeRemoveTx (account: Account, collabId: Ref): Tx { + const factory = new TxFactory(account.primarySocialId) + return factory.createTxRemoveDoc(core.class.Collaborator, ISSUE_SPACE, collabId) +} + +function makeUpdateTx (account: Account, collabId: Ref): Tx { + const factory = new TxFactory(account.primarySocialId) + return factory.createTxUpdateDoc(core.class.Collaborator, ISSUE_SPACE, collabId, { level: 'admin' } as any) +} + +function makeGroupCreateTx ( + account: Account, + attrs: Partial, + targetClass: Ref> = SECURED_CLASS, + attachedTo: Ref = ISSUE_ID +): Tx { + const factory = new TxFactory(account.primarySocialId) + const inner = factory.createTxCreateDoc(core.class.GroupGrant, ISSUE_SPACE, attrs as any) + return factory.createTxCollectionCUD(targetClass, attachedTo as any, ISSUE_SPACE, 'groupGrants', inner) +} + +function makeGroupRemoveTx (account: Account, grantId: Ref): Tx { + const factory = new TxFactory(account.primarySocialId) + return factory.createTxRemoveDoc(core.class.GroupGrant, ISSUE_SPACE, grantId) +} + +function makeGroupUpdateTx (account: Account, grantId: Ref, operations: Record): Tx { + const factory = new TxFactory(account.primarySocialId) + return factory.createTxUpdateDoc(core.class.GroupGrant, ISSUE_SPACE, grantId, operations as any) +} + +function groupGrantRecord (over: Partial): GroupGrant { + return { + _id: over._id ?? generateId(), + _class: core.class.GroupGrant, + space: ISSUE_SPACE, + attachedTo: ISSUE_ID, + attachedToClass: SECURED_CLASS, + collection: 'groupGrants', + modifiedOn: Date.now(), + modifiedBy: 'test' as PersonId, + group: generateId() as any, + grantedBy: uuid(), + ...over + } as any +} + +const GROUP_ID = 'test:doc:Group1' as Ref + +function makeAccessGroupCreateTx (account: Account, attrs: Partial): Tx { + const factory = new TxFactory(account.primarySocialId) + return factory.createTxCreateDoc(core.class.AccessGroup, ISSUE_SPACE, attrs as any) +} + +function makeAccessGroupUpdateTx (account: Account, id: Ref, operations: Record): Tx { + const factory = new TxFactory(account.primarySocialId) + return factory.createTxUpdateDoc(core.class.AccessGroup, ISSUE_SPACE, id, operations as any) +} + +function makeAccessGroupRemoveTx (account: Account, id: Ref): Tx { + const factory = new TxFactory(account.primarySocialId) + return factory.createTxRemoveDoc(core.class.AccessGroup, ISSUE_SPACE, id) +} + +function accessGroupRecord (over: Partial): AccessGroup { + return { + _id: over._id ?? GROUP_ID, + _class: core.class.AccessGroup, + space: ISSUE_SPACE, + modifiedOn: Date.now(), + modifiedBy: 'test' as PersonId, + name: 'G', + members: [], + owners: [], + ...over + } as any +} + +function collabRecord (over: Partial): Collaborator { + return { + _id: over._id ?? generateId(), + _class: core.class.Collaborator, + space: ISSUE_SPACE, + attachedTo: ISSUE_ID, + attachedToClass: SECURED_CLASS, + collection: 'collaborators', + modifiedOn: Date.now(), + modifiedBy: 'test' as PersonId, + collaborator: uuid(), + ...over + } as any +} + +describe('CollaboratorGuardMiddleware', () => { + // ─── create ──────────────────────────────────────────────────────────────── + it('rejects manual grant create by non-member of the target space', async () => { + const actor = makeAccount(AccountRole.User) + const mw = makeMw(serve({ space: makeSpace([]) })) + const tx = makeCreateTx(actor, { collaborator: uuid(), grantedVia: 'manual', grantedBy: actor.uuid, level: 'read' }) + await expect(mw.tx(makeCtx(actor), [tx])).rejects.toThrow() + }) + + it('rejects create with grantedVia mention from client', async () => { + const actor = makeAccount(AccountRole.User) + const mw = makeMw(serve({ space: makeSpace([actor.uuid]) })) + const tx = makeCreateTx(actor, { + collaborator: uuid(), + grantedVia: 'mention' as any, + grantedBy: actor.uuid, + level: 'read' + }) + await expect(mw.tx(makeCtx(actor), [tx])).rejects.toThrow() + }) + + it('rejects create with grantedBy !== actor', async () => { + const actor = makeAccount(AccountRole.User) + const mw = makeMw(serve({ space: makeSpace([actor.uuid]) })) + const tx = makeCreateTx(actor, { collaborator: uuid(), grantedVia: 'manual', grantedBy: uuid(), level: 'read' }) + await expect(mw.tx(makeCtx(actor), [tx])).rejects.toThrow() + }) + + it('rejects create with invalid level string', async () => { + const actor = makeAccount(AccountRole.User) + const mw = makeMw(serve({ space: makeSpace([actor.uuid]) })) + const tx = makeCreateTx(actor, { + collaborator: uuid(), + grantedVia: 'manual', + grantedBy: actor.uuid, + level: 'superuser' as any + }) + await expect(mw.tx(makeCtx(actor), [tx])).rejects.toThrow() + }) + + it('allows manual read grant create by space member (role User)', async () => { + const actor = makeAccount(AccountRole.User) + let nextCalled = false + const mw = makeMw(serve({ space: makeSpace([actor.uuid]) }), async () => { + nextCalled = true + return {} + }) + const tx = makeCreateTx(actor, { collaborator: uuid(), grantedVia: 'manual', grantedBy: actor.uuid, level: 'read' }) + await mw.tx(makeCtx(actor), [tx]) + expect(nextCalled).toBe(true) + }) + + it('allows manual write grant create by space member', async () => { + const actor = makeAccount(AccountRole.User) + let nextCalled = false + const mw = makeMw(serve({ space: makeSpace([actor.uuid]) }), async () => { + nextCalled = true + return {} + }) + const tx = makeCreateTx(actor, { + collaborator: uuid(), + grantedVia: 'manual', + grantedBy: actor.uuid, + level: 'write' + }) + await mw.tx(makeCtx(actor), [tx]) + expect(nextCalled).toBe(true) + }) + + it('allows create by admin-level grantee of the doc (no space membership)', async () => { + const actor = makeAccount(AccountRole.User) + let nextCalled = false + const adminGrant = collabRecord({ collaborator: actor.uuid, grantedVia: 'manual', level: 'admin' }) + const mw = makeMw(serve({ space: makeSpace([]), collaborators: [adminGrant] }), async () => { + nextCalled = true + return {} + }) + const tx = makeCreateTx(actor, { collaborator: uuid(), grantedVia: 'manual', grantedBy: actor.uuid, level: 'read' }) + await mw.tx(makeCtx(actor), [tx]) + expect(nextCalled).toBe(true) + }) + + it('rejects create by non-admin-level grantee (read-only) of the doc', async () => { + const actor = makeAccount(AccountRole.User) + const readGrant = collabRecord({ collaborator: actor.uuid, grantedVia: 'manual', level: 'read' }) + const mw = makeMw(serve({ space: makeSpace([]), collaborators: [readGrant] })) + const tx = makeCreateTx(actor, { collaborator: uuid(), grantedVia: 'manual', grantedBy: actor.uuid, level: 'read' }) + await expect(mw.tx(makeCtx(actor), [tx])).rejects.toThrow() + }) + + it('rejects admin-level self-mint by a plain space member (H-NEW-01)', async () => { + const actor = makeAccount(AccountRole.User) + const mw = makeMw(serve({ space: makeSpace([actor.uuid]) })) + const tx = makeCreateTx(actor, { + collaborator: uuid(), + grantedVia: 'manual', + grantedBy: actor.uuid, + level: 'admin' + }) + await expect(mw.tx(makeCtx(actor), [tx])).rejects.toThrow() + }) + + it('allows admin-level grant by a space owner', async () => { + const actor = makeAccount(AccountRole.User) + let nextCalled = false + const mw = makeMw(serve({ space: makeSpace([actor.uuid], [actor.uuid]) }), async () => { + nextCalled = true + return {} + }) + const tx = makeCreateTx(actor, { + collaborator: uuid(), + grantedVia: 'manual', + grantedBy: actor.uuid, + level: 'admin' + }) + await mw.tx(makeCtx(actor), [tx]) + expect(nextCalled).toBe(true) + }) + + it('allows admin-level grant by a pre-existing admin-grantee', async () => { + const actor = makeAccount(AccountRole.User) + let nextCalled = false + const adminGrant = collabRecord({ collaborator: actor.uuid, grantedVia: 'manual', level: 'admin' }) + const mw = makeMw(serve({ space: makeSpace([]), collaborators: [adminGrant] }), async () => { + nextCalled = true + return {} + }) + const tx = makeCreateTx(actor, { + collaborator: uuid(), + grantedVia: 'manual', + grantedBy: actor.uuid, + level: 'admin' + }) + await mw.tx(makeCtx(actor), [tx]) + expect(nextCalled).toBe(true) + }) + + it('allows workspace Maintainer to grant even without space membership', async () => { + const actor = makeAccount(AccountRole.Maintainer) + let nextCalled = false + const mw = makeMw(serve({ space: makeSpace([]) }), async () => { + nextCalled = true + return {} + }) + const tx = makeCreateTx(actor, { + collaborator: uuid(), + grantedVia: 'manual', + grantedBy: actor.uuid, + level: 'admin' + }) + await mw.tx(makeCtx(actor), [tx]) + expect(nextCalled).toBe(true) + }) + + // ─── C-01: cross-space authority laundering ────────────────────────────────── + // The Postgres visibility grant keys on `attachedTo` alone (collab_sec.attachedTo + // = domain._id), independent of the grant record's own space. So authority MUST + // be judged against the target doc's REAL space, never the client-chosen + // objectSpace. A user who owns/joins space A must not be able to grant themselves + // access to a doc that lives in a foreign space B by writing the grant into A. + it('C-01: rejects manual grant whose objectSpace ≠ the target doc space', async () => { + const actor = makeAccount(AccountRole.User) + // actor OWNS ISSUE_SPACE (= tx objectSpace) but the target issue lives in a foreign space. + const foreignDoc = { _id: ISSUE_ID, _class: SECURED_CLASS, space: 'test:space:Foreign' as Ref } as any + const mw = makeMw(serve({ space: makeSpace([], [actor.uuid]), targetDoc: foreignDoc })) + const tx = makeCreateTx(actor, { + collaborator: uuid(), + grantedVia: 'manual', + grantedBy: actor.uuid, + level: 'admin' + }) + await expect(mw.tx(makeCtx(actor), [tx])).rejects.toThrow() + }) + + it('C-01: rejects GroupGrant whose objectSpace ≠ the target doc space', async () => { + const actor = makeAccount(AccountRole.User) + const foreignDoc = { _id: ISSUE_ID, _class: SECURED_CLASS, space: 'test:space:Foreign' as Ref } as any + const mw = makeMw(serve({ space: makeSpace([], [actor.uuid]), targetDoc: foreignDoc })) + const tx = makeGroupCreateTx(actor, { group: generateId() as any, grantedBy: actor.uuid, level: 'admin' }) + await expect(mw.tx(makeCtx(actor), [tx])).rejects.toThrow() + }) + + it('C-01 fail-closed: rejects manual grant when the target doc cannot be resolved', async () => { + const actor = makeAccount(AccountRole.User) + // Owner of the space, but the attachedTo doc does not exist → cannot prove its space. + const mw = makeMw( + serve({ + space: makeSpace([], [actor.uuid]), + targetDoc: { _id: 'test:doc:Absent' as Ref, _class: SECURED_CLASS, space: ISSUE_SPACE } as any + }) + ) + const tx = makeCreateTx(actor, { collaborator: uuid(), grantedVia: 'manual', grantedBy: actor.uuid, level: 'read' }) + await expect(mw.tx(makeCtx(actor), [tx])).rejects.toThrow() + }) + + // ─── system / trigger path ─────────────────────────────────────────────────── + it('allows System (trigger) writes untouched', async () => { + const sys = makeAccount(AccountRole.Owner, systemAccountUuid) + let nextCalled = false + const mw = makeMw(serve({}), async () => { + nextCalled = true + return {} + }) + // group-provenance create that a client could never do: + const tx = makeCreateTx(sys, { + collaborator: uuid(), + grantedVia: 'group', + grantedByGroup: generateId() + }) + await mw.tx(makeCtx(sys), [tx]) + expect(nextCalled).toBe(true) + }) + + // ─── remove ────────────────────────────────────────────────────────────────── + it('allows grantee self-remove of own mention-grant', async () => { + const actor = makeAccount(AccountRole.User) + let nextCalled = false + const rec = collabRecord({ collaborator: actor.uuid, grantedVia: 'mention', grantedByMessage: generateId() }) + const mw = makeMw(serve({ space: makeSpace([]), collaborators: [rec] }), async () => { + nextCalled = true + return {} + }) + await mw.tx(makeCtx(actor), [makeRemoveTx(actor, rec._id)]) + expect(nextCalled).toBe(true) + }) + + it('rejects grantee self-remove of structural collaborator', async () => { + const actor = makeAccount(AccountRole.User) + // structural = grantedVia undefined; actor is the collaborator but not owner/maintainer + const rec = collabRecord({ collaborator: actor.uuid }) + const mw = makeMw(serve({ space: makeSpace([]), collaborators: [rec] })) + await expect(mw.tx(makeCtx(actor), [makeRemoveTx(actor, rec._id)])).rejects.toThrow() + }) + + it('allows granting actor to revoke a manual grant', async () => { + const actor = makeAccount(AccountRole.User) + let nextCalled = false + const rec = collabRecord({ collaborator: uuid(), grantedVia: 'manual', grantedBy: actor.uuid }) + const mw = makeMw(serve({ space: makeSpace([]), collaborators: [rec] }), async () => { + nextCalled = true + return {} + }) + await mw.tx(makeCtx(actor), [makeRemoveTx(actor, rec._id)]) + expect(nextCalled).toBe(true) + }) + + it('rejects remove of a manual grant by an unrelated non-privileged user', async () => { + const stranger = makeAccount(AccountRole.User) + const rec = collabRecord({ collaborator: uuid(), grantedVia: 'manual', grantedBy: uuid() }) + const mw = makeMw(serve({ space: makeSpace([]), collaborators: [rec] })) + await expect(mw.tx(makeCtx(stranger), [makeRemoveTx(stranger, rec._id)])).rejects.toThrow() + }) + + it('allows space owner to remove a structural collaborator', async () => { + const owner = makeAccount(AccountRole.User) + let nextCalled = false + const rec = collabRecord({ collaborator: uuid() }) // structural + const mw = makeMw(serve({ space: makeSpace([], [owner.uuid]), collaborators: [rec] }), async () => { + nextCalled = true + return {} + }) + await mw.tx(makeCtx(owner), [makeRemoveTx(owner, rec._id)]) + expect(nextCalled).toBe(true) + }) + + it('fail-closed: reject remove when the record cannot be resolved', async () => { + const actor = makeAccount(AccountRole.User) + const mw = makeMw(serve({ space: makeSpace([]), collaborators: [] })) + await expect(mw.tx(makeCtx(actor), [makeRemoveTx(actor, generateId())])).rejects.toThrow() + }) + + // ─── update / immutability ──────────────────────────────────────────────────── + it('rejects TxUpdateDoc on grant records (immutable, level change = remove+create)', async () => { + const actor = makeAccount(AccountRole.Maintainer) + const rec = collabRecord({ collaborator: uuid(), grantedVia: 'manual', grantedBy: actor.uuid }) + const mw = makeMw(serve({ space: makeSpace([], [actor.uuid]), collaborators: [rec] })) + await expect(mw.tx(makeCtx(actor), [makeUpdateTx(actor, rec._id)])).rejects.toThrow() + }) + + // ─── non-secured (notification) classes pass through ────────────────────────── + it('passes through create of collaborators on non-provideSecurity classes (channels)', async () => { + const actor = makeAccount(AccountRole.User) + let nextCalled = false + // CHANNEL_CLASS is not opted into provideSecurity in the model mock. + const mw = makeMw(serve({}), async () => { + nextCalled = true + return {} + }) + const tx = makeCreateTx(actor, { collaborator: uuid() }, CHANNEL_CLASS, 'test:doc:Channel1' as Ref) + await mw.tx(makeCtx(actor), [tx]) + expect(nextCalled).toBe(true) + }) + + it('passes through remove of collaborators on non-provideSecurity classes (channels)', async () => { + const actor = makeAccount(AccountRole.User) + let nextCalled = false + const rec = collabRecord({ collaborator: uuid(), attachedToClass: CHANNEL_CLASS }) + const mw = makeMw(serve({ collaborators: [rec] }), async () => { + nextCalled = true + return {} + }) + await mw.tx(makeCtx(actor), [makeRemoveTx(actor, rec._id)]) + expect(nextCalled).toBe(true) + }) + + // ─── GroupGrant CUD (P4.1) ───────────────────────────────────────────────── + it('allows GroupGrant create by space owner', async () => { + const owner = makeAccount(AccountRole.User) + let nextCalled = false + const mw = makeMw(serve({ space: makeSpace([], [owner.uuid]) }), async () => { + nextCalled = true + return {} + }) + const tx = makeGroupCreateTx(owner, { group: generateId() as any, grantedBy: owner.uuid, level: 'read' }) + await mw.tx(makeCtx(owner), [tx]) + expect(nextCalled).toBe(true) + }) + + it('rejects GroupGrant create by a non-owner space member (role User, no ≥User path)', async () => { + const member = makeAccount(AccountRole.User) + const mw = makeMw(serve({ space: makeSpace([member.uuid]) })) + const tx = makeGroupCreateTx(member, { group: generateId() as any, grantedBy: member.uuid, level: 'read' }) + await expect(mw.tx(makeCtx(member), [tx])).rejects.toThrow() + }) + + it('allows GroupGrant create by an admin-level grantee of the doc', async () => { + const actor = makeAccount(AccountRole.User) + let nextCalled = false + const adminGrant = collabRecord({ collaborator: actor.uuid, grantedVia: 'manual', level: 'admin' }) + const mw = makeMw(serve({ space: makeSpace([]), collaborators: [adminGrant] }), async () => { + nextCalled = true + return {} + }) + const tx = makeGroupCreateTx(actor, { group: generateId() as any, grantedBy: actor.uuid, level: 'write' }) + await mw.tx(makeCtx(actor), [tx]) + expect(nextCalled).toBe(true) + }) + + it('rejects GroupGrant create with invalid level', async () => { + const owner = makeAccount(AccountRole.User) + const mw = makeMw(serve({ space: makeSpace([], [owner.uuid]) })) + const tx = makeGroupCreateTx(owner, { group: generateId() as any, grantedBy: owner.uuid, level: 'root' as any }) + await expect(mw.tx(makeCtx(owner), [tx])).rejects.toThrow() + }) + + it('allows GroupGrant level-only update (the one mutable field)', async () => { + const owner = makeAccount(AccountRole.User) + let nextCalled = false + const rec = groupGrantRecord({ level: 'read' }) + const mw = makeMw(serve({ space: makeSpace([], [owner.uuid]), groupGrants: [rec] }), async () => { + nextCalled = true + return {} + }) + await mw.tx(makeCtx(owner), [makeGroupUpdateTx(owner, rec._id, { level: 'write' })]) + expect(nextCalled).toBe(true) + }) + + it('rejects GroupGrant update of a non-level field', async () => { + const owner = makeAccount(AccountRole.Maintainer) + const rec = groupGrantRecord({ level: 'read' }) + const mw = makeMw(serve({ space: makeSpace([], [owner.uuid]), groupGrants: [rec] })) + await expect( + mw.tx(makeCtx(owner), [makeGroupUpdateTx(owner, rec._id, { group: generateId() as any })]) + ).rejects.toThrow() + }) + + it('rejects GroupGrant level update to an invalid value', async () => { + const owner = makeAccount(AccountRole.Maintainer) + const rec = groupGrantRecord({ level: 'read' }) + const mw = makeMw(serve({ space: makeSpace([], [owner.uuid]), groupGrants: [rec] })) + await expect(mw.tx(makeCtx(owner), [makeGroupUpdateTx(owner, rec._id, { level: 'root' })])).rejects.toThrow() + }) + + it('allows GroupGrant remove by workspace Maintainer', async () => { + const maint = makeAccount(AccountRole.Maintainer) + let nextCalled = false + const rec = groupGrantRecord({}) + const mw = makeMw(serve({ space: makeSpace([]), groupGrants: [rec] }), async () => { + nextCalled = true + return {} + }) + await mw.tx(makeCtx(maint), [makeGroupRemoveTx(maint, rec._id)]) + expect(nextCalled).toBe(true) + }) + + it('rejects GroupGrant remove by an unrelated non-privileged user', async () => { + const stranger = makeAccount(AccountRole.User) + const rec = groupGrantRecord({}) + const mw = makeMw(serve({ space: makeSpace([stranger.uuid]), groupGrants: [rec] })) + await expect(mw.tx(makeCtx(stranger), [makeGroupRemoveTx(stranger, rec._id)])).rejects.toThrow() + }) + + it('fail-closed: reject GroupGrant remove when the grant cannot be resolved', async () => { + const owner = makeAccount(AccountRole.Maintainer) + const mw = makeMw(serve({ space: makeSpace([]), groupGrants: [] })) + await expect(mw.tx(makeCtx(owner), [makeGroupRemoveTx(owner, generateId())])).rejects.toThrow() + }) + + it('rejects GroupGrant create on a non-secured class (fail-closed)', async () => { + const owner = makeAccount(AccountRole.Maintainer) + const mw = makeMw(serve({ space: makeSpace([], [owner.uuid]) })) + const tx = makeGroupCreateTx( + owner, + { group: generateId() as any, grantedBy: owner.uuid, level: 'read' }, + CHANNEL_CLASS, + 'test:doc:Channel1' as Ref + ) + await expect(mw.tx(makeCtx(owner), [tx])).rejects.toThrow() + }) + + // ─── AccessGroup CUD (P4.1/4.3) ──────────────────────────────────────────── + it('allows AccessGroup create when creator lists itself as owner', async () => { + const actor = makeAccount(AccountRole.User) + let nextCalled = false + const mw = makeMw(serve({}), async () => { + nextCalled = true + return {} + }) + await mw.tx(makeCtx(actor), [makeAccessGroupCreateTx(actor, { name: 'G', members: [], owners: [actor.uuid] })]) + expect(nextCalled).toBe(true) + }) + + it('rejects AccessGroup create when creator is not among owners (no orphan groups)', async () => { + const actor = makeAccount(AccountRole.User) + const mw = makeMw(serve({})) + await expect( + mw.tx(makeCtx(actor), [makeAccessGroupCreateTx(actor, { name: 'G', members: [], owners: [uuid()] })]) + ).rejects.toThrow() + }) + + it('allows AccessGroup membership update by an owner', async () => { + const owner = makeAccount(AccountRole.User) + let nextCalled = false + const g = accessGroupRecord({ owners: [owner.uuid] }) + const mw = makeMw(serve({ accessGroups: [g] }), async () => { + nextCalled = true + return {} + }) + await mw.tx(makeCtx(owner), [makeAccessGroupUpdateTx(owner, g._id, { $push: { members: uuid() } })]) + expect(nextCalled).toBe(true) + }) + + it('rejects AccessGroup membership update by a non-owner (escalation guard)', async () => { + const stranger = makeAccount(AccountRole.User) + const g = accessGroupRecord({ owners: [uuid()] }) + const mw = makeMw(serve({ accessGroups: [g] })) + await expect( + mw.tx(makeCtx(stranger), [makeAccessGroupUpdateTx(stranger, g._id, { $push: { members: stranger.uuid } })]) + ).rejects.toThrow() + }) + + it('rejects AccessGroup delete while a GroupGrant still references it', async () => { + const owner = makeAccount(AccountRole.User) + const g = accessGroupRecord({ owners: [owner.uuid] }) + const grant = groupGrantRecord({ group: g._id }) + const mw = makeMw(serve({ accessGroups: [g], groupGrants: [grant] })) + await expect(mw.tx(makeCtx(owner), [makeAccessGroupRemoveTx(owner, g._id)])).rejects.toThrow() + }) + + it('allows AccessGroup delete by owner when no GroupGrant references it', async () => { + const owner = makeAccount(AccountRole.User) + let nextCalled = false + const g = accessGroupRecord({ owners: [owner.uuid] }) + const mw = makeMw(serve({ accessGroups: [g], groupGrants: [] }), async () => { + nextCalled = true + return {} + }) + await mw.tx(makeCtx(owner), [makeAccessGroupRemoveTx(owner, g._id)]) + expect(nextCalled).toBe(true) + }) +}) diff --git a/foundations/server/packages/middleware/src/tests/guestPermissions.test.ts b/foundations/server/packages/middleware/src/tests/guestPermissions.test.ts index e124aee8cbe..7d4b293e14b 100644 --- a/foundations/server/packages/middleware/src/tests/guestPermissions.test.ts +++ b/foundations/server/packages/middleware/src/tests/guestPermissions.test.ts @@ -192,6 +192,7 @@ describe('GuestPermissionsMiddleware', () => { return a === b } ;(mw as any).context.hierarchy.classHierarchyMixin = () => undefined + ;(mw as any).context.hierarchy.getAncestors = () => [] } it('allows create for covered class in any space (TxAccessLevel is irrelevant)', async () => { @@ -276,6 +277,7 @@ describe('GuestPermissionsMiddleware', () => { if (b === core.class.Space) return false return a === b } + ;(mw as any).context.hierarchy.getAncestors = () => [] const tx = makeCreateTx(UNCOVERED_CLASS, ALLOWED_SPACE) const ctx = makeCtx(makeAccount(AccountRole.Guest)) @@ -310,6 +312,7 @@ describe('GuestPermissionsMiddleware', () => { if (b === core.class.Space) return false return a === b } + ;(mw as any).context.hierarchy.getAncestors = () => [] const tx = makeCreateTx(COVERED_CLASS, ALLOWED_SPACE) const ctx = makeCtx(makeAccount(AccountRole.Guest)) @@ -337,6 +340,7 @@ describe('GuestPermissionsMiddleware', () => { if (b === core.class.Space) return false return a === b } + ;(mw as any).context.hierarchy.getAncestors = () => [] const tx = makeCreateTx(COVERED_CLASS, FORBIDDEN_SPACE) const ctx = makeCtx(makeAccount(AccountRole.Guest)) @@ -364,6 +368,7 @@ describe('GuestPermissionsMiddleware', () => { if (b === core.class.Space) return false return a === b } + ;(mw as any).context.hierarchy.getAncestors = () => [] } it('allows guest to update document created by same account', async () => { @@ -468,6 +473,7 @@ describe('GuestPermissionsMiddleware', () => { return a === b } ;(mw as any).context.hierarchy.classHierarchyMixin = () => undefined + ;(mw as any).context.hierarchy.getAncestors = () => [] // First tx as guest should load cache const userCtx = makeCtx(makeAccount(AccountRole.User)) @@ -488,4 +494,176 @@ describe('GuestPermissionsMiddleware', () => { expect((mw as any).permissionsCache).toBeUndefined() }) }) + + // ─── collab-only guest veto: fail-closed (L-GP) + covers remove (L-RM) ──────── + describe('collab-only guest veto (mention-grants opt-in)', () => { + const ISSUE_CLASS = 'test:class:Issue' as Ref> + const ISSUE_SPACE = 'test:space:Issue' as Ref + + // Build a middleware whose model opts ISSUE_CLASS into mention-grants + // (provideSecurity + mentionsGrantAccess). `space` controls what findAll + // returns for the doc's space (undefined => unresolvable); `grants` is what a + // Collaborator lookup returns for the caller on the target doc. + function makeCollabMw (space: Space | undefined, grants: any[] = []): GuestPermissionsMiddleware { + const mw = makeMiddleware(async (_ctx, _class) => { + if (_class === core.class.Collaborator) return grants + return space !== undefined ? [space] : [] + }) + ;(mw as any).context.hierarchy.getAncestors = (id: any) => [id] + ;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => a === b + ;(mw as any).context.hierarchy.classHierarchyMixin = () => undefined + ;(mw as any).context.modelDb = { + findAllSync: (_class: any, _q: any) => [ + { attachedTo: ISSUE_CLASS, provideSecurity: true, mentionsGrantAccess: true } + ] + } + return mw + } + + // Spaces default to private: a non-member reaching an issue in a private space + // can only be there through a per-doc collaborator grant (the P2.2 capability), + // so it is subject to the level-aware write veto. Public spaces (private:false) + // are normal collaborative spaces where non-member members participate freely. + function makeSpace (members: any[], isPrivate = true): Space { + return { _id: ISSUE_SPACE, members, private: isPrivate } as any + } + + function grant (level?: 'read' | 'write' | 'admin'): any { + return { collaborator: 'test:collab', level } + } + + it('L-GP: fail-closed — unresolvable space forbids a guest field update', async () => { + const mw = makeCollabMw(undefined) + const guest = makeAccount(AccountRole.Guest) + const factory = new TxFactory('test:account:System' as PersonId) + const tx = factory.createTxUpdateDoc(ISSUE_CLASS, ISSUE_SPACE, generateId() as any, {}) + const forbidden = await (mw as any).isForbiddenCollabOnlyGuestFieldUpdate(makeCtx(guest), tx, guest) + expect(forbidden).toBe(true) + }) + + it('L-RM: collab-only guest TxRemoveDoc on opted-in doc is forbidden', async () => { + const guest = makeAccount(AccountRole.Guest) + const mw = makeCollabMw(makeSpace([])) // guest is NOT a space member + const factory = new TxFactory('test:account:System' as PersonId) + const tx = factory.createTxRemoveDoc(ISSUE_CLASS, ISSUE_SPACE, generateId() as any) + const forbidden = await (mw as any).isForbiddenCollabOnlyGuestFieldUpdate(makeCtx(guest), tx, guest) + expect(forbidden).toBe(true) + }) + + it('space-member guest is NOT vetoed (update stays allowed)', async () => { + const guest = makeAccount(AccountRole.Guest) + const mw = makeCollabMw(makeSpace([guest.uuid])) + const factory = new TxFactory('test:account:System' as PersonId) + const tx = factory.createTxUpdateDoc(ISSUE_CLASS, ISSUE_SPACE, generateId() as any, {}) + const forbidden = await (mw as any).isForbiddenCollabOnlyGuestFieldUpdate(makeCtx(guest), tx, guest) + expect(forbidden).toBe(false) + }) + + it('non-member User on a PUBLIC space passes the veto (normal collaboration)', async () => { + // Public-space non-members reach the doc through ordinary space visibility, + // not a per-doc grant, so their existing access is preserved unchanged. + const user = makeAccount(AccountRole.User) + const mw = makeCollabMw(makeSpace([], false)) + const factory = new TxFactory('test:account:System' as PersonId) + const tx = factory.createTxRemoveDoc(ISSUE_CLASS, ISSUE_SPACE, generateId() as any) + const forbidden = await (mw as any).isForbiddenCollabOnlyGuestFieldUpdate(makeCtx(user), tx, user) + expect(forbidden).toBe(false) + }) + + // ─── P2.3: level-aware write enforcement (read blocks, write/admin allows) ── + function updateTx (): Tx { + const factory = new TxFactory('test:account:System' as PersonId) + return factory.createTxUpdateDoc(ISSUE_CLASS, ISSUE_SPACE, generateId() as any, {}) + } + function removeTx (): Tx { + const factory = new TxFactory('test:account:System' as PersonId) + return factory.createTxRemoveDoc(ISSUE_CLASS, ISSUE_SPACE, generateId() as any) + } + + it('P2.3: collab-only User with READ grant cannot update issue fields', async () => { + const user = makeAccount(AccountRole.User) + const mw = makeCollabMw(makeSpace([]), [grant('read')]) + const forbidden = await (mw as any).isForbiddenCollabOnlyGuestFieldUpdate(makeCtx(user), updateTx(), user) + expect(forbidden).toBe(true) + }) + + it('P2.3: collab-only User with WRITE grant CAN update issue fields', async () => { + const user = makeAccount(AccountRole.User) + const mw = makeCollabMw(makeSpace([]), [grant('write')]) + const forbidden = await (mw as any).isForbiddenCollabOnlyGuestFieldUpdate(makeCtx(user), updateTx(), user) + expect(forbidden).toBe(false) + }) + + it('P2.3: collab-only User with ADMIN grant CAN update issue fields', async () => { + const user = makeAccount(AccountRole.User) + const mw = makeCollabMw(makeSpace([]), [grant('admin')]) + const forbidden = await (mw as any).isForbiddenCollabOnlyGuestFieldUpdate(makeCtx(user), updateTx(), user) + expect(forbidden).toBe(false) + }) + + it('P2.3: multiple grants — max level wins (mention read + manual write allows)', async () => { + const user = makeAccount(AccountRole.User) + const mw = makeCollabMw(makeSpace([]), [grant('read'), grant('write')]) + const forbidden = await (mw as any).isForbiddenCollabOnlyGuestFieldUpdate(makeCtx(user), updateTx(), user) + expect(forbidden).toBe(false) + }) + + it('P2.3: collab-only User with NO grant cannot update (private space)', async () => { + const user = makeAccount(AccountRole.User) + const mw = makeCollabMw(makeSpace([]), []) + const forbidden = await (mw as any).isForbiddenCollabOnlyGuestFieldUpdate(makeCtx(user), updateTx(), user) + expect(forbidden).toBe(true) + }) + + it('P2.3: collab-only User with WRITE grant still cannot REMOVE the issue', async () => { + // Deleting the doc is reserved for space members/owners; write/admin grants + // only free field updates, never a remove of the target doc itself. + const user = makeAccount(AccountRole.User) + const mw = makeCollabMw(makeSpace([]), [grant('write')]) + const forbidden = await (mw as any).isForbiddenCollabOnlyGuestFieldUpdate(makeCtx(user), removeTx(), user) + expect(forbidden).toBe(true) + }) + + // ─── C-02: the veto must actually fire in the tx() pipeline for role >= User, + // not only when the private method is called directly (the P2.3 tests above + // bypassed tx() and so missed the >= User early-return that skipped the veto). ── + it('C-02: collab-only User with READ grant is blocked at tx() (pipeline-level)', async () => { + const user = makeAccount(AccountRole.User) + const mw = makeCollabMw(makeSpace([]), [grant('read')]) + await expect(mw.tx(makeCtx(user), [updateTx()])).rejects.toThrow() + }) + + it('C-02: collab-only User with WRITE grant passes tx()', async () => { + const user = makeAccount(AccountRole.User) + const mw = makeCollabMw(makeSpace([]), [grant('write')]) + await expect(mw.tx(makeCtx(user), [updateTx()])).resolves.toBeDefined() + }) + + it('C-02: space member passes tx() field update (a member is never vetoed)', async () => { + const user = makeAccount(AccountRole.User) + const mw = makeCollabMw(makeSpace([user.uuid]), [grant('read')]) + await expect(mw.tx(makeCtx(user), [updateTx()])).resolves.toBeDefined() + }) + + it('P2.3: collab-only Guest with READ grant still cannot update (regression)', async () => { + const guest = makeAccount(AccountRole.Guest) + const mw = makeCollabMw(makeSpace([]), [grant('read')]) + const forbidden = await (mw as any).isForbiddenCollabOnlyGuestFieldUpdate(makeCtx(guest), updateTx(), guest) + expect(forbidden).toBe(true) + }) + + it('P2.3: collab-only Guest with WRITE grant CAN update fields (level is role-independent)', async () => { + const guest = makeAccount(AccountRole.Guest) + const mw = makeCollabMw(makeSpace([]), [grant('write')]) + const forbidden = await (mw as any).isForbiddenCollabOnlyGuestFieldUpdate(makeCtx(guest), updateTx(), guest) + expect(forbidden).toBe(false) + }) + + it('P2.3: Maintainer non-member of a private space passes (workspace-privileged)', async () => { + const maint = makeAccount(AccountRole.Maintainer) + const mw = makeCollabMw(makeSpace([]), []) + const forbidden = await (mw as any).isForbiddenCollabOnlyGuestFieldUpdate(makeCtx(maint), updateTx(), maint) + expect(forbidden).toBe(false) + }) + }) }) diff --git a/foundations/server/packages/postgres/src/__tests__/utils.spec.ts b/foundations/server/packages/postgres/src/__tests__/utils.spec.ts index 1940cc8d7ab..0ba06731eb4 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, @@ -816,3 +816,25 @@ describe('utils - edge cases and potential bugs', () => { expect(decodeArray('{" "}')).toEqual([' ']) }) }) + +// P1 Task 1.4: the Collaborator provenance/level fields are NOT columns of the +// collaborator schema (only attachedTo/attachedToClass/collaborator + base are). +// They therefore route through the `data` JSONB via isDataField === true, which +// is what makes findAll(Collaborator, { grantedVia, grantedByMessage, level }) +// queryable (transformKey emits `data#>>'{}'` for data fields). The +// revocation logic in P5 depends on this being provable without a schema change. +describe('utils - Collaborator provenance fields route through data JSONB', () => { + it('treats grantedVia / grantedBy / grantedByMessage / grantedByGroup / level as data fields', () => { + expect(isDataField(DOMAIN_COLLABORATOR, 'grantedVia')).toBe(true) + expect(isDataField(DOMAIN_COLLABORATOR, 'grantedBy')).toBe(true) + expect(isDataField(DOMAIN_COLLABORATOR, 'grantedByMessage')).toBe(true) + expect(isDataField(DOMAIN_COLLABORATOR, 'grantedByGroup')).toBe(true) + expect(isDataField(DOMAIN_COLLABORATOR, 'level')).toBe(true) + }) + + it('keeps attachedTo / attachedToClass / collaborator as indexed columns (not data fields)', () => { + expect(isDataField(DOMAIN_COLLABORATOR, 'attachedTo')).toBe(false) + expect(isDataField(DOMAIN_COLLABORATOR, 'attachedToClass')).toBe(false) + expect(isDataField(DOMAIN_COLLABORATOR, 'collaborator')).toBe(false) + }) +}) diff --git a/foundations/server/packages/postgres/src/storage.ts b/foundations/server/packages/postgres/src/storage.ts index c2fec296227..597e0d1d22e 100644 --- a/foundations/server/packages/postgres/src/storage.ts +++ b/foundations/server/packages/postgres/src/storage.ts @@ -210,6 +210,11 @@ class ValuesVariables { } abstract class PostgresAdapterBase implements DbAdapter { + // Postgres enforces collaborator-grant read security via addSecurity's collab + // OR-branch (see getSecurityClause below), so the space-security middleware may + // safely drop the space filter for collab-read bypasses on this backend. + readonly supportsCollaboratorSecurity = true + protected readonly _helper: DBCollectionHelper protected readonly tableFields = new Map() @@ -639,13 +644,40 @@ abstract class PostgresAdapterBase implements DbAdapter { const collabSec = getClassCollaborators(this.modelDb, this.hierarchy, _class) let collabRes = '' - if ([AccountRole.Guest, AccountRole.ReadOnlyGuest].includes(acc.role)) { - if (collabSec?.provideSecurity === true) { - collabRes += ` OR EXISTS (SELECT 1 FROM ${translateDomain(DOMAIN_COLLABORATOR)} collab_sec WHERE collab_sec."workspaceId" = ${vars.add(this.workspaceId, '::uuid')} AND collab_sec."attachedTo" = ${domain}._id AND collab_sec.collaborator = '${acc.uuid}')` - } - if (collabSec?.provideAttachedSecurity === true) { - collabRes += ` OR EXISTS (SELECT 1 FROM ${translateDomain(DOMAIN_COLLABORATOR)} collab_sec WHERE collab_sec."workspaceId" = ${vars.add(this.workspaceId, '::uuid')} AND collab_sec."attachedTo" = ${domain}."attachedTo" AND collab_sec.collaborator = '${acc.uuid}')` - } + // P2.2: the collab-grant read branch now applies to EVERY role that reaches + // here — Admin and DocGuest (and the system account) already returned above, + // so the remaining roles are ReadOnlyGuest/Guest/User/Maintainer/Owner. Widening + // from Guest-only makes a per-doc grant to a regular member effective. This is + // the primary enforcement of per-doc grants on Postgres: the space-security + // middleware drops its space filter for these roles (spaceSecurity.ts + // collabReadBypass) and relies on this OR-branch to re-restrict visibility to + // member spaces PLUS docs the caller holds a Collaborator record on. + if (collabSec?.provideSecurity === true) { + collabRes += ` OR EXISTS (SELECT 1 FROM ${translateDomain(DOMAIN_COLLABORATOR)} collab_sec WHERE collab_sec."workspaceId" = ${vars.add(this.workspaceId, '::uuid')} AND collab_sec."attachedTo" = ${domain}._id AND collab_sec.collaborator = '${acc.uuid}')` + } + if (collabSec?.provideAttachedSecurity === true) { + collabRes += ` OR EXISTS (SELECT 1 FROM ${translateDomain(DOMAIN_COLLABORATOR)} collab_sec WHERE collab_sec."workspaceId" = ${vars.add(this.workspaceId, '::uuid')} AND collab_sec."attachedTo" = ${domain}."attachedTo" AND collab_sec.collaborator = '${acc.uuid}')` + } + // Self-Collaborator visibility: any non-Admin/non-System caller can always read + // their own Collaborator records, regardless of space membership. Without this, + // a Guest who is a Collaborator on a doc in a project they are not a member of + // could never enumerate their own subscriptions (e.g. the tracker "Subscribed" + // tab queries Collaborator by `{collaborator: self}`). + if (domain === DOMAIN_COLLABORATOR) { + collabRes += ` OR ${domain}.collaborator = '${acc.uuid}'` + } + // Containing-Space visibility for collab-only Guests: surface Spaces that + // host docs the caller is a Collaborator on. Required for the project/space + // nav tree to list such projects (and for any code resolving the doc's + // parent space to succeed). The Collaborator record's `space` field always + // mirrors the parent doc's `space`, so existence of any such record naming + // the caller is sufficient evidence that the Space contains something they + // can see. + // P2.2: widened from Guest-only to every remaining role (Admin/DocGuest/system + // already returned above) so a private space is surfaced in the nav tree when a + // regular member only reaches it through a per-doc collaborator grant. + if (domain === DOMAIN_SPACE) { + collabRes += ` OR EXISTS (SELECT 1 FROM ${translateDomain(DOMAIN_COLLABORATOR)} space_collab WHERE space_collab."workspaceId" = ${vars.add(this.workspaceId, '::uuid')} AND space_collab.space = ${domain}._id AND space_collab.collaborator = '${acc.uuid}')` } return `AND (${res}${collabRes})` } diff --git a/models/all/package.json b/models/all/package.json index 7f2f7d2d923..e97d3fc66ac 100644 --- a/models/all/package.json +++ b/models/all/package.json @@ -59,6 +59,7 @@ "@hcengineering/model-server-notification": "workspace:^0.7.0", "@hcengineering/model-server-setting": "workspace:^0.7.0", "@hcengineering/model-server-chunter": "workspace:^0.7.0", + "@hcengineering/model-server-access-group": "workspace:^0.7.0", "@hcengineering/model-server-task": "workspace:^0.7.0", "@hcengineering/model-server-tracker": "workspace:^0.7.0", "@hcengineering/model-server-templates": "workspace:^0.7.0", diff --git a/models/all/src/index.ts b/models/all/src/index.ts index e33dfc98abd..54258345f83 100644 --- a/models/all/src/index.ts +++ b/models/all/src/index.ts @@ -48,6 +48,7 @@ import { serverAttachmentId, createModel as serverAttachmentModel } from '@hceng import { serverCalendarId, createModel as serverCalendarModel } from '@hcengineering/model-server-calendar' import { serverCardId, createModel as serverCardModel } from '@hcengineering/model-server-card' import { serverChunterId, createModel as serverChunterModel } from '@hcengineering/model-server-chunter' +import { serverAccessGroupId, createModel as serverAccessGroupModel } from '@hcengineering/model-server-access-group' import { serverCollaborationId, createModel as serverCollaborationModel @@ -541,6 +542,7 @@ export default function buildModel (): Builder { [serverContactModel, serverContactId], [serveSettingModel, serverSettingId], [serverChunterModel, serverChunterId], + [serverAccessGroupModel, serverAccessGroupId], [serverInventoryModel, serverInventoryId], [serverLeadModel, serverLeadId], [serverTagsModel, serverTagsId], diff --git a/models/core/src/core.ts b/models/core/src/core.ts index d6f4588a905..3f0cb48cf00 100644 --- a/models/core/src/core.ts +++ b/models/core/src/core.ts @@ -14,6 +14,8 @@ // import { + type AccessGroup, + type AccessLevel, type AccountUuid, type AnyAttribute, type ArrOf, @@ -24,12 +26,15 @@ import { type ClassCollaborators, type ClassifierKind, type Collaborator, + type CollaboratorProvenance, type Collection, + type GroupGrant, type Configuration, type ConfigurationElement, type CustomSequence, type Doc, type Domain, + DOMAIN_ACCESS_GROUP, DOMAIN_BLOB, DOMAIN_COLLABORATOR, DOMAIN_CONFIGURATION, @@ -64,12 +69,14 @@ import { type VersionableClass } from '@hcengineering/core' import { + ArrOf as ArrOfProp, Hidden, Index, Mixin as MMixin, Model, Prop, ReadOnly, + TypeAccountUuid, TypeBoolean, TypeFileSize, TypeIntlString, @@ -438,11 +445,40 @@ export class TClassCollaborators extends TDoc implements ClassCollaborators fields!: (keyof Doc)[] provideSecurity?: boolean provideAttachedSecurity?: boolean + mentionsGrantAccess?: boolean } @Model(core.class.Collaborator, core.class.Doc, DOMAIN_COLLABORATOR) export class TCollaborator extends TAttachedDoc implements Collaborator { collaborator!: AccountUuid + grantedVia?: CollaboratorProvenance + grantedBy?: AccountUuid + grantedByMessage?: Ref + grantedByGroup?: Ref // P4: sharpened from Ref + level?: AccessLevel +} + +@Model(core.class.AccessGroup, core.class.Doc, DOMAIN_ACCESS_GROUP) +export class TAccessGroup extends TDoc implements AccessGroup { + @Prop(TypeString(), core.string.Name) + @Index(IndexKind.FullText) + name!: string + + @Prop(TypeString(), core.string.Description) + description?: string + + @Prop(ArrOfProp(TypeAccountUuid()), core.string.Members) + members!: AccountUuid[] + + @Prop(ArrOfProp(TypeAccountUuid()), core.string.Owners) + owners!: AccountUuid[] +} + +@Model(core.class.GroupGrant, core.class.AttachedDoc, DOMAIN_ACCESS_GROUP) +export class TGroupGrant extends TAttachedDoc implements GroupGrant { + group!: Ref + grantedBy!: AccountUuid + level?: AccessLevel } @MMixin(core.mixin.VersionableClass, core.class.Class) diff --git a/models/core/src/index.ts b/models/core/src/index.ts index cebdc42862f..6cb01336e9d 100644 --- a/models/core/src/index.ts +++ b/models/core/src/index.ts @@ -27,6 +27,7 @@ import { type Builder } from '@hcengineering/model' import { TBenchmarkDoc } from './benchmark' import core from './component' import { + TAccessGroup, TArrOf, TAssociation, TAttachedDoc, @@ -36,6 +37,7 @@ import { TClassCollaborators, TCollaborator, TCollection, + TGroupGrant, TConfiguration, TConfigurationElement, TCustomSequence, @@ -187,6 +189,8 @@ export function createModel (builder: Builder): void { TTransientConfiguration, TClassCollaborators, TCollaborator, + TAccessGroup, + TGroupGrant, TVersionableClass ) diff --git a/models/server-access-group/.eslintrc.js b/models/server-access-group/.eslintrc.js new file mode 100644 index 00000000000..c1cf82cba08 --- /dev/null +++ b/models/server-access-group/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/model/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/models/server-access-group/.npmignore b/models/server-access-group/.npmignore new file mode 100644 index 00000000000..e3ec093c383 --- /dev/null +++ b/models/server-access-group/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/models/server-access-group/jest.config.js b/models/server-access-group/jest.config.js new file mode 100644 index 00000000000..2cfd408b679 --- /dev/null +++ b/models/server-access-group/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/models/server-access-group/package.json b/models/server-access-group/package.json new file mode 100644 index 00000000000..ec6c6083aeb --- /dev/null +++ b/models/server-access-group/package.json @@ -0,0 +1,43 @@ +{ + "name": "@hcengineering/model-server-access-group", + "version": "0.7.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "author": "Anticrm Platform Contributors", + "template": "@hcengineering/model-package", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:format": "format src", + "_phase:validate": "compile validate", + "_phase:test": "jest --passWithNoTests --silent --forceExit", + "test": "jest --passWithNoTests --silent --forceExit" + }, + "devDependencies": { + "@hcengineering/platform-rig": "workspace:^0.7.21", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.6.2", + "typescript": "^5.9.3", + "@types/node": "^22.18.1", + "jest": "^29.7.0", + "@types/jest": "^29.5.5", + "ts-jest": "^29.1.1" + }, + "dependencies": { + "@hcengineering/core": "workspace:^0.7.26", + "@hcengineering/model": "workspace:^0.7.17", + "@hcengineering/platform": "workspace:^0.7.20", + "@hcengineering/server-access-group": "workspace:^0.7.0", + "@hcengineering/server-core": "workspace:^0.7.19" + } +} diff --git a/models/server-access-group/src/index.ts b/models/server-access-group/src/index.ts new file mode 100644 index 00000000000..a63e9dc2470 --- /dev/null +++ b/models/server-access-group/src/index.ts @@ -0,0 +1,44 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { type Builder } from '@hcengineering/model' + +import core from '@hcengineering/core' +import serverCore from '@hcengineering/server-core' +import serverAccessGroup from '@hcengineering/server-access-group' + +export { serverAccessGroupId } from '@hcengineering/server-access-group' + +export function createModel (builder: Builder): void { + // GroupGrant create / remove / level-update → materialize + reconcile the + // derived group-collaborators synchronously (immediate visibility). + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverAccessGroup.trigger.OnGroupGrantChanged, + txMatch: { + objectClass: core.class.GroupGrant + } + }) + + // AccessGroup membership change → re-reconcile every grant of the group. + // Async: a membership edit may fan out across many grants / docs. + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverAccessGroup.trigger.OnAccessGroupChanged, + txMatch: { + _class: core.class.TxUpdateDoc, + objectClass: core.class.AccessGroup + }, + isAsync: true + }) +} diff --git a/models/server-access-group/tsconfig.json b/models/server-access-group/tsconfig.json new file mode 100644 index 00000000000..367a8578c9f --- /dev/null +++ b/models/server-access-group/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/model/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} \ No newline at end of file diff --git a/models/setting/src/index.ts b/models/setting/src/index.ts index 52c15d8ec79..22ee93e8c1e 100644 --- a/models/setting/src/index.ts +++ b/models/setting/src/index.ts @@ -338,6 +338,19 @@ export function createModel (builder: Builder): void { }, setting.ids.Spaces ) + builder.createDoc( + setting.class.WorkspaceSettingCategory, + core.space.Model, + { + name: 'accessGroups', + label: setting.string.AccessGroups, + icon: setting.icon.Members, + component: setting.component.AccessGroups, + order: 1150, + role: AccountRole.Maintainer + }, + 'setting:ids:AccessGroups' as Ref + ) builder.createDoc( setting.class.WorkspaceSettingCategory, core.space.Model, diff --git a/models/tracker/src/index.ts b/models/tracker/src/index.ts index baabea1381a..1296c8c1f17 100644 --- a/models/tracker/src/index.ts +++ b/models/tracker/src/index.ts @@ -523,7 +523,17 @@ export function createModel (builder: Builder): void { builder.createDoc>(core.class.ClassCollaborators, core.space.Model, { attachedTo: tracker.class.Issue, - fields: ['createdBy', 'assignee'] + fields: ['createdBy', 'assignee'], + // Collaborator status grants read visibility on the issue, bypassing + // project-space membership. Used so a user @-mentioned on an issue + // they are not a project member of can actually see and comment on it. + provideSecurity: true, + // @-mentions in chat/activity messages on an issue auto-create a + // Collaborator record (server-plugins/chunter-resources). Combined + // with provideSecurity above, the mentioned user gets explicit, + // disclosed read+comment access. Field writes remain blocked for + // collab-only guests by the GuestPermissions middleware veto. + mentionsGrantAccess: true }) builder.mixin(tracker.class.Issue, core.class.Class, setting.mixin.Editable, { diff --git a/plugins/chunter-assets/lang/cs.json b/plugins/chunter-assets/lang/cs.json index 55d51da4246..c5411a48fc2 100644 --- a/plugins/chunter-assets/lang/cs.json +++ b/plugins/chunter-assets/lang/cs.json @@ -132,6 +132,7 @@ "ViewingArchivedChannel": "Prohlížíte archivovaný kanál", "OpenChatInSidebar": "Otevřít chat v postranním panelu", "NoThreadsYet": "Zatím nejsou žádné vlákna.", - "SummarizeMessages": "Shrnout diskuzi" + "SummarizeMessages": "Shrnout diskuzi", + "MentionGrantsAccessHint": "A mention here will grant that person access to this item. You will confirm exactly who before sending." } } diff --git a/plugins/chunter-assets/lang/de.json b/plugins/chunter-assets/lang/de.json index e50384368c0..5a9ebcf5bf8 100644 --- a/plugins/chunter-assets/lang/de.json +++ b/plugins/chunter-assets/lang/de.json @@ -1,5 +1,6 @@ { "string": { + "MentionGrantsAccessHint": "Eine Erwähnung hier gewährt dieser Person Zugriff auf dieses Element. Vor dem Senden bestätigst du genau, wer.", "ApplicationLabelChunter": "Chat", "LeftComment": "hat einen Kommentar hinterlassen", "Channels": "Kanäle", diff --git a/plugins/chunter-assets/lang/en.json b/plugins/chunter-assets/lang/en.json index a4452eb3f82..cee8b7c0b44 100644 --- a/plugins/chunter-assets/lang/en.json +++ b/plugins/chunter-assets/lang/en.json @@ -1,5 +1,6 @@ { "string": { + "MentionGrantsAccessHint": "A mention here will grant that person access to this item. You will confirm exactly who before sending.", "ApplicationLabelChunter": "Chat", "LeftComment": "left a comment", "Channels": "Channels", diff --git a/plugins/chunter-assets/lang/es.json b/plugins/chunter-assets/lang/es.json index 6ef448ca415..a140ef37a56 100644 --- a/plugins/chunter-assets/lang/es.json +++ b/plugins/chunter-assets/lang/es.json @@ -132,6 +132,7 @@ "ViewingArchivedChannel": "Estás viendo un canal archivado", "OpenChatInSidebar": "Abrir chat en la barra lateral", "NoThreadsYet": "No hay hilos todavía.", - "SummarizeMessages": "Resumir la discusión" + "SummarizeMessages": "Resumir la discusión", + "MentionGrantsAccessHint": "A mention here will grant that person access to this item. You will confirm exactly who before sending." } } diff --git a/plugins/chunter-assets/lang/fr.json b/plugins/chunter-assets/lang/fr.json index 98751bef78c..71f8eccbce7 100644 --- a/plugins/chunter-assets/lang/fr.json +++ b/plugins/chunter-assets/lang/fr.json @@ -132,6 +132,7 @@ "ViewingArchivedChannel": "Vous consultez un canal archivé", "OpenChatInSidebar": "Ouvrir le chat dans la barre latérale", "NoThreadsYet": "Il n'y a pas encore de fils de discussion.", - "SummarizeMessages": "Résumer la discussion" + "SummarizeMessages": "Résumer la discussion", + "MentionGrantsAccessHint": "A mention here will grant that person access to this item. You will confirm exactly who before sending." } } diff --git a/plugins/chunter-assets/lang/it.json b/plugins/chunter-assets/lang/it.json index 4629dab131d..c7d9df14cb8 100644 --- a/plugins/chunter-assets/lang/it.json +++ b/plugins/chunter-assets/lang/it.json @@ -132,6 +132,7 @@ "ViewingArchivedChannel": "Stai visualizzando un canale archiviato", "OpenChatInSidebar": "Apri chat nella barra laterale", "NoThreadsYet": "Non ci sono ancora discussioni.", - "SummarizeMessages": "Riassumere la discussione" + "SummarizeMessages": "Riassumere la discussione", + "MentionGrantsAccessHint": "A mention here will grant that person access to this item. You will confirm exactly who before sending." } } diff --git a/plugins/chunter-assets/lang/ja.json b/plugins/chunter-assets/lang/ja.json index c7a3549aa2a..7e0b495700c 100644 --- a/plugins/chunter-assets/lang/ja.json +++ b/plugins/chunter-assets/lang/ja.json @@ -133,6 +133,7 @@ "OpenChatInSidebar": "サイドバーでチャットを開く", "ResolveThread": "解決", "NoThreadsYet": "まだスレッドはありません。", - "SummarizeMessages": "内容を要約" + "SummarizeMessages": "内容を要約", + "MentionGrantsAccessHint": "A mention here will grant that person access to this item. You will confirm exactly who before sending." } } diff --git a/plugins/chunter-assets/lang/ko.json b/plugins/chunter-assets/lang/ko.json index 718e6f20634..b7003f03f1b 100644 --- a/plugins/chunter-assets/lang/ko.json +++ b/plugins/chunter-assets/lang/ko.json @@ -133,6 +133,7 @@ "OpenChatInSidebar": "사이드바에서 채팅 열기", "ResolveThread": "해결", "NoThreadsYet": "아직 스레드가 없습니다.", - "SummarizeMessages": "내용 요약" + "SummarizeMessages": "내용 요약", + "MentionGrantsAccessHint": "A mention here will grant that person access to this item. You will confirm exactly who before sending." } } diff --git a/plugins/chunter-assets/lang/pl.json b/plugins/chunter-assets/lang/pl.json index 87d5e7daf7b..a0ddcd7e6df 100644 --- a/plugins/chunter-assets/lang/pl.json +++ b/plugins/chunter-assets/lang/pl.json @@ -133,6 +133,7 @@ "OpenChatInSidebar": "Otwórz czat w panelu bocznym", "ResolveThread": "Rozwiąż dyskusję", "NoThreadsYet": "Nie ma jeszcze wątków.", - "SummarizeMessages": "Podsumuj dyskusję" + "SummarizeMessages": "Podsumuj dyskusję", + "MentionGrantsAccessHint": "A mention here will grant that person access to this item. You will confirm exactly who before sending." } } diff --git a/plugins/chunter-assets/lang/pt-br.json b/plugins/chunter-assets/lang/pt-br.json index c9e9d7155ae..a137c73291a 100644 --- a/plugins/chunter-assets/lang/pt-br.json +++ b/plugins/chunter-assets/lang/pt-br.json @@ -132,6 +132,7 @@ "ViewingArchivedChannel": "Você está vendo um canal arquivado", "OpenChatInSidebar": "Abrir chat na barra lateral", "NoThreadsYet": "Não há conversas ainda.", - "SummarizeMessages": "Resumir discussão" + "SummarizeMessages": "Resumir discussão", + "MentionGrantsAccessHint": "A mention here will grant that person access to this item. You will confirm exactly who before sending." } } diff --git a/plugins/chunter-assets/lang/pt.json b/plugins/chunter-assets/lang/pt.json index 64bfe659d10..ae60972c121 100644 --- a/plugins/chunter-assets/lang/pt.json +++ b/plugins/chunter-assets/lang/pt.json @@ -132,6 +132,7 @@ "ViewingArchivedChannel": "Está a visualizar um canal arquivado", "OpenChatInSidebar": "Abrir chat na barra lateral", "NoThreadsYet": "Não há conversas ainda.", - "SummarizeMessages": "Resumir discussão" + "SummarizeMessages": "Resumir discussão", + "MentionGrantsAccessHint": "A mention here will grant that person access to this item. You will confirm exactly who before sending." } } diff --git a/plugins/chunter-assets/lang/ru.json b/plugins/chunter-assets/lang/ru.json index 2e04281f627..554d6db625a 100644 --- a/plugins/chunter-assets/lang/ru.json +++ b/plugins/chunter-assets/lang/ru.json @@ -133,6 +133,7 @@ "OpenChatInSidebar": "Открыть чат в боковой панели", "ResolveThread": "Пометить завершенным", "NoThreadsYet": "Пока нет обсуждений.", - "SummarizeMessages": "Подвести итоги обсуждения" + "SummarizeMessages": "Подвести итоги обсуждения", + "MentionGrantsAccessHint": "A mention here will grant that person access to this item. You will confirm exactly who before sending." } } diff --git a/plugins/chunter-assets/lang/tr.json b/plugins/chunter-assets/lang/tr.json index 358ad9f13c9..ba367a33fc1 100644 --- a/plugins/chunter-assets/lang/tr.json +++ b/plugins/chunter-assets/lang/tr.json @@ -133,6 +133,7 @@ "OpenChatInSidebar": "Sohbeti kenar çubuğunda aç", "ResolveThread": "Çöz", "NoThreadsYet": "Henüz başlık yok.", - "SummarizeMessages": "Tartışmayı özetle" + "SummarizeMessages": "Tartışmayı özetle", + "MentionGrantsAccessHint": "A mention here will grant that person access to this item. You will confirm exactly who before sending." } } diff --git a/plugins/chunter-assets/lang/zh.json b/plugins/chunter-assets/lang/zh.json index 6a959abfa9f..4bb7388ea36 100644 --- a/plugins/chunter-assets/lang/zh.json +++ b/plugins/chunter-assets/lang/zh.json @@ -132,6 +132,7 @@ "ViewingArchivedChannel": "你正在查看已归档频道", "OpenChatInSidebar": "在侧边栏中打开聊天", "NoThreadsYet": "还没有线程。", - "SummarizeMessages": "总结讨论" + "SummarizeMessages": "总结讨论", + "MentionGrantsAccessHint": "A mention here will grant that person access to this item. You will confirm exactly who before sending." } } diff --git a/plugins/chunter-resources/package.json b/plugins/chunter-resources/package.json index 05123872f19..d95e6df65fe 100644 --- a/plugins/chunter-resources/package.json +++ b/plugins/chunter-resources/package.json @@ -11,8 +11,10 @@ "svelte-check": "do-svelte-check", "_phase:svelte-check": "do-svelte-check", "build:watch": "compile ui", + "test": "jest --passWithNoTests --silent", "_phase:build": "compile ui", "_phase:format": "format src", + "_phase:test": "jest --passWithNoTests --silent", "_phase:validate": "compile validate" }, "devDependencies": { @@ -57,6 +59,7 @@ "@hcengineering/preference": "workspace:^0.7.0", "@hcengineering/presentation": "workspace:^0.7.0", "@hcengineering/text": "workspace:^0.7.19", + "@hcengineering/text-core": "workspace:^0.7.19", "@hcengineering/ui": "workspace:^0.7.0", "@hcengineering/view": "workspace:^0.7.0", "@hcengineering/view-resources": "workspace:^0.7.0", diff --git a/plugins/chunter-resources/src/__tests__/mentionGrants.test.ts b/plugins/chunter-resources/src/__tests__/mentionGrants.test.ts new file mode 100644 index 00000000000..f2cf32007ad --- /dev/null +++ b/plugins/chunter-resources/src/__tests__/mentionGrants.test.ts @@ -0,0 +1,65 @@ +import { markupToJSON, jsonToMarkup, type MarkupNode, MarkupNodeType } from '@hcengineering/text-core' +import { applyMentionGrantChoices } from '../mentionGrants' + +function refNode (id: string): MarkupNode { + return { type: MarkupNodeType.reference, attrs: { id, label: id, objectclass: 'contact:class:Person' } } +} +function docMarkup (...refs: MarkupNode[]): string { + return jsonToMarkup({ type: MarkupNodeType.doc, content: refs }) +} +function grantsOf (markup: string, id: string): Array<'true' | 'false' | undefined> { + const node = markupToJSON(markup) + const out: Array<'true' | 'false' | undefined> = [] + const walk = (n: MarkupNode): void => { + if (n.type === MarkupNodeType.reference && n.attrs?.id === id) { + out.push(n.attrs.grantsAccess as 'true' | 'false' | undefined) + } + n.content?.forEach(walk) + } + walk(node) + return out +} + +describe('applyMentionGrantChoices', () => { + test('deselect sets grantsAccess=false on the person reference', () => { + const result = applyMentionGrantChoices(docMarkup(refNode('p1')), new Map([['p1', false]])) + expect(grantsOf(result, 'p1')).toEqual(['false']) + }) + + test('select sets grantsAccess=true explicitly', () => { + const result = applyMentionGrantChoices(docMarkup(refNode('p1')), new Map([['p1', true]])) + expect(grantsOf(result, 'p1')).toEqual(['true']) + }) + + test('deselect rewrites EVERY reference to that person', () => { + const result = applyMentionGrantChoices(docMarkup(refNode('p1'), refNode('p1')), new Map([['p1', false]])) + expect(grantsOf(result, 'p1')).toEqual(['false', 'false']) + }) + + test('persons absent from the choice map are left unchanged', () => { + const result = applyMentionGrantChoices(docMarkup(refNode('p1'), refNode('p2')), new Map([['p1', false]])) + expect(grantsOf(result, 'p1')).toEqual(['false']) + expect(grantsOf(result, 'p2')).toEqual([undefined]) + }) + + test('empty choice map is a no-op', () => { + const markup = docMarkup(refNode('p1')) + const result = applyMentionGrantChoices(markup, new Map()) + expect(grantsOf(result, 'p1')).toEqual([undefined]) + }) + + test('fail-closed default (all grantees unchecked) produces no grantsAccess=true refs', () => { + // MentionGrantConfirm now defaults every row to `granted: false`; sending + // without checking anyone must mark every reference denied — the server + // grants only on grantsAccess === 'true', so nothing is granted. + const defaultUnchecked = new Map([ + ['p1', false], + ['p2', false] + ]) + const result = applyMentionGrantChoices(docMarkup(refNode('p1'), refNode('p2')), defaultUnchecked) + expect(grantsOf(result, 'p1')).toEqual(['false']) + expect(grantsOf(result, 'p2')).toEqual(['false']) + const anyTrue = [...grantsOf(result, 'p1'), ...grantsOf(result, 'p2')].some((v) => v === 'true') + expect(anyTrue).toBe(false) + }) +}) diff --git a/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte b/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte index 4f9c51ed2c4..a8bc56a3e66 100644 --- a/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte +++ b/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte @@ -17,16 +17,30 @@ import { Analytics } from '@hcengineering/analytics' import { AttachmentRefInput } from '@hcengineering/attachment-resources' import chunter, { ChatMessage, ChunterEvents, ThreadMessage } from '@hcengineering/chunter' - import { Class, Doc, generateId, getCurrentAccount, Ref, type CommitResult } from '@hcengineering/core' + import contact, { type Person } from '@hcengineering/contact' + import core, { + type AccountUuid, + Class, + Doc, + generateId, + getCurrentAccount, + Ref, + resolveMentionGrantTarget, + type Space, + type CommitResult + } from '@hcengineering/core' import { createQuery, DraftController, draftsStore, getClient } from '@hcengineering/presentation' - import { EmptyMarkup, isEmptyMarkup } from '@hcengineering/text' + import { EmptyMarkup, isEmptyMarkup, markupToJSON } from '@hcengineering/text' + import { extractReferences } from '@hcengineering/text-core' import { createEventDispatcher } from 'svelte' import { getObjectId } from '@hcengineering/view-resources' - import { ThrottledCaller } from '@hcengineering/ui' + import { showPopup, ThrottledCaller, Label } from '@hcengineering/ui' import { getSpace, editingMessageStore } from '@hcengineering/activity-resources' import { getChannelSpace } from '../../utils' + import { applyMentionGrantChoices } from '../../mentionGrants' import ChannelTypingInfo from '../ChannelTypingInfo.svelte' + import MentionGrantConfirm from './MentionGrantConfirm.svelte' export let object: Doc export let chatMessage: ChatMessage | undefined = undefined @@ -126,47 +140,152 @@ currentMessage.attachments = attachments } - async function handleCreate (event: CustomEvent, _id: Ref): Promise { + // Inline "this mention grants access" indicator (M-G.5). Shown while typing + // whenever the target document is a grant target AND the current draft + // contains a non-denied person mention. The binding confirmation dialog + // (MentionGrantConfirm) remains the authoritative, fail-closed consent step + // before send; this is only an early, dezent heads-up. + let objectIsGrantTarget = false + $: void resolveMentionGrantTarget(object, (cls, q) => client.findAll(cls, q)) + .then((t) => { + objectIsGrantTarget = t != null + }) + .catch(() => { + objectIsGrantTarget = false + }) + + function hasGrantingMention (markup: string | undefined): boolean { + if (markup === undefined || markup === '' || isEmptyMarkup(markup)) return false + let node + try { + node = markupToJSON(markup) + } catch { + return false + } + return extractReferences(node) + .filter(({ objectClass }) => hierarchy.isDerived(objectClass, contact.class.Person)) + .some(({ grantsAccess }) => grantsAccess !== 'false') + } + + $: showGrantWarning = objectIsGrantTarget && hasGrantingMention(inputContent) + + /** + * Disclosure UX for the mention-grants-access flow. Resolves the set of NEW + * grantees (mentioned Persons not already in the grant-target space) and asks + * the actor to confirm/deselect each. Returns: + * - null => cancel (do not send) + * - Map => send; map is personId -> grant choice (true/false). Empty map + * when there is nothing to disclose (send unchanged). + * The actual access grant happens server-side via the chunter trigger; this + * dialog only discloses + lets the actor opt specific people out. + */ + async function resolveMentionGrantChoices (markup: string): Promise | null> { + if (markup === undefined || markup === '' || isEmptyMarkup(markup)) return new Map() + let node try { + node = markupToJSON(markup) + } catch { + return new Map() + } + const references = extractReferences(node) + const mentionedPersonIds = references + .filter(({ objectClass }) => hierarchy.isDerived(objectClass, contact.class.Person)) + .filter(({ grantsAccess }) => grantsAccess !== 'false') // V3c: already-denied refs need no disclosure + .map(({ objectId }) => objectId as Ref) + if (mentionedPersonIds.length === 0) return new Map() + + const grantTarget = await resolveMentionGrantTarget(object, (cls, q) => client.findAll(cls, q)) + if (grantTarget == null) return new Map() + + const space = (await client.findAll(core.class.Space, { _id: grantTarget.space }))[0] + if (space === undefined) return new Map() + const members = new Set(space.members ?? []) + + const persons = await client.findAll(contact.class.Person, { _id: { $in: mentionedPersonIds } }) + const newGrantees = persons.filter((p) => p.personUuid != null && !members.has(p.personUuid as AccountUuid)) + if (newGrantees.length === 0) return new Map() + + const targetName: string = (grantTarget as any).name ?? (grantTarget as any).title ?? grantTarget._id + const spaceName: string = (space as any).name ?? space._id + const grantees = newGrantees.map((p) => ({ id: p._id, name: p.name ?? String(p.personUuid) })) + + return await new Promise | null>((resolve) => { + showPopup(MentionGrantConfirm, { grantees, targetName, spaceName }, undefined, (res?: Map) => { + resolve(res instanceof Map ? res : null) + }) + }) + } + + // Runs the mention-grants disclosure for an outgoing/edited message and + // rewrites event.detail.message with the actor's per-grantee choices. + // Returns false if the actor cancelled (caller must abort the send). + async function prepareMentionGrantChoices (event: CustomEvent): Promise { + const markup = event.detail?.message + const choices = await resolveMentionGrantChoices(typeof markup === 'string' ? markup : '') + if (choices === null) return false // actor cancelled + if (choices.size > 0 && typeof markup === 'string') { + event.detail.message = applyMentionGrantChoices(markup, choices) + } + return true + } + + // Returns true if the message was sent, false if the actor cancelled the + // grant dialog or the send failed. The caller (onMessage) must only clear + // the draft/input on a true result, otherwise a cancelled grant dialog + // would lose the unsent comment. + async function handleCreate (event: CustomEvent, _id: Ref): Promise { + try { + if (!(await prepareMentionGrantChoices(event))) return false + const res = await createMessage(event, _id, `chunter.create.${_class} ${object._class}`) console.log(`create.${_class} measure`, res.serverTime, res.time) const objectId = await getObjectId(object, client.getHierarchy()) Analytics.handleEvent(ChunterEvents.MessageCreated, { ok: res.result, objectId, objectClass: object._class }) + return true } catch (err: any) { const objectId = await getObjectId(object, client.getHierarchy()) Analytics.handleEvent(ChunterEvents.MessageCreated, { ok: false, objectId, objectClass: object._class }) Analytics.handleError(err) + return false } } - async function handleEdit (event: CustomEvent): Promise { + async function handleEdit (event: CustomEvent): Promise { try { + if (!(await prepareMentionGrantChoices(event))) return false + await editMessage(event) const objectId = await getObjectId(object, client.getHierarchy()) Analytics.handleEvent(ChunterEvents.MessageEdited, { ok: true, objectId, objectClass: object._class }) + return true } catch (err: any) { const objectId = await getObjectId(object, client.getHierarchy()) Analytics.handleEvent(ChunterEvents.MessageEdited, { ok: false, objectId, objectClass: object._class }) Analytics.handleError(err) + return false } } async function onMessage (event: CustomEvent): Promise { - draftController.remove() - inputRef.removeDraft(false) + loading = true + let ok = false if (chatMessage !== undefined) { - loading = true - await handleEdit(event) + ok = await handleEdit(event) } else { - void handleCreate(event, _id) + ok = await handleCreate(event, _id) void deleteTypingInfo() } - // Remove draft from Local Storage - clear() - dispatch('submit', false) + // Only clear the input / drop the draft after a successful send or edit. + // A cancelled grant dialog (ok === false) must preserve the unsent comment. + if (ok) { + draftController.remove() + inputRef.removeDraft(false) + clear() + dispatch('submit', false) + } loading = false } @@ -254,6 +373,24 @@ onKeyDown={handleKeyDown} /> +{#if showGrantWarning} +
+
+{/if} + {#if withTypingInfo} {/if} + + diff --git a/plugins/chunter-resources/src/components/chat-message/MentionGrantConfirm.svelte b/plugins/chunter-resources/src/components/chat-message/MentionGrantConfirm.svelte new file mode 100644 index 00000000000..14461b5821f --- /dev/null +++ b/plugins/chunter-resources/src/components/chat-message/MentionGrantConfirm.svelte @@ -0,0 +1,70 @@ + + + +
+
+
+
+
+ {#each rows as row (row.id)} +
+ + {row.name} +
+ {/each} +
+
+
+ + + diff --git a/plugins/chunter-resources/src/mentionGrants.ts b/plugins/chunter-resources/src/mentionGrants.ts new file mode 100644 index 00000000000..e8056e3661c --- /dev/null +++ b/plugins/chunter-resources/src/mentionGrants.ts @@ -0,0 +1,50 @@ +// +// 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 +// + +import { markupToJSON, jsonToMarkup, type MarkupNode, MarkupNodeType } from '@hcengineering/text-core' +import { type Markup } from '@hcengineering/core' + +/** + * Rewrite the grantsAccess attribute on mention references in a message's + * markup according to per-person choices made in the send-time disclosure. + * + * - choice === false -> set grantsAccess='false' on EVERY reference to that + * person. Setting all of them is required because extractReferences merges + * any-wins: a single remaining undefined reference would re-grant access. + * - choice === true -> set grantsAccess='true' explicitly. + * - person not in the map -> left unchanged (e.g. existing space members, + * whose mentions stay undefined and grant-by-default as before). + * + * Pure function: returns a new markup string; does not mutate its input + * string (it parses, mutates the parsed tree, and re-serializes). + */ +export function applyMentionGrantChoices (markup: Markup, choices: Map): Markup { + if (choices.size === 0) return markup + let node: MarkupNode + try { + node = markupToJSON(markup) + } catch { + return markup + } + + const walk = (n: MarkupNode): void => { + // Use `!= null` to guard against both `undefined` AND `null` — a defensive + // attrs == null on a reference node would otherwise throw on n.attrs.id. + if (n.type === MarkupNodeType.reference && n.attrs != null) { + const id = n.attrs.id as string + const choice = choices.get(id) + if (choice !== undefined) { + n.attrs.grantsAccess = choice ? 'true' : 'false' + } + } + n.content?.forEach(walk) + } + walk(node) + + return jsonToMarkup(node) +} diff --git a/plugins/chunter/src/index.ts b/plugins/chunter/src/index.ts index 0bd96810fe7..429809e5655 100644 --- a/plugins/chunter/src/index.ts +++ b/plugins/chunter/src/index.ts @@ -151,6 +151,7 @@ export default plugin(chunterId, { }, string: { Reactions: '' as IntlString, + MentionGrantsAccessHint: '' as IntlString, EditUpdate: '' as IntlString, EditCancel: '' as IntlString, Comments: '' as IntlString, diff --git a/plugins/setting-assets/lang/cs.json b/plugins/setting-assets/lang/cs.json index bdcef592cd5..af6fc11aaa6 100644 --- a/plugins/setting-assets/lang/cs.json +++ b/plugins/setting-assets/lang/cs.json @@ -247,6 +247,16 @@ "ShowQRCode": "Zobrazit QR kód", "EnterVerificationCode": "Zadejte ověřovací kód", "OverrideAttribute": "Přepsat atribut", - "Required": "Požadované" + "Required": "Požadované", + "AccessGroups": "Access groups", + "AccessGroupsHint": "Named groups of people. Grant a group access to an issue and every member gains access; future members inherit it automatically.", + "NewAccessGroup": "New group", + "AccessGroupName": "Group name", + "AccessGroupDescription": "Description", + "DeleteAccessGroup": "Delete group", + "AccessGroupInUse": "This group still grants access to issues. Remove those grants before deleting it.", + "NoAccessGroups": "No access groups yet.", + "GroupOwners": "Owners", + "GroupMembers": "Members" } } diff --git a/plugins/setting-assets/lang/de.json b/plugins/setting-assets/lang/de.json index 7408e34277e..1c237af97cb 100644 --- a/plugins/setting-assets/lang/de.json +++ b/plugins/setting-assets/lang/de.json @@ -249,6 +249,16 @@ "ShowQRCode": "QR-Code anzeigen", "EnterVerificationCode": "Verifizierungscode eingeben", "OverrideAttribute": "Überschreibattribut", - "Required": "Pflichtfeld" + "Required": "Pflichtfeld", + "AccessGroups": "Zugriffsgruppen", + "AccessGroupsHint": "Benannte Personengruppen. Gewähre einer Gruppe Zugriff auf ein Issue und alle Mitglieder erhalten Zugriff; zukünftige Mitglieder erben ihn automatisch.", + "NewAccessGroup": "Neue Gruppe", + "AccessGroupName": "Gruppenname", + "AccessGroupDescription": "Beschreibung", + "DeleteAccessGroup": "Gruppe löschen", + "AccessGroupInUse": "Diese Gruppe gewährt noch Zugriff auf Issues. Entferne diese Grants, bevor du sie löschst.", + "NoAccessGroups": "Noch keine Zugriffsgruppen.", + "GroupOwners": "Eigentümer", + "GroupMembers": "Mitglieder" } } diff --git a/plugins/setting-assets/lang/en.json b/plugins/setting-assets/lang/en.json index 838f5de3659..7a5139dbdb5 100644 --- a/plugins/setting-assets/lang/en.json +++ b/plugins/setting-assets/lang/en.json @@ -249,6 +249,16 @@ "ShowQRCode": "Show QR code", "EnterVerificationCode": "Enter verification code", "OverrideAttribute": "Override attribute", - "Required": "Required" + "Required": "Required", + "AccessGroups": "Access groups", + "AccessGroupsHint": "Named groups of people. Grant a group access to an issue and every member gains access; future members inherit it automatically.", + "NewAccessGroup": "New group", + "AccessGroupName": "Group name", + "AccessGroupDescription": "Description", + "DeleteAccessGroup": "Delete group", + "AccessGroupInUse": "This group still grants access to issues. Remove those grants before deleting it.", + "NoAccessGroups": "No access groups yet.", + "GroupOwners": "Owners", + "GroupMembers": "Members" } } diff --git a/plugins/setting-assets/lang/es.json b/plugins/setting-assets/lang/es.json index 81bccb24712..d16358e153d 100644 --- a/plugins/setting-assets/lang/es.json +++ b/plugins/setting-assets/lang/es.json @@ -240,6 +240,16 @@ "ShowQRCode": "Mostrar código QR", "EnterVerificationCode": "Introducir código de verificación", "OverrideAttribute": "Sobreescribir atributo", - "Required": "Requerido" + "Required": "Requerido", + "AccessGroups": "Access groups", + "AccessGroupsHint": "Named groups of people. Grant a group access to an issue and every member gains access; future members inherit it automatically.", + "NewAccessGroup": "New group", + "AccessGroupName": "Group name", + "AccessGroupDescription": "Description", + "DeleteAccessGroup": "Delete group", + "AccessGroupInUse": "This group still grants access to issues. Remove those grants before deleting it.", + "NoAccessGroups": "No access groups yet.", + "GroupOwners": "Owners", + "GroupMembers": "Members" } } diff --git a/plugins/setting-assets/lang/fr.json b/plugins/setting-assets/lang/fr.json index 7ea973f0dd6..6831d01a6d7 100644 --- a/plugins/setting-assets/lang/fr.json +++ b/plugins/setting-assets/lang/fr.json @@ -249,6 +249,16 @@ "ShowQRCode": "Afficher le code QR", "EnterVerificationCode": "Entrer le code de vérification", "OverrideAttribute": "Surcharger l'attribut", - "Required": "Requis" + "Required": "Requis", + "AccessGroups": "Access groups", + "AccessGroupsHint": "Named groups of people. Grant a group access to an issue and every member gains access; future members inherit it automatically.", + "NewAccessGroup": "New group", + "AccessGroupName": "Group name", + "AccessGroupDescription": "Description", + "DeleteAccessGroup": "Delete group", + "AccessGroupInUse": "This group still grants access to issues. Remove those grants before deleting it.", + "NoAccessGroups": "No access groups yet.", + "GroupOwners": "Owners", + "GroupMembers": "Members" } } diff --git a/plugins/setting-assets/lang/it.json b/plugins/setting-assets/lang/it.json index 4d556acaeb0..a7e8477d35a 100644 --- a/plugins/setting-assets/lang/it.json +++ b/plugins/setting-assets/lang/it.json @@ -249,6 +249,16 @@ "ShowQRCode": "Mostra codice QR", "EnterVerificationCode": "Inserisci codice di verifica", "OverrideAttribute": "Sovrascrivi attributo", - "Required": "Richiesto" + "Required": "Richiesto", + "AccessGroups": "Access groups", + "AccessGroupsHint": "Named groups of people. Grant a group access to an issue and every member gains access; future members inherit it automatically.", + "NewAccessGroup": "New group", + "AccessGroupName": "Group name", + "AccessGroupDescription": "Description", + "DeleteAccessGroup": "Delete group", + "AccessGroupInUse": "This group still grants access to issues. Remove those grants before deleting it.", + "NoAccessGroups": "No access groups yet.", + "GroupOwners": "Owners", + "GroupMembers": "Members" } } diff --git a/plugins/setting-assets/lang/ja.json b/plugins/setting-assets/lang/ja.json index f767c713d2e..02d25d4d0f4 100644 --- a/plugins/setting-assets/lang/ja.json +++ b/plugins/setting-assets/lang/ja.json @@ -249,6 +249,16 @@ "ShowQRCode": "QRコードを表示", "EnterVerificationCode": "確認コードを入力", "OverrideAttribute": "属性を上書き", - "Required": "必須" + "Required": "必須", + "AccessGroups": "Access groups", + "AccessGroupsHint": "Named groups of people. Grant a group access to an issue and every member gains access; future members inherit it automatically.", + "NewAccessGroup": "New group", + "AccessGroupName": "Group name", + "AccessGroupDescription": "Description", + "DeleteAccessGroup": "Delete group", + "AccessGroupInUse": "This group still grants access to issues. Remove those grants before deleting it.", + "NoAccessGroups": "No access groups yet.", + "GroupOwners": "Owners", + "GroupMembers": "Members" } } diff --git a/plugins/setting-assets/lang/ko.json b/plugins/setting-assets/lang/ko.json index c883efbce64..131f086b9a5 100644 --- a/plugins/setting-assets/lang/ko.json +++ b/plugins/setting-assets/lang/ko.json @@ -249,6 +249,16 @@ "ShowQRCode": "QR 코드 표시", "EnterVerificationCode": "인증 코드 입력", "OverrideAttribute": "속성 재정의", - "Required": "필수" + "Required": "필수", + "AccessGroups": "Access groups", + "AccessGroupsHint": "Named groups of people. Grant a group access to an issue and every member gains access; future members inherit it automatically.", + "NewAccessGroup": "New group", + "AccessGroupName": "Group name", + "AccessGroupDescription": "Description", + "DeleteAccessGroup": "Delete group", + "AccessGroupInUse": "This group still grants access to issues. Remove those grants before deleting it.", + "NoAccessGroups": "No access groups yet.", + "GroupOwners": "Owners", + "GroupMembers": "Members" } } diff --git a/plugins/setting-assets/lang/pl.json b/plugins/setting-assets/lang/pl.json index afdf4caee63..5984127a752 100644 --- a/plugins/setting-assets/lang/pl.json +++ b/plugins/setting-assets/lang/pl.json @@ -249,6 +249,16 @@ "ShowQRCode": "Pokaż kod QR", "EnterVerificationCode": "Wpisz kod weryfikacyjny", "OverrideAttribute": "Nadpisz atrybut", - "Required": "Obowiązkowe" + "Required": "Obowiązkowe", + "AccessGroups": "Access groups", + "AccessGroupsHint": "Named groups of people. Grant a group access to an issue and every member gains access; future members inherit it automatically.", + "NewAccessGroup": "New group", + "AccessGroupName": "Group name", + "AccessGroupDescription": "Description", + "DeleteAccessGroup": "Delete group", + "AccessGroupInUse": "This group still grants access to issues. Remove those grants before deleting it.", + "NoAccessGroups": "No access groups yet.", + "GroupOwners": "Owners", + "GroupMembers": "Members" } } diff --git a/plugins/setting-assets/lang/pt-br.json b/plugins/setting-assets/lang/pt-br.json index 35fe91b5d6e..3ec870a149d 100644 --- a/plugins/setting-assets/lang/pt-br.json +++ b/plugins/setting-assets/lang/pt-br.json @@ -240,6 +240,16 @@ "ShowQRCode": "Mostrar código QR", "EnterVerificationCode": "Inserir código de verificação", "OverrideAttribute": "Sobrescrever atributo", - "Required": "Obrigatório" + "Required": "Obrigatório", + "AccessGroups": "Access groups", + "AccessGroupsHint": "Named groups of people. Grant a group access to an issue and every member gains access; future members inherit it automatically.", + "NewAccessGroup": "New group", + "AccessGroupName": "Group name", + "AccessGroupDescription": "Description", + "DeleteAccessGroup": "Delete group", + "AccessGroupInUse": "This group still grants access to issues. Remove those grants before deleting it.", + "NoAccessGroups": "No access groups yet.", + "GroupOwners": "Owners", + "GroupMembers": "Members" } } diff --git a/plugins/setting-assets/lang/pt.json b/plugins/setting-assets/lang/pt.json index 50d8f10a37a..eb2e4491719 100644 --- a/plugins/setting-assets/lang/pt.json +++ b/plugins/setting-assets/lang/pt.json @@ -240,6 +240,16 @@ "ShowQRCode": "Mostrar código QR", "EnterVerificationCode": "Inserir código de verificação", "OverrideAttribute": "Sobrescrever atributo", - "Required": "Obrigatório" + "Required": "Obrigatório", + "AccessGroups": "Access groups", + "AccessGroupsHint": "Named groups of people. Grant a group access to an issue and every member gains access; future members inherit it automatically.", + "NewAccessGroup": "New group", + "AccessGroupName": "Group name", + "AccessGroupDescription": "Description", + "DeleteAccessGroup": "Delete group", + "AccessGroupInUse": "This group still grants access to issues. Remove those grants before deleting it.", + "NoAccessGroups": "No access groups yet.", + "GroupOwners": "Owners", + "GroupMembers": "Members" } } diff --git a/plugins/setting-assets/lang/ru.json b/plugins/setting-assets/lang/ru.json index 677a522cc72..81ae08d78c0 100644 --- a/plugins/setting-assets/lang/ru.json +++ b/plugins/setting-assets/lang/ru.json @@ -249,6 +249,16 @@ "ShowQRCode": "Показать QR-код", "EnterVerificationCode": "Введите код подтверждения", "OverrideAttribute": "Переопределить атрибут", - "Required": "Обязательный" + "Required": "Обязательный", + "AccessGroups": "Access groups", + "AccessGroupsHint": "Named groups of people. Grant a group access to an issue and every member gains access; future members inherit it automatically.", + "NewAccessGroup": "New group", + "AccessGroupName": "Group name", + "AccessGroupDescription": "Description", + "DeleteAccessGroup": "Delete group", + "AccessGroupInUse": "This group still grants access to issues. Remove those grants before deleting it.", + "NoAccessGroups": "No access groups yet.", + "GroupOwners": "Owners", + "GroupMembers": "Members" } } diff --git a/plugins/setting-assets/lang/tr.json b/plugins/setting-assets/lang/tr.json index 0a883da16ac..fa4aeaf0de8 100644 --- a/plugins/setting-assets/lang/tr.json +++ b/plugins/setting-assets/lang/tr.json @@ -249,6 +249,16 @@ "ShowQRCode": "QR kodu göster", "EnterVerificationCode": "Doğrulama kodunu gir", "OverrideAttribute": "Özniteliği geçersiz kıl", - "Required": "Zorunlu" + "Required": "Zorunlu", + "AccessGroups": "Access groups", + "AccessGroupsHint": "Named groups of people. Grant a group access to an issue and every member gains access; future members inherit it automatically.", + "NewAccessGroup": "New group", + "AccessGroupName": "Group name", + "AccessGroupDescription": "Description", + "DeleteAccessGroup": "Delete group", + "AccessGroupInUse": "This group still grants access to issues. Remove those grants before deleting it.", + "NoAccessGroups": "No access groups yet.", + "GroupOwners": "Owners", + "GroupMembers": "Members" } } \ No newline at end of file diff --git a/plugins/setting-assets/lang/zh.json b/plugins/setting-assets/lang/zh.json index f1b43b18961..8718c4ef59f 100644 --- a/plugins/setting-assets/lang/zh.json +++ b/plugins/setting-assets/lang/zh.json @@ -249,6 +249,16 @@ "ShowQRCode": "显示QR码", "EnterVerificationCode": "输入验证码", "OverrideAttribute": "覆盖属性", - "Required": "必须" + "Required": "必须", + "AccessGroups": "Access groups", + "AccessGroupsHint": "Named groups of people. Grant a group access to an issue and every member gains access; future members inherit it automatically.", + "NewAccessGroup": "New group", + "AccessGroupName": "Group name", + "AccessGroupDescription": "Description", + "DeleteAccessGroup": "Delete group", + "AccessGroupInUse": "This group still grants access to issues. Remove those grants before deleting it.", + "NoAccessGroups": "No access groups yet.", + "GroupOwners": "Owners", + "GroupMembers": "Members" } } diff --git a/plugins/setting-resources/src/components/AccessGroups.svelte b/plugins/setting-resources/src/components/AccessGroups.svelte new file mode 100644 index 00000000000..62d317505e5 --- /dev/null +++ b/plugins/setting-resources/src/components/AccessGroups.svelte @@ -0,0 +1,223 @@ + + + +
+
+ + + + +
+ +
+ +
+ + {#if groups.length === 0} +
+ {/if} + + {#each groups as group (group._id)} + {@const inUse = (grantCounts.get(group._id) ?? 0) > 0} +
+
+ + +
{ + toggle(group._id) + }} + > + rename(group, group.name)} + /> +
+ removeGroup(group)} + /> +
+ +
+
+ + setMembers(group, v)} + /> +
+
+ + setOwners(group, v)} + /> +
+
+
+
+ {/each} +
+
+
+ + diff --git a/plugins/setting-resources/src/index.ts b/plugins/setting-resources/src/index.ts index 1e3571d89a7..a04a02ded99 100644 --- a/plugins/setting-resources/src/index.ts +++ b/plugins/setting-resources/src/index.ts @@ -26,6 +26,7 @@ import Integrations from './components/integrations/Integrations.svelte' import General from './components/General.svelte' import Backup from './components/Backup.svelte' import Members from './components/Members.svelte' +import AccessGroups from './components/AccessGroups.svelte' import Password from './components/Password.svelte' import Privacy from './components/Privacy.svelte' import Profile from './components/Profile.svelte' @@ -173,7 +174,8 @@ export default async (): Promise => ({ AddEmailSocialId, EmployeeRefEditor, UserRoleSelect, - TwoFactorSettings + TwoFactorSettings, + AccessGroups }, actionImpl: { DeleteMixin diff --git a/plugins/setting/src/index.ts b/plugins/setting/src/index.ts index 76317f3d8e9..9db79cc63b8 100644 --- a/plugins/setting/src/index.ts +++ b/plugins/setting/src/index.ts @@ -246,7 +246,8 @@ export default plugin(settingId, { AddEmailSocialId: '' as AnyComponent, OfficeSettings: '' as AnyComponent, UserRoleSelect: '' as AnyComponent, - TwoFactorSettings: '' as AnyComponent + TwoFactorSettings: '' as AnyComponent, + AccessGroups: '' as AnyComponent }, string: { Settings: '' as IntlString, @@ -361,7 +362,17 @@ export default plugin(settingId, { Disconnected: '' as IntlString, Available: '' as IntlString, NotConnectedIntegration: '' as IntlString, - IntegrationIsUnstable: '' as IntlString + IntegrationIsUnstable: '' as IntlString, + AccessGroups: '' as IntlString, + AccessGroupsHint: '' as IntlString, + NewAccessGroup: '' as IntlString, + AccessGroupName: '' as IntlString, + AccessGroupDescription: '' as IntlString, + DeleteAccessGroup: '' as IntlString, + AccessGroupInUse: '' as IntlString, + NoAccessGroups: '' as IntlString, + GroupOwners: '' as IntlString, + GroupMembers: '' as IntlString }, icon: { AccountSettings: '' as Asset, diff --git a/plugins/tracker-assets/lang/cs.json b/plugins/tracker-assets/lang/cs.json index dea411668af..31ebffaf77d 100644 --- a/plugins/tracker-assets/lang/cs.json +++ b/plugins/tracker-assets/lang/cs.json @@ -283,7 +283,34 @@ "UnsetParentIssue": "Odebrat nadřazený úkol", "ForbidCreateProjectPermission": "Zakázat vytvoření projektu", "ForbidCreateProjectPermissionDescription": "Zakazuje uživatelům vytvářet nové projekty", - "AllowCreatingIssues": "Povolit vytváření úkolů" + "AllowCreatingIssues": "Povolit vytváření úkolů", + "SharedWithYouTooltip": "You have access to specific issues only; you are not a member of this project", + "Access": "Access", + "AdditionalAccess": "Additional access", + "SpaceMembersAccess": "Project members", + "NoAdditionalAccess": "No additional access granted", + "GrantedViaMention": "via @mention", + "GrantedManually": "granted manually", + "GrantedViaGroup": "via group", + "GrantedByOn": "by {name}", + "RevokeAccess": "Revoke access", + "GiveUpAccess": "Give up access", + "AddPersonAccess": "Add person", + "LevelRead": "Read", + "LevelWrite": "Write", + "LevelAdmin": "Admin", + "AccessLevelLabel": "Access level", + "GrantAccessTitle": "Grant access", + "AccessGrantWarningRead": "{name} will get read and comment access to \"{issue}\", although they are not a member of this project.", + "AccessGrantWarningWrite": "{name} will get read, comment and edit access to \"{issue}\", although they are not a member of this project.", + "AccessGrantWarningAdmin": "{name} will get read, comment and edit access to \"{issue}\" and may manage this issue's access, although they are not a member of this project.", + "GrantAccessConfirm": "Grant access", + "AddGroupAccess": "Add group", + "GroupMembersCount": "{count, plural, =1 {1 person} other {# people}}", + "GroupAccessAutoHint": "Future group members automatically receive access (level: {level}).", + "SelectAccessGroup": "Select a group", + "NoAccessGroups": "No access groups exist yet.", + "RevokeGroupAccess": "Remove group access" }, "status": {} } diff --git a/plugins/tracker-assets/lang/de.json b/plugins/tracker-assets/lang/de.json index d1e698c9c9a..c3acd74df6f 100644 --- a/plugins/tracker-assets/lang/de.json +++ b/plugins/tracker-assets/lang/de.json @@ -293,7 +293,34 @@ "UnsetParentIssue": "Übergeordnete Aufgabe entfernen", "ForbidCreateProjectPermission": "Projekterstellung verbieten", "ForbidCreateProjectPermissionDescription": "Verbietet Benutzern das Erstellen neuer Projekte", - "AllowCreatingIssues": "Erstellen von Aufgaben erlauben" + "AllowCreatingIssues": "Erstellen von Aufgaben erlauben", + "SharedWithYouTooltip": "Du hast nur Zugriff auf einzelne Issues; du bist kein Mitglied dieses Projekts", + "Access": "Zugriff", + "AdditionalAccess": "Zusätzlicher Zugriff", + "SpaceMembersAccess": "Projekt-Mitglieder", + "NoAdditionalAccess": "Kein zusätzlicher Zugriff vergeben", + "GrantedViaMention": "über @Erwähnung", + "GrantedManually": "manuell vergeben", + "GrantedViaGroup": "über Gruppe", + "GrantedByOn": "von {name}", + "RevokeAccess": "Zugriff entziehen", + "GiveUpAccess": "Zugriff aufgeben", + "AddPersonAccess": "Person hinzufügen", + "LevelRead": "Lesen", + "LevelWrite": "Bearbeiten", + "LevelAdmin": "Verwalten", + "AccessLevelLabel": "Zugriffsebene", + "GrantAccessTitle": "Zugriff gewähren", + "AccessGrantWarningRead": "{name} erhält Lese- und Kommentarzugriff auf \"{issue}\", obwohl die Person kein Projekt-Mitglied ist.", + "AccessGrantWarningWrite": "{name} erhält Lese-, Kommentar- und Bearbeitungszugriff auf \"{issue}\", obwohl die Person kein Projekt-Mitglied ist.", + "AccessGrantWarningAdmin": "{name} erhält Lese-, Kommentar- und Bearbeitungszugriff auf \"{issue}\" und darf den Zugriff dieses Issues verwalten, obwohl die Person kein Projekt-Mitglied ist.", + "GrantAccessConfirm": "Zugriff gewähren", + "AddGroupAccess": "Gruppe hinzufügen", + "GroupMembersCount": "{count, plural, =1 {1 Person} other {# Personen}}", + "GroupAccessAutoHint": "Zukünftige Gruppen-Mitglieder erhalten automatisch Zugriff (Level: {level}).", + "SelectAccessGroup": "Gruppe auswählen", + "NoAccessGroups": "Es existieren noch keine Zugriffsgruppen.", + "RevokeGroupAccess": "Gruppen-Zugriff entfernen" }, "status": {} } diff --git a/plugins/tracker-assets/lang/en.json b/plugins/tracker-assets/lang/en.json index 3d856a20031..bea396efe10 100644 --- a/plugins/tracker-assets/lang/en.json +++ b/plugins/tracker-assets/lang/en.json @@ -293,7 +293,34 @@ "UnsetParentIssue": "Unset parent issue", "ForbidCreateProjectPermission": "Forbid create project", "ForbidCreateProjectPermissionDescription": "Forbid users creating new projects", - "AllowCreatingIssues": "Allow creating issues" + "AllowCreatingIssues": "Allow creating issues", + "SharedWithYouTooltip": "You have access to specific issues only; you are not a member of this project", + "Access": "Access", + "AdditionalAccess": "Additional access", + "SpaceMembersAccess": "Project members", + "NoAdditionalAccess": "No additional access granted", + "GrantedViaMention": "via @mention", + "GrantedManually": "granted manually", + "GrantedViaGroup": "via group", + "GrantedByOn": "by {name}", + "RevokeAccess": "Revoke access", + "GiveUpAccess": "Give up access", + "AddPersonAccess": "Add person", + "LevelRead": "Read", + "LevelWrite": "Write", + "LevelAdmin": "Admin", + "AccessLevelLabel": "Access level", + "GrantAccessTitle": "Grant access", + "AccessGrantWarningRead": "{name} will get read and comment access to \"{issue}\", although they are not a member of this project.", + "AccessGrantWarningWrite": "{name} will get read, comment and edit access to \"{issue}\", although they are not a member of this project.", + "AccessGrantWarningAdmin": "{name} will get read, comment and edit access to \"{issue}\" and may manage this issue's access, although they are not a member of this project.", + "GrantAccessConfirm": "Grant access", + "AddGroupAccess": "Add group", + "GroupMembersCount": "{count, plural, =1 {1 person} other {# people}}", + "GroupAccessAutoHint": "Future group members automatically receive access (level: {level}).", + "SelectAccessGroup": "Select a group", + "NoAccessGroups": "No access groups exist yet.", + "RevokeGroupAccess": "Remove group access" }, "status": {} } diff --git a/plugins/tracker-assets/lang/es.json b/plugins/tracker-assets/lang/es.json index ed9795ef41b..48dda0c113a 100644 --- a/plugins/tracker-assets/lang/es.json +++ b/plugins/tracker-assets/lang/es.json @@ -276,7 +276,34 @@ "UnsetParentIssue": "Unset parent issue", "ForbidCreateProjectPermission": "Prohibir crear proyecto", "ForbidCreateProjectPermissionDescription": "Prohíbe a los usuarios crear nuevos proyectos", - "AllowCreatingIssues": "Permitir crear incidencias" + "AllowCreatingIssues": "Permitir crear incidencias", + "SharedWithYouTooltip": "You have access to specific issues only; you are not a member of this project", + "Access": "Access", + "AdditionalAccess": "Additional access", + "SpaceMembersAccess": "Project members", + "NoAdditionalAccess": "No additional access granted", + "GrantedViaMention": "via @mention", + "GrantedManually": "granted manually", + "GrantedViaGroup": "via group", + "GrantedByOn": "by {name}", + "RevokeAccess": "Revoke access", + "GiveUpAccess": "Give up access", + "AddPersonAccess": "Add person", + "LevelRead": "Read", + "LevelWrite": "Write", + "LevelAdmin": "Admin", + "AccessLevelLabel": "Access level", + "GrantAccessTitle": "Grant access", + "AccessGrantWarningRead": "{name} will get read and comment access to \"{issue}\", although they are not a member of this project.", + "AccessGrantWarningWrite": "{name} will get read, comment and edit access to \"{issue}\", although they are not a member of this project.", + "AccessGrantWarningAdmin": "{name} will get read, comment and edit access to \"{issue}\" and may manage this issue's access, although they are not a member of this project.", + "GrantAccessConfirm": "Grant access", + "AddGroupAccess": "Add group", + "GroupMembersCount": "{count, plural, =1 {1 person} other {# people}}", + "GroupAccessAutoHint": "Future group members automatically receive access (level: {level}).", + "SelectAccessGroup": "Select a group", + "NoAccessGroups": "No access groups exist yet.", + "RevokeGroupAccess": "Remove group access" }, "status": {} } diff --git a/plugins/tracker-assets/lang/fr.json b/plugins/tracker-assets/lang/fr.json index daa7f4eaaba..ccef6a36654 100644 --- a/plugins/tracker-assets/lang/fr.json +++ b/plugins/tracker-assets/lang/fr.json @@ -276,7 +276,34 @@ "UnsetParentIssue": "Désélectionner l'issue parent", "ForbidCreateProjectPermission": "Interdire la création de projet", "ForbidCreateProjectPermissionDescription": "Interdit aux utilisateurs de créer de nouveaux projets", - "AllowCreatingIssues": "Autoriser la création d'issues" + "AllowCreatingIssues": "Autoriser la création d'issues", + "SharedWithYouTooltip": "You have access to specific issues only; you are not a member of this project", + "Access": "Access", + "AdditionalAccess": "Additional access", + "SpaceMembersAccess": "Project members", + "NoAdditionalAccess": "No additional access granted", + "GrantedViaMention": "via @mention", + "GrantedManually": "granted manually", + "GrantedViaGroup": "via group", + "GrantedByOn": "by {name}", + "RevokeAccess": "Revoke access", + "GiveUpAccess": "Give up access", + "AddPersonAccess": "Add person", + "LevelRead": "Read", + "LevelWrite": "Write", + "LevelAdmin": "Admin", + "AccessLevelLabel": "Access level", + "GrantAccessTitle": "Grant access", + "AccessGrantWarningRead": "{name} will get read and comment access to \"{issue}\", although they are not a member of this project.", + "AccessGrantWarningWrite": "{name} will get read, comment and edit access to \"{issue}\", although they are not a member of this project.", + "AccessGrantWarningAdmin": "{name} will get read, comment and edit access to \"{issue}\" and may manage this issue's access, although they are not a member of this project.", + "GrantAccessConfirm": "Grant access", + "AddGroupAccess": "Add group", + "GroupMembersCount": "{count, plural, =1 {1 person} other {# people}}", + "GroupAccessAutoHint": "Future group members automatically receive access (level: {level}).", + "SelectAccessGroup": "Select a group", + "NoAccessGroups": "No access groups exist yet.", + "RevokeGroupAccess": "Remove group access" }, "status": {} } diff --git a/plugins/tracker-assets/lang/it.json b/plugins/tracker-assets/lang/it.json index 7fe2285f261..d74668512a0 100644 --- a/plugins/tracker-assets/lang/it.json +++ b/plugins/tracker-assets/lang/it.json @@ -276,7 +276,34 @@ "UnsetParentIssue": "Annulla l'issue genitore", "ForbidCreateProjectPermission": "Vieta creazione progetto", "ForbidCreateProjectPermissionDescription": "Vieta agli utenti di creare nuovi progetti", - "AllowCreatingIssues": "Consenti la creazione di issue" + "AllowCreatingIssues": "Consenti la creazione di issue", + "SharedWithYouTooltip": "You have access to specific issues only; you are not a member of this project", + "Access": "Access", + "AdditionalAccess": "Additional access", + "SpaceMembersAccess": "Project members", + "NoAdditionalAccess": "No additional access granted", + "GrantedViaMention": "via @mention", + "GrantedManually": "granted manually", + "GrantedViaGroup": "via group", + "GrantedByOn": "by {name}", + "RevokeAccess": "Revoke access", + "GiveUpAccess": "Give up access", + "AddPersonAccess": "Add person", + "LevelRead": "Read", + "LevelWrite": "Write", + "LevelAdmin": "Admin", + "AccessLevelLabel": "Access level", + "GrantAccessTitle": "Grant access", + "AccessGrantWarningRead": "{name} will get read and comment access to \"{issue}\", although they are not a member of this project.", + "AccessGrantWarningWrite": "{name} will get read, comment and edit access to \"{issue}\", although they are not a member of this project.", + "AccessGrantWarningAdmin": "{name} will get read, comment and edit access to \"{issue}\" and may manage this issue's access, although they are not a member of this project.", + "GrantAccessConfirm": "Grant access", + "AddGroupAccess": "Add group", + "GroupMembersCount": "{count, plural, =1 {1 person} other {# people}}", + "GroupAccessAutoHint": "Future group members automatically receive access (level: {level}).", + "SelectAccessGroup": "Select a group", + "NoAccessGroups": "No access groups exist yet.", + "RevokeGroupAccess": "Remove group access" }, "status": {} } diff --git a/plugins/tracker-assets/lang/ja.json b/plugins/tracker-assets/lang/ja.json index 34426b09875..5a602c7cf60 100644 --- a/plugins/tracker-assets/lang/ja.json +++ b/plugins/tracker-assets/lang/ja.json @@ -276,7 +276,34 @@ "UnsetParentIssue": "親イシューの設定を解除", "ForbidCreateProjectPermission": "プロジェクト作成禁止", "ForbidCreateProjectPermissionDescription": "ユーザーが新しいプロジェクトを作成することを禁止します", - "AllowCreatingIssues": "イシューの作成を許可" + "AllowCreatingIssues": "イシューの作成を許可", + "SharedWithYouTooltip": "You have access to specific issues only; you are not a member of this project", + "Access": "Access", + "AdditionalAccess": "Additional access", + "SpaceMembersAccess": "Project members", + "NoAdditionalAccess": "No additional access granted", + "GrantedViaMention": "via @mention", + "GrantedManually": "granted manually", + "GrantedViaGroup": "via group", + "GrantedByOn": "by {name}", + "RevokeAccess": "Revoke access", + "GiveUpAccess": "Give up access", + "AddPersonAccess": "Add person", + "LevelRead": "Read", + "LevelWrite": "Write", + "LevelAdmin": "Admin", + "AccessLevelLabel": "Access level", + "GrantAccessTitle": "Grant access", + "AccessGrantWarningRead": "{name} will get read and comment access to \"{issue}\", although they are not a member of this project.", + "AccessGrantWarningWrite": "{name} will get read, comment and edit access to \"{issue}\", although they are not a member of this project.", + "AccessGrantWarningAdmin": "{name} will get read, comment and edit access to \"{issue}\" and may manage this issue's access, although they are not a member of this project.", + "GrantAccessConfirm": "Grant access", + "AddGroupAccess": "Add group", + "GroupMembersCount": "{count, plural, =1 {1 person} other {# people}}", + "GroupAccessAutoHint": "Future group members automatically receive access (level: {level}).", + "SelectAccessGroup": "Select a group", + "NoAccessGroups": "No access groups exist yet.", + "RevokeGroupAccess": "Remove group access" }, "status": {} } diff --git a/plugins/tracker-assets/lang/ko.json b/plugins/tracker-assets/lang/ko.json index 32b4b4c75c6..3fb25058d75 100644 --- a/plugins/tracker-assets/lang/ko.json +++ b/plugins/tracker-assets/lang/ko.json @@ -276,7 +276,34 @@ "UnsetParentIssue": "상위 이슈 설정 해제", "ForbidCreateProjectPermission": "프로젝트 생성 금지", "ForbidCreateProjectPermissionDescription": "사용자의 새 프로젝트 생성을 금지", - "AllowCreatingIssues": "이슈 생성 허용" + "AllowCreatingIssues": "이슈 생성 허용", + "SharedWithYouTooltip": "You have access to specific issues only; you are not a member of this project", + "Access": "Access", + "AdditionalAccess": "Additional access", + "SpaceMembersAccess": "Project members", + "NoAdditionalAccess": "No additional access granted", + "GrantedViaMention": "via @mention", + "GrantedManually": "granted manually", + "GrantedViaGroup": "via group", + "GrantedByOn": "by {name}", + "RevokeAccess": "Revoke access", + "GiveUpAccess": "Give up access", + "AddPersonAccess": "Add person", + "LevelRead": "Read", + "LevelWrite": "Write", + "LevelAdmin": "Admin", + "AccessLevelLabel": "Access level", + "GrantAccessTitle": "Grant access", + "AccessGrantWarningRead": "{name} will get read and comment access to \"{issue}\", although they are not a member of this project.", + "AccessGrantWarningWrite": "{name} will get read, comment and edit access to \"{issue}\", although they are not a member of this project.", + "AccessGrantWarningAdmin": "{name} will get read, comment and edit access to \"{issue}\" and may manage this issue's access, although they are not a member of this project.", + "GrantAccessConfirm": "Grant access", + "AddGroupAccess": "Add group", + "GroupMembersCount": "{count, plural, =1 {1 person} other {# people}}", + "GroupAccessAutoHint": "Future group members automatically receive access (level: {level}).", + "SelectAccessGroup": "Select a group", + "NoAccessGroups": "No access groups exist yet.", + "RevokeGroupAccess": "Remove group access" }, "status": {} } diff --git a/plugins/tracker-assets/lang/pl.json b/plugins/tracker-assets/lang/pl.json index fa2e8555275..86a6d05d3d1 100644 --- a/plugins/tracker-assets/lang/pl.json +++ b/plugins/tracker-assets/lang/pl.json @@ -290,7 +290,34 @@ "UnsetParentIssue": "Wyczyść zagadnienie nadrzędne", "ForbidCreateProjectPermission": "Zakaż tworzenia projektów", "ForbidCreateProjectPermissionDescription": "Zakaż użytkownikom tworzenia nowych projektów.", - "AllowCreatingIssues": "Zezwól na tworzenie zadań" + "AllowCreatingIssues": "Zezwól na tworzenie zadań", + "SharedWithYouTooltip": "You have access to specific issues only; you are not a member of this project", + "Access": "Access", + "AdditionalAccess": "Additional access", + "SpaceMembersAccess": "Project members", + "NoAdditionalAccess": "No additional access granted", + "GrantedViaMention": "via @mention", + "GrantedManually": "granted manually", + "GrantedViaGroup": "via group", + "GrantedByOn": "by {name}", + "RevokeAccess": "Revoke access", + "GiveUpAccess": "Give up access", + "AddPersonAccess": "Add person", + "LevelRead": "Read", + "LevelWrite": "Write", + "LevelAdmin": "Admin", + "AccessLevelLabel": "Access level", + "GrantAccessTitle": "Grant access", + "AccessGrantWarningRead": "{name} will get read and comment access to \"{issue}\", although they are not a member of this project.", + "AccessGrantWarningWrite": "{name} will get read, comment and edit access to \"{issue}\", although they are not a member of this project.", + "AccessGrantWarningAdmin": "{name} will get read, comment and edit access to \"{issue}\" and may manage this issue's access, although they are not a member of this project.", + "GrantAccessConfirm": "Grant access", + "AddGroupAccess": "Add group", + "GroupMembersCount": "{count, plural, =1 {1 person} other {# people}}", + "GroupAccessAutoHint": "Future group members automatically receive access (level: {level}).", + "SelectAccessGroup": "Select a group", + "NoAccessGroups": "No access groups exist yet.", + "RevokeGroupAccess": "Remove group access" }, "status": {} } diff --git a/plugins/tracker-assets/lang/pt-br.json b/plugins/tracker-assets/lang/pt-br.json index 9d8ec0214e2..eb82626c00e 100644 --- a/plugins/tracker-assets/lang/pt-br.json +++ b/plugins/tracker-assets/lang/pt-br.json @@ -276,7 +276,34 @@ "UnsetParentIssue": "Desmarcar problema pai", "ForbidCreateProjectPermission": "Proibir criação de projeto", "ForbidCreateProjectPermissionDescription": "Proíbe os usuários de criar novos projetos", - "AllowCreatingIssues": "Permitir criar problemas" + "AllowCreatingIssues": "Permitir criar problemas", + "SharedWithYouTooltip": "You have access to specific issues only; you are not a member of this project", + "Access": "Access", + "AdditionalAccess": "Additional access", + "SpaceMembersAccess": "Project members", + "NoAdditionalAccess": "No additional access granted", + "GrantedViaMention": "via @mention", + "GrantedManually": "granted manually", + "GrantedViaGroup": "via group", + "GrantedByOn": "by {name}", + "RevokeAccess": "Revoke access", + "GiveUpAccess": "Give up access", + "AddPersonAccess": "Add person", + "LevelRead": "Read", + "LevelWrite": "Write", + "LevelAdmin": "Admin", + "AccessLevelLabel": "Access level", + "GrantAccessTitle": "Grant access", + "AccessGrantWarningRead": "{name} will get read and comment access to \"{issue}\", although they are not a member of this project.", + "AccessGrantWarningWrite": "{name} will get read, comment and edit access to \"{issue}\", although they are not a member of this project.", + "AccessGrantWarningAdmin": "{name} will get read, comment and edit access to \"{issue}\" and may manage this issue's access, although they are not a member of this project.", + "GrantAccessConfirm": "Grant access", + "AddGroupAccess": "Add group", + "GroupMembersCount": "{count, plural, =1 {1 person} other {# people}}", + "GroupAccessAutoHint": "Future group members automatically receive access (level: {level}).", + "SelectAccessGroup": "Select a group", + "NoAccessGroups": "No access groups exist yet.", + "RevokeGroupAccess": "Remove group access" }, "status": {} } diff --git a/plugins/tracker-assets/lang/pt.json b/plugins/tracker-assets/lang/pt.json index 82a640e2c59..97e6c898553 100644 --- a/plugins/tracker-assets/lang/pt.json +++ b/plugins/tracker-assets/lang/pt.json @@ -276,7 +276,34 @@ "UnsetParentIssue": "Desmarcar problema pai", "ForbidCreateProjectPermission": "Proibir criação de projeto", "ForbidCreateProjectPermissionDescription": "Proíbe os usuários de criar novos projetos", - "AllowCreatingIssues": "Permitir criar problemas" + "AllowCreatingIssues": "Permitir criar problemas", + "SharedWithYouTooltip": "You have access to specific issues only; you are not a member of this project", + "Access": "Access", + "AdditionalAccess": "Additional access", + "SpaceMembersAccess": "Project members", + "NoAdditionalAccess": "No additional access granted", + "GrantedViaMention": "via @mention", + "GrantedManually": "granted manually", + "GrantedViaGroup": "via group", + "GrantedByOn": "by {name}", + "RevokeAccess": "Revoke access", + "GiveUpAccess": "Give up access", + "AddPersonAccess": "Add person", + "LevelRead": "Read", + "LevelWrite": "Write", + "LevelAdmin": "Admin", + "AccessLevelLabel": "Access level", + "GrantAccessTitle": "Grant access", + "AccessGrantWarningRead": "{name} will get read and comment access to \"{issue}\", although they are not a member of this project.", + "AccessGrantWarningWrite": "{name} will get read, comment and edit access to \"{issue}\", although they are not a member of this project.", + "AccessGrantWarningAdmin": "{name} will get read, comment and edit access to \"{issue}\" and may manage this issue's access, although they are not a member of this project.", + "GrantAccessConfirm": "Grant access", + "AddGroupAccess": "Add group", + "GroupMembersCount": "{count, plural, =1 {1 person} other {# people}}", + "GroupAccessAutoHint": "Future group members automatically receive access (level: {level}).", + "SelectAccessGroup": "Select a group", + "NoAccessGroups": "No access groups exist yet.", + "RevokeGroupAccess": "Remove group access" }, "status": {} } diff --git a/plugins/tracker-assets/lang/ru.json b/plugins/tracker-assets/lang/ru.json index 7ccd4cb5ccd..1b868c165ad 100644 --- a/plugins/tracker-assets/lang/ru.json +++ b/plugins/tracker-assets/lang/ru.json @@ -293,7 +293,34 @@ "UnsetParentIssue": "Снять родительскую задачу", "ForbidCreateProjectPermission": "Запретить создание проекта", "ForbidCreateProjectPermissionDescription": "Запрещает пользователям создавать новые проекты", - "AllowCreatingIssues": "Разрешить создание задач" + "AllowCreatingIssues": "Разрешить создание задач", + "SharedWithYouTooltip": "You have access to specific issues only; you are not a member of this project", + "Access": "Access", + "AdditionalAccess": "Additional access", + "SpaceMembersAccess": "Project members", + "NoAdditionalAccess": "No additional access granted", + "GrantedViaMention": "via @mention", + "GrantedManually": "granted manually", + "GrantedViaGroup": "via group", + "GrantedByOn": "by {name}", + "RevokeAccess": "Revoke access", + "GiveUpAccess": "Give up access", + "AddPersonAccess": "Add person", + "LevelRead": "Read", + "LevelWrite": "Write", + "LevelAdmin": "Admin", + "AccessLevelLabel": "Access level", + "GrantAccessTitle": "Grant access", + "AccessGrantWarningRead": "{name} will get read and comment access to \"{issue}\", although they are not a member of this project.", + "AccessGrantWarningWrite": "{name} will get read, comment and edit access to \"{issue}\", although they are not a member of this project.", + "AccessGrantWarningAdmin": "{name} will get read, comment and edit access to \"{issue}\" and may manage this issue's access, although they are not a member of this project.", + "GrantAccessConfirm": "Grant access", + "AddGroupAccess": "Add group", + "GroupMembersCount": "{count, plural, =1 {1 person} other {# people}}", + "GroupAccessAutoHint": "Future group members automatically receive access (level: {level}).", + "SelectAccessGroup": "Select a group", + "NoAccessGroups": "No access groups exist yet.", + "RevokeGroupAccess": "Remove group access" }, "status": {} } diff --git a/plugins/tracker-assets/lang/tr.json b/plugins/tracker-assets/lang/tr.json index ec118014359..e023e304f1b 100644 --- a/plugins/tracker-assets/lang/tr.json +++ b/plugins/tracker-assets/lang/tr.json @@ -274,7 +274,34 @@ "IssueStatus": "Durum", "Extensions": "Uzantılar", "UnsetParentIssue": "Üst sorunu kaldır", - "AllowCreatingIssues": "Sorun oluşturmaya izin ver" + "AllowCreatingIssues": "Sorun oluşturmaya izin ver", + "SharedWithYouTooltip": "You have access to specific issues only; you are not a member of this project", + "Access": "Access", + "AdditionalAccess": "Additional access", + "SpaceMembersAccess": "Project members", + "NoAdditionalAccess": "No additional access granted", + "GrantedViaMention": "via @mention", + "GrantedManually": "granted manually", + "GrantedViaGroup": "via group", + "GrantedByOn": "by {name}", + "RevokeAccess": "Revoke access", + "GiveUpAccess": "Give up access", + "AddPersonAccess": "Add person", + "LevelRead": "Read", + "LevelWrite": "Write", + "LevelAdmin": "Admin", + "AccessLevelLabel": "Access level", + "GrantAccessTitle": "Grant access", + "AccessGrantWarningRead": "{name} will get read and comment access to \"{issue}\", although they are not a member of this project.", + "AccessGrantWarningWrite": "{name} will get read, comment and edit access to \"{issue}\", although they are not a member of this project.", + "AccessGrantWarningAdmin": "{name} will get read, comment and edit access to \"{issue}\" and may manage this issue's access, although they are not a member of this project.", + "GrantAccessConfirm": "Grant access", + "AddGroupAccess": "Add group", + "GroupMembersCount": "{count, plural, =1 {1 person} other {# people}}", + "GroupAccessAutoHint": "Future group members automatically receive access (level: {level}).", + "SelectAccessGroup": "Select a group", + "NoAccessGroups": "No access groups exist yet.", + "RevokeGroupAccess": "Remove group access" }, "status": {} } diff --git a/plugins/tracker-assets/lang/zh.json b/plugins/tracker-assets/lang/zh.json index a424d856cac..d518bd5c157 100644 --- a/plugins/tracker-assets/lang/zh.json +++ b/plugins/tracker-assets/lang/zh.json @@ -293,7 +293,34 @@ "UnsetParentIssue": "取消父问题", "ForbidCreateProjectPermission": "禁止创建项目", "ForbidCreateProjectPermissionDescription": "禁止用户创建新项目", - "AllowCreatingIssues": "允许创建问题" + "AllowCreatingIssues": "允许创建问题", + "SharedWithYouTooltip": "You have access to specific issues only; you are not a member of this project", + "Access": "Access", + "AdditionalAccess": "Additional access", + "SpaceMembersAccess": "Project members", + "NoAdditionalAccess": "No additional access granted", + "GrantedViaMention": "via @mention", + "GrantedManually": "granted manually", + "GrantedViaGroup": "via group", + "GrantedByOn": "by {name}", + "RevokeAccess": "Revoke access", + "GiveUpAccess": "Give up access", + "AddPersonAccess": "Add person", + "LevelRead": "Read", + "LevelWrite": "Write", + "LevelAdmin": "Admin", + "AccessLevelLabel": "Access level", + "GrantAccessTitle": "Grant access", + "AccessGrantWarningRead": "{name} will get read and comment access to \"{issue}\", although they are not a member of this project.", + "AccessGrantWarningWrite": "{name} will get read, comment and edit access to \"{issue}\", although they are not a member of this project.", + "AccessGrantWarningAdmin": "{name} will get read, comment and edit access to \"{issue}\" and may manage this issue's access, although they are not a member of this project.", + "GrantAccessConfirm": "Grant access", + "AddGroupAccess": "Add group", + "GroupMembersCount": "{count, plural, =1 {1 person} other {# people}}", + "GroupAccessAutoHint": "Future group members automatically receive access (level: {level}).", + "SelectAccessGroup": "Select a group", + "NoAccessGroups": "No access groups exist yet.", + "RevokeGroupAccess": "Remove group access" }, "status": {} } diff --git a/plugins/tracker-resources/src/components/issues/edit/ControlPanel.svelte b/plugins/tracker-resources/src/components/issues/edit/ControlPanel.svelte index 9aa91228e29..2607925a511 100644 --- a/plugins/tracker-resources/src/components/issues/edit/ControlPanel.svelte +++ b/plugins/tracker-resources/src/components/issues/edit/ControlPanel.svelte @@ -29,6 +29,7 @@ } from '@hcengineering/view-resources' import tracker from '../../../plugin' + import IssueAccessPanel from './IssueAccessPanel.svelte' import ComponentEditor from '../../components/ComponentEditor.svelte' import MilestoneEditor from '../../milestones/MilestoneEditor.svelte' import AssigneeEditor from '../AssigneeEditor.svelte' @@ -80,9 +81,10 @@ $: _mixins = getDocMixins(issue, showAllMixins) - $: mixins = _mixins.find((p) => p._id === notification.mixin.Collaborators) - ? _mixins - : [..._mixins, hierarchy.getClass(notification.mixin.Collaborators)] + // Issues render the dedicated per-issue Access panel below instead of the + // generic Collaborators mixin (which is a notifications-only editor without + // provenance or a server-side grant gate). + $: mixins = _mixins.filter((p) => p._id !== notification.mixin.Collaborators) const allowedCollections = ['collaborators'] @@ -231,6 +233,11 @@ {/each} {/if} +
+
+ +
+ {#each mixins as mixin} {@const mixinKeys = getMixinKeys(mixin._id)} {#if mixinKeys.length} @@ -249,3 +256,9 @@ {/if} {/each}
+ + diff --git a/plugins/tracker-resources/src/components/issues/edit/EditIssue.svelte b/plugins/tracker-resources/src/components/issues/edit/EditIssue.svelte index bf7de0a5db4..2633a1d21ec 100644 --- a/plugins/tracker-resources/src/components/issues/edit/EditIssue.svelte +++ b/plugins/tracker-resources/src/components/issues/edit/EditIssue.svelte @@ -47,7 +47,7 @@ import { createEventDispatcher, onDestroy } from 'svelte' import { generateIssueShortLink, getIssueIdByIdentifier } from '../../../issues' - import { canEditIssue } from '../../../utils' + import { canCommentOnIssue, canEditIssueFields } from '../../../utils' import tracker from '../../../plugin' import IssueStatusActivity from '../IssueStatusActivity.svelte' import ControlPanel from './ControlPanel.svelte' @@ -73,16 +73,29 @@ let descriptionBox: AttachmentStyleBoxCollabEditor let showAllMixins: boolean + // Two independent gates: + // effectiveReadonly — Issue field editors (title, status, dates, ...). + // Driven by canEditIssueFields(). Guests are blocked. + // canComment — Comment composer at the bottom of the panel. + // Driven by canCommentOnIssue(). Guests who are + // Creator OR Collaborator on the issue are allowed. let effectiveReadonly = true + let canComment = false $: if (issue !== undefined) { const currentIssue = issue - void canEditIssue(currentIssue).then((canEdit) => { + void canEditIssueFields(currentIssue).then((canEdit) => { if (issue === currentIssue) { effectiveReadonly = readonly || !canEdit } }) + void canCommentOnIssue(currentIssue).then((v) => { + if (issue === currentIssue) { + canComment = !readonly && v + } + }) } else { effectiveReadonly = readonly + canComment = false } const inboxClient = InboxNotificationsClientImpl.getClient() @@ -211,7 +224,7 @@ + + +
+
+
+ +
+ + { + level = e.detail + }} + /> +
+ +
+
+ + {#if isGroup} +
+
+
+
+ {/if} + +
+
+
+ + + diff --git a/plugins/tracker-resources/src/components/issues/edit/IssueAccessPanel.svelte b/plugins/tracker-resources/src/components/issues/edit/IssueAccessPanel.svelte new file mode 100644 index 00000000000..54846f78c75 --- /dev/null +++ b/plugins/tracker-resources/src/components/issues/edit/IssueAccessPanel.svelte @@ -0,0 +1,556 @@ + + + +
+
+
+ + +
+ + +
(membersExpanded = !membersExpanded)}> +
+ + {#each memberAccounts as account (account)} + {@const emp = $employeeByAccountStore.get(account)} + {#if emp !== undefined} +
+ +
+ {/if} + {/each} +
+
+ + +
+
+
+ + {#if grants.length === 0} +
+ {/if} + + {#each grants as record (record._id)} + {@const emp = $employeeByAccountStore.get(record.collaborator)} +
+
+ {#if emp !== undefined} + + {:else} + {record.collaborator} + {/if} +
+
+
+ +
+ {#if levelEditable(record)} + changeLevel(record, e.detail)} + /> + {:else} + + {/if} +
+ + {#if canRevoke(record)} + revoke(record)} + /> + {/if} +
+ {/each} + + {#each groupGrants as grant (grant._id)} + {@const group = groupsById.get(grant.group)} +
+
+ + +
{ + toggleGroup(grant._id) + }} + > + {group?.name ?? grant.group} + + +
+
+
+
+ +
+ {#if canGrantGroup} + changeGroupLevel(grant, e.detail)} + /> + {:else} + + {/if} +
+ + {#if canGrantGroup} + revokeGroup(grant)} + /> + {/if} +
+ {#if group !== undefined} + +
+ {#each group.members ?? [] as account (account)} + {@const emp = $employeeByAccountStore.get(account)} + {#if emp !== undefined} +
+ +
+ {/if} + {/each} +
+
+ {/if} + {/each} + + {#if canGrant || canGrantGroup} +
+ {#if canGrant} +
+ {/if} +
+
+ + diff --git a/plugins/tracker-resources/src/components/issues/edit/SelectAccessGroupPopup.svelte b/plugins/tracker-resources/src/components/issues/edit/SelectAccessGroupPopup.svelte new file mode 100644 index 00000000000..c19cb8d8461 --- /dev/null +++ b/plugins/tracker-resources/src/components/issues/edit/SelectAccessGroupPopup.svelte @@ -0,0 +1,103 @@ + + + +
+
+
+
+ {#if groups.length === 0} +
+ {/if} + {#each groups as group (group._id)} + + +
{ + select(group) + }} + > + {group.name} + + +
+ {/each} +
+
+ + diff --git a/plugins/tracker-resources/src/components/projects/ProjectSpacePresenter.svelte b/plugins/tracker-resources/src/components/projects/ProjectSpacePresenter.svelte index 17f004068b6..a7d2b271a9b 100644 --- a/plugins/tracker-resources/src/components/projects/ProjectSpacePresenter.svelte +++ b/plugins/tracker-resources/src/components/projects/ProjectSpacePresenter.svelte @@ -13,12 +13,13 @@ // limitations under the License. --> {#if specials} - getActions(space)} - {forciblyСollapsed} - > - {#each specials as special} - - - - {/each} - - - {#if visible} - {@const item = specials.find((sp) => sp.id === currentSpecial && currentSpace === space._id)} - {#if item} - - + {#if isCollabOnlyProject} +
+ getActions(space)} + {forciblyСollapsed} + > + {#each specials as special} + + + {/each} + + + {#if visible} + {@const item = specials.find((sp) => sp.id === currentSpecial && currentSpace === space._id)} + {#if item} + + + + {/if} + {/if} + + +
+ {:else} + getActions(space)} + {forciblyСollapsed} + > + {#each specials as special} + + + + {/each} + + + {#if visible} + {@const item = specials.find((sp) => sp.id === currentSpecial && currentSpace === space._id)} + {#if item} + + + + {/if} {/if} - {/if} - - +
+
+ {/if} {/if} diff --git a/plugins/tracker-resources/src/index.ts b/plugins/tracker-resources/src/index.ts index 071a13ac73f..0f0a7cf1b72 100644 --- a/plugins/tracker-resources/src/index.ts +++ b/plugins/tracker-resources/src/index.ts @@ -14,7 +14,7 @@ // import { Analytics } from '@hcengineering/analytics' -import { +import core, { type Attribute, type Class, type Client, @@ -517,7 +517,19 @@ export default async (): Promise => ({ ) => await getAllStates(query, onUpdate, queryId, attr, false), GetVisibleFilters: getVisibleFilters, IssueChatTitleProvider: getIssueChatTitle, - IsProjectJoined: async (project: Project) => project.members.includes(getCurrentAccount().uuid), + IsProjectJoined: async (project: Project) => { + const accountUuid = getCurrentAccount().uuid + if (project.members.includes(accountUuid)) return true + // Collab-only access: surface projects in the nav tree when the user is a + // Collaborator on any doc inside this project, even without space-membership. + // Backend security (postgres collabRes OR-branch + middleware bypass) limits + // what the user can actually open within the project. + const collab = await getClient().findOne(core.class.Collaborator, { + collaborator: accountUuid, + space: project._id + }) + return collab !== undefined + }, GetIssueStatusCategories: getIssueStatusCategories, SetComponentStore: setStore, ComponentFilterFunction: filterComponents, diff --git a/plugins/tracker-resources/src/plugin.ts b/plugins/tracker-resources/src/plugin.ts index eed088c59d5..d86a8e072cc 100644 --- a/plugins/tracker-resources/src/plugin.ts +++ b/plugins/tracker-resources/src/plugin.ts @@ -307,7 +307,34 @@ export default mergeIds(trackerId, tracker, { UnsetParent: '' as IntlString, PreviousAssigned: '' as IntlString, EditRelatedTargets: '' as IntlString, - RelatedIssueTargetDescription: '' as IntlString + RelatedIssueTargetDescription: '' as IntlString, + SharedWithYouTooltip: '' as IntlString, + Access: '' as IntlString, + AdditionalAccess: '' as IntlString, + SpaceMembersAccess: '' as IntlString, + NoAdditionalAccess: '' as IntlString, + GrantedViaMention: '' as IntlString, + GrantedManually: '' as IntlString, + GrantedViaGroup: '' as IntlString, + GrantedByOn: '' as IntlString, + RevokeAccess: '' as IntlString, + GiveUpAccess: '' as IntlString, + AddPersonAccess: '' as IntlString, + LevelRead: '' as IntlString, + LevelWrite: '' as IntlString, + LevelAdmin: '' as IntlString, + AccessLevelLabel: '' as IntlString, + GrantAccessTitle: '' as IntlString, + AccessGrantWarningRead: '' as IntlString, + AccessGrantWarningWrite: '' as IntlString, + AccessGrantWarningAdmin: '' as IntlString, + GrantAccessConfirm: '' as IntlString, + AddGroupAccess: '' as IntlString, + GroupMembersCount: '' as IntlString, + GroupAccessAutoHint: '' as IntlString, + SelectAccessGroup: '' as IntlString, + NoAccessGroups: '' as IntlString, + RevokeGroupAccess: '' as IntlString }, component: { NopeComponent: '' as AnyComponent, diff --git a/plugins/tracker-resources/src/utils.ts b/plugins/tracker-resources/src/utils.ts index c0afa1ed7bc..ef149701f6e 100644 --- a/plugins/tracker-resources/src/utils.ts +++ b/plugins/tracker-resources/src/utils.ts @@ -277,8 +277,21 @@ export async function moveIssuesToAnotherMilestone ( } } -export async function canEditIssue (issue?: Issue | WithLookup): Promise { - const client = getClient() +/** + * True when the current user may edit Issue fields (title, description, + * status, dates, labels, assignee, dependencies, etc). + * + * Guest-tier accounts are blocked unconditionally — even when listed as + * Collaborator on the issue. Their Collaborator status grants read + + * comment via {@link canCommentOnIssue}, but not field-edit. This matches + * the server-side veto in the GuestPermissions middleware so the UI + * doesnt show editors that would fail on submit. + * + * Project-member guests (e.g. a guest who is a member of their own + * project) are also blocked here; if you need to allow them to + * edit issues they own, promote them to AccountRole.User+. + */ +export async function canEditIssueFields (issue?: Issue | WithLookup): Promise { if (issue === undefined) return false const account = getCurrentAccount() @@ -286,14 +299,33 @@ export async function canEditIssue (issue?: Issue | WithLookup): Promise< account.role === AccountRole.Guest || account.role === AccountRole.DocGuest || account.role === AccountRole.ReadOnlyGuest + return !isGuest +} + +/** + * True when the current user may post comments on the Issue. + * + * - User+ accounts always pass. + * - ReadOnlyGuest is always blocked. + * - Other guests pass if they are the Issues creator OR they are listed + * as Collaborator on the issue (auto-added via @-mention by the + * chunter trigger when mentionsGrantAccess is set on the class, or + * added manually via the Collaborators editor in the panel). + */ +export async function canCommentOnIssue (issue?: Issue | WithLookup): Promise { + if (issue === undefined) return false + const account = getCurrentAccount() + if (account.role === AccountRole.ReadOnlyGuest) return false + + const isGuest = account.role === AccountRole.Guest || account.role === AccountRole.DocGuest if (!isGuest) return true const isCreator = issue.createdBy !== undefined && Array.isArray(account.socialIds) && account.socialIds.includes(issue.createdBy) - if (isCreator) return true + const client = getClient() const collaborator = await client.findOne(core.class.Collaborator, { attachedTo: issue._id, collaborator: account.uuid @@ -301,6 +333,13 @@ export async function canEditIssue (issue?: Issue | WithLookup): Promise< return collaborator !== undefined } +/** + * Backwards-compat alias for callers that have not yet migrated to + * {@link canEditIssueFields}. New code should use the explicit split. + * Tag for removal in a follow-up sweep across tracker-resources. + */ +export const canEditIssue = canEditIssueFields + export function getTimeReportDate (type: TimeReportDayType): number { const date = new Date(Date.now()) diff --git a/plugins/workbench-resources/src/components/Navigator.svelte b/plugins/workbench-resources/src/components/Navigator.svelte index ca09ae04e37..21aab9d8cc2 100644 --- a/plugins/workbench-resources/src/components/Navigator.svelte +++ b/plugins/workbench-resources/src/components/Navigator.svelte @@ -13,7 +13,7 @@ // limitations under the License. -->