From eb35ef92a0d171f125e7f9c452e26815d42938cd Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Mon, 25 May 2026 20:55:13 +0000 Subject: [PATCH 01/65] feat(core): add ClassCollaborators.mentionsGrantAccess opt-in flag Adds a model-level boolean that classes can set alongside provideSecurity to indicate that @-mentions in chat/activity messages on docs of this class should auto-create Collaborator records, granting the mentioned user explicit, disclosed access. The flag has no effect unless provideSecurity is also true. provideSecurity itself is unchanged (read-visibility OR-branch through the SpaceSecurity middleware). Without mentionsGrantAccess, todays "mention is a no-op silently" behavior is preserved for QMS and Love classes. Step A1 of the plan in /opt/infrastructure/docs/superpowers/plans/ 2026-05-25-huly-mention-grants-collaborator-access.md. The accompanying model wiring (A2), Tracker opt-in (A3), middleware veto (A4), shared helper (B-1), chunter trigger update (B-2), and client surfaces (B.1, B.2, C) follow in subsequent commits. Refs hcengineering/platform#10783, #9741. Signed-off-by: Michael Uray (cherry picked from commit fcc426fa2214079e8fbff7f2074b7778b733bc7b) Signed-off-by: Michael Uray --- foundations/core/packages/core/src/classes.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/foundations/core/packages/core/src/classes.ts b/foundations/core/packages/core/src/classes.ts index b9c62466d90..07a9baa37d0 100644 --- a/foundations/core/packages/core/src/classes.ts +++ b/foundations/core/packages/core/src/classes.ts @@ -1010,8 +1010,15 @@ 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 } export interface Collaborator extends AttachedDoc { From 1d4017e5adb29308e2df3bd17a8b1a65f253f629 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Mon, 25 May 2026 20:55:43 +0000 Subject: [PATCH 02/65] feat(model/core): wire mentionsGrantAccess into TClassCollaborators model class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the new field in the runtime model schema. Follows the existing bare-annotation pattern used for the other ClassCollaborators fields (no @Prop decorators on this model class today). Field is model-internal — never appears in user-facing labels, so no IntlString or locale entries needed. Step A2. Signed-off-by: Michael Uray (cherry picked from commit 91359230610a9d9ce3d2da333e476753e3102fc2) Signed-off-by: Michael Uray --- models/core/src/core.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/models/core/src/core.ts b/models/core/src/core.ts index d6f4588a905..310a17f3dce 100644 --- a/models/core/src/core.ts +++ b/models/core/src/core.ts @@ -438,6 +438,7 @@ 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) From 0b9fa6a9fae78290c44e4ab4e4db3cd0786aace9 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Mon, 25 May 2026 20:56:26 +0000 Subject: [PATCH 03/65] feat(tracker): opt tracker.class.Issue into collaborator-grants-read + mentions-grant-access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sets provideSecurity:true and mentionsGrantAccess:true on the Issue ClassCollaborators declaration. This is the single class opting into the new behavior in this PR — QMS, Love, Cards and other classes are unaffected. Effect together with subsequent commits: - A user @-mentioned on an Issue they are not a project-member of is auto-added as Collaborator on the Issue (B-2 chunter trigger). - The Collaborator record grants them read visibility (provideSecurity in SpaceSecurity middleware). - They can post comments (chunter.class.ChatMessage createAccessLevel is already Guest). - They cannot edit Issue fields (A4 GuestPermissions middleware veto). Step A3. Signed-off-by: Michael Uray (cherry picked from commit bbf25b667461793c2964181983d10bdd2a29fb2e) Signed-off-by: Michael Uray --- models/tracker/src/index.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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, { From 2d28f4bdd6f0e0f7cdd203320d67026715b5cec6 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Mon, 25 May 2026 20:58:41 +0000 Subject: [PATCH 04/65] feat(core/collaborators): add isomorphic resolveMentionGrantTarget helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walks a Docs attachedTo chain (depth-capped at 8) to find the nearest ancestor whose ClassCollaborators has both provideSecurity:true AND mentionsGrantAccess:true. Returns that ancestor as the grant target, or null if none. Isomorphic via the findAll dependency injection — same code used by both the server-side chunter mention-trigger (B-2) and the client-side mention warning popup (C), so the disclosure UX matches the actual server-side grant. For ThreadMessage mentions: walks from ThreadMessage -> parent ChatMessage -> parent Issue, and writes Collaborator on the Issue, not on the thread or chat message. Step B-1. Signed-off-by: Michael Uray (cherry picked from commit f7b9a9909b36ab21e3fc92994d02576158e66779) Signed-off-by: Michael Uray --- .../core/packages/core/src/collaborators.ts | 57 ++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/foundations/core/packages/core/src/collaborators.ts b/foundations/core/packages/core/src/collaborators.ts index 1574a2c1401..775bf116792 100644 --- a/foundations/core/packages/core/src/collaborators.ts +++ b/foundations/core/packages/core/src/collaborators.ts @@ -13,7 +13,16 @@ // 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 +44,49 @@ 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 cc = (await findAll(core.class.ClassCollaborators, { + attachedTo: cur._class + } as DocumentQuery>))[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 parent = (await findAll(attached.attachedToClass, { + _id: attached.attachedTo + } as DocumentQuery))[0] + cur = parent + } + return null +} From b67689ca96cc8af72750adf7e97a3643748f7c6c Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Mon, 25 May 2026 20:58:03 +0000 Subject: [PATCH 05/65] feat(middleware/guest-permissions): veto field updates from collab-only guests on opt-in classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds isForbiddenCollabOnlyGuestFieldUpdate to GuestPermissionsMiddleware as a class-agnostic, pre-commit veto. Triggered only when the targeted class has both provideSecurity:true AND mentionsGrantAccess:true on its ClassCollaborators model entry. Tracker Issue is the first opt-in (set in models/tracker/src/index.ts), but any future class with the same flags inherits identical semantics with zero additional code. Semantics: - User+ accounts pass through (unchanged). - Space-member guests pass through (their existing rules apply). - Guest-tier accounts that are NOT space-members but reach the doc via Collaborator status (provideSecurity OR-branch in SpaceSecurity middleware) get a Forbidden when they try to TxUpdateDoc the doc. Effect for the user-visible scenario in #10783: - Florian @-mentioned on GAME-4 in a project he is not a member of becomes Collaborator on GAME-4 (auto, via B-2 chunter trigger). - He gets read visibility (provideSecurity). - He can post comments (ChatMessage createAccessLevel: Guest). - He CANNOT modify GAME-4 (status, title, dates, ...) — this veto. - Florian editing his own OSKOS-12 in Ostrowo where he IS a member is unaffected: space.members.includes(florian) → veto returns false. Step A4. Refs #10783, #9741. Signed-off-by: Michael Uray (cherry picked from commit 8e825a54db51f6b5eaa392d8d961a37ad4da2f7b) Signed-off-by: Michael Uray --- .../middleware/src/guestPermissions.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/foundations/server/packages/middleware/src/guestPermissions.ts b/foundations/server/packages/middleware/src/guestPermissions.ts index 077d546a84d..31415c5b786 100644 --- a/foundations/server/packages/middleware/src/guestPermissions.ts +++ b/foundations/server/packages/middleware/src/guestPermissions.ts @@ -10,6 +10,7 @@ import core, { type Class, type Doc, type ClassPermission, + getClassCollaborators, type Permission, hasAccountRole, type MeasureContext, @@ -157,9 +158,56 @@ 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, {})) } + if (await this.isForbiddenCollabOnlyGuestFieldUpdate(ctx, cudTx, account)) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } } } + /** + * Class-agnostic veto for field updates on docs whose class has opted into + * mention-grants-access. The opt-in is the pair (provideSecurity: true, + * mentionsGrantAccess: true) on the class's ClassCollaborators model entry. + * + * For such classes, a guest-tier account that obtained read visibility ONLY + * through Collaborator status (i.e. is NOT in the doc's space.members) must + * not be able to modify the doc's fields via TxUpdateDoc. Comments via + * chunter.class.ChatMessage createAccessLevel still pass through. + * + * Space-member guests retain their current behavior — they pass through + * this check untouched and their normal access rules continue to apply. + * User+ accounts always pass through. + * + * 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 { + if (cudTx._class !== core.class.TxUpdateDoc) return false + + const isGuest = + account.role === AccountRole.Guest || + account.role === AccountRole.DocGuest || + account.role === AccountRole.ReadOnlyGuest + if (!isGuest) 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] + if (space === undefined) return false + if (space.members?.includes(account.uuid) === true) 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. From be38db3fd52d8c1b8bc753155069ea1359fb4b75 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Thu, 9 Jul 2026 07:07:59 +0000 Subject: [PATCH 06/65] feat(tracker/utils): split canEditIssue into canEditIssueFields + canCommentOnIssue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously canEditIssue conflated "may edit fields" with "may comment". A guest who was Collaborator on an issue ended up with all field editors enabled (too permissive) — or, when blocked, lost the comment composer too (too restrictive). The split: - canEditIssueFields: Issue field editors (title, status, dates, description, ...). Returns false for any guest-tier account. - canCommentOnIssue: Comment composer. Returns true for User+, false for ReadOnlyGuest, and for Guest/DocGuest true when the user is the Issues creator OR listed as Collaborator on the issue. Existing canEditIssue is kept as a backwards-compat alias for canEditIssueFields until callers are migrated. EditIssue.svelte already migrates in the next commit (B.2). Pairs with the server-side veto in A4 (GuestPermissions middleware) that rejects TxUpdateDoc for collab-only guests at the tx layer — so a guest cannot route around this UI gate via raw API. Step B.1. Signed-off-by: Michael Uray (cherry picked from commit b49c7c59e815433a0a38f7d25248d2bc078110a5) Signed-off-by: Michael Uray --- plugins/tracker-resources/src/utils.ts | 48 ++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/plugins/tracker-resources/src/utils.ts b/plugins/tracker-resources/src/utils.ts index c0afa1ed7bc..4946a1ac00b 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,35 @@ 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 +335,14 @@ 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()) From f07d6e0c40e5f0b57be22d23dafc57f25a499e61 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Mon, 25 May 2026 21:02:00 +0000 Subject: [PATCH 07/65] feat(tracker/EditIssue): show comment composer separately from field editors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EditIssue.svelte used a single effectiveReadonly flag to gate both the issue-field editors (title, status, dates, description, dependencies, ...) AND the Panels comment composer below them. With the v6 split a Collaborator-Guest must be able to comment without unlocking the editors, so the two need separate variables. Adds canComment alongside effectiveReadonly: - effectiveReadonly stays driven by canEditIssueFields() — Guest gets read-only field editors. - canComment is driven by canCommentOnIssue() — Guest who is the Issues creator or listed as Collaborator gets the comment composer. Panels withoutInput now reads !canComment; all field-editor readonly props keep using effectiveReadonly. Step B.2. Signed-off-by: Michael Uray (cherry picked from commit 7449ad0d435c2eb43f12c8b1b41f31702eb6ac48) Signed-off-by: Michael Uray --- .../components/issues/edit/EditIssue.svelte | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) 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 @@ Date: Mon, 25 May 2026 21:18:50 +0000 Subject: [PATCH 08/65] fix(chunter-resources): add @hcengineering/text-core dependency ChatMessageInput.svelte imports extractReferences from text-core for the mention warning popup. The package was already an indirect dep via text-editor-resources but webpack module resolution requires it as a direct entry in package.json. Added; pnpm-lock regenerated via rush update. Signed-off-by: Michael Uray (cherry picked from commit 7c58a562eeaaf54f94361e8a52cb9cb112fd2766) Signed-off-by: Michael Uray --- common/config/rush/pnpm-lock.yaml | 3 +++ plugins/chunter-resources/package.json | 1 + 2 files changed, 4 insertions(+) diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 57a469ad1fa..35c7aa6b1d5 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -18056,6 +18056,9 @@ importers: '@hcengineering/text': specifier: workspace:^0.7.19 version: link:../../foundations/core/packages/text + '@hcengineering/text-core': + specifier: workspace:^0.7.0 + version: link:../../foundations/core/packages/text-core '@hcengineering/text-editor': specifier: workspace:^0.7.0 version: link:../text-editor diff --git a/plugins/chunter-resources/package.json b/plugins/chunter-resources/package.json index 05123872f19..64a71cfcf4a 100644 --- a/plugins/chunter-resources/package.json +++ b/plugins/chunter-resources/package.json @@ -57,6 +57,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.0", "@hcengineering/ui": "workspace:^0.7.0", "@hcengineering/view": "workspace:^0.7.0", "@hcengineering/view-resources": "workspace:^0.7.0", From 961e33c43136b6cbd9788e2ac3e43e4b9126dd9f Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Mon, 25 May 2026 21:00:14 +0000 Subject: [PATCH 09/65] feat(chunter-trigger): mention-grants-access uses shared helper + grant-target dedup Replaces the prior provideSecurity!==true guard with three explicit branches driven by resolveMentionGrantTarget(): 1. grantTarget non-null (opt-in class found via attachedTo chain): write Collaborator records on the grant-target Doc with dedup against the GRANT-TARGETs collaborator list, not the original message Docs list. Fixes the thread-reply case where the mention would otherwise land on the parent ChatMessage instead of the Issue. 2. grantTarget null + targetDoc not provideSecurity: keep todays notification-routing behavior on targetDoc (Channels, DMs). 3. grantTarget null + targetDoc provideSecurity (without opt-in): no-op. Preserves QMS / Love behavior bit-for-bit. Step B-2. Refs #10783, #9741. Signed-off-by: Michael Uray (cherry picked from commit ad78e4edb0b953883a904e50ea8adaab23aee1c1) Signed-off-by: Michael Uray --- server-plugins/chunter-resources/src/index.ts | 65 +++++++++++++++---- 1 file changed, 53 insertions(+), 12 deletions(-) diff --git a/server-plugins/chunter-resources/src/index.ts b/server-plugins/chunter-resources/src/index.ts index 91896f6654c..582f7fc5baf 100644 --- a/server-plugins/chunter-resources/src/index.ts +++ b/server-plugins/chunter-resources/src/index.ts @@ -37,7 +37,9 @@ import core, { TxUpdateDoc, UserStatus, getClassCollaborators, - type MeasureContext + resolveMentionGrantTarget, + type MeasureContext, + type Collaborator } from '@hcengineering/core' import notification, { DocNotifyContext, NotificationContent } from '@hcengineering/notification' import { getMetadata, IntlString, translate } from '@hcengineering/platform' @@ -208,25 +210,64 @@ async function OnChatMessageCreated (ctx: MeasureContext, tx: TxCUD, contro } } - const classCollab = ( + // Resolve the Doc the mention-Collaborator records should land on. + // - targetDoc has provideSecurity:true && mentionsGrantAccess:true: + // helper returns targetDoc itself → grants access on that doc. + // - targetDoc unprotected, but its attachedTo chain reaches an opted-in + // ancestor (e.g. ThreadMessage → ChatMessage → Issue): helper returns + // that ancestor → grants access on the Issue, not the thread. + // - nothing in the chain is opted in: helper returns null. + // The grant-target branch writes Collaborator on the resolved doc and + // dedups against THAT doc's collaborator list (not against targetDoc's, + // which is the wrong basis when targetDoc is a child like ThreadMessage). + const grantTarget = await resolveMentionGrantTarget(targetDoc, (cls, q) => + control.findAll(control.ctx, cls, q) + ) + const targetClassCollab = ( await control.findAll(control.ctx, core.class.ClassCollaborators, { attachedTo: targetDoc._class }) )[0] - if (classCollab?.provideSecurity !== true) { + const isProtectedTarget = targetClassCollab?.provideSecurity === true + + if (grantTarget != null) { + const grantCollabs = ( + await control.findAll(control.ctx, core.class.Collaborator, { + attachedTo: grantTarget._id + }) + ).map((c) => c.collaborator) + + for (const collab of collaboratorsFromMessage) { + if (grantCollabs.includes(collab)) { + continue + } + res.push( + control.txFactory.createTxCreateDoc(core.class.Collaborator, grantTarget.space, { + attachedTo: grantTarget._id, + attachedToClass: grantTarget._class, + collaborator: collab, + collection: 'collaborators' + }) + ) + } + } else if (!isProtectedTarget) { + // Legacy notification-routing path: targetDoc is not provideSecurity, + // so Collaborator records here are purely for notification fan-out + // (today's behavior for Channels, DirectMessages, etc.). for (const collab of collaboratorsFromMessage) { if (currentCollaborators.includes(collab)) { continue } - - const tx = control.txFactory.createTxCreateDoc(core.class.Collaborator, targetDoc.space, { - attachedTo: targetDoc._id, - attachedToClass: targetDoc._class, - collaborator: collab, - collection: 'collaborators' - }) - - res.push(tx) + res.push( + control.txFactory.createTxCreateDoc(core.class.Collaborator, targetDoc.space, { + attachedTo: targetDoc._id, + attachedToClass: targetDoc._class, + collaborator: collab, + collection: 'collaborators' + }) + ) } } + // Else: protected target without mentionsGrantAccess (QMS / Love today) + // → no-op, preserving pre-PR behavior for those classes. if (account != null && isChannel && !(targetDoc as Channel).members.includes(account)) { res.push(...joinChannel(control, targetDoc as Channel, account)) From d886ee0aabc4df1aaf27ff1c96ad88aca4898d1b Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Mon, 25 May 2026 21:03:38 +0000 Subject: [PATCH 10/65] feat(chunter/ChatMessageInput): warn before mention auto-grants Issue access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disclosure UX for the mention-grants-access flow. Before submitting a message containing @-mentions, check whether the resolved grant-target Doc (via the shared resolveMentionGrantTarget helper) has those mentioned users in its space.members list. If any mention would grant new access, show a MessageBox warning with the actor and the names of the new grantees, and require explicit confirmation. Important framing: - This is disclosure UX for the standard client, NOT a security gate. Access is enforced server-side by SpaceSecurityMiddleware + the chunter trigger that creates Collaborator records. A scripted API client can still bypass the dialog. - For thread replies: the grant-target resolves to the root Issue, so the warning correctly names the project the user would gain access to (not the thread). - For Channels and other non-opted-in classes: grantTarget is null, no warning shown — preserves todays behavior bit-for-bit. Step C. Refs #10783, #9741. Signed-off-by: Michael Uray (cherry picked from commit ab212dbf61a828334ceb88ca470466b8c40d3fdd) Signed-off-by: Michael Uray --- .../chat-message/ChatMessageInput.svelte | 91 ++++++++++++++++++- 1 file changed, 87 insertions(+), 4 deletions(-) diff --git a/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte b/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte index 4f9c51ed2c4..9abc5218196 100644 --- a/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte +++ b/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte @@ -17,12 +17,25 @@ 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 { createQuery, DraftController, draftsStore, getClient } from '@hcengineering/presentation' - import { EmptyMarkup, isEmptyMarkup } from '@hcengineering/text' + 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 { getEmbeddedLabel } from '@hcengineering/platform' + import { createQuery, DraftController, draftsStore, getClient, MessageBox } from '@hcengineering/presentation' + 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 } from '@hcengineering/ui' import { getSpace, editingMessageStore } from '@hcengineering/activity-resources' import { getChannelSpace } from '../../utils' @@ -126,8 +139,78 @@ currentMessage.attachments = attachments } + /** + * Disclosure UX for the mention-grants-access flow. If the message + * mentions a Person whose AccountUuid is NOT already in the resolved + * grant-target's space.members, show a confirmation dialog before + * submitting. The actual access grant happens server-side via the + * chunter trigger (Collaborator record) + SpaceSecurity middleware + * (provideSecurity OR-branch). This dialog only surfaces the fact + * to the actor — a scripted API client could still bypass it. + * + * Returns true if the message should be sent, false to cancel. + */ + async function confirmMentionGrantsAccess (markup: string): Promise { + if (markup === undefined || markup === '' || isEmptyMarkup(markup)) return true + let node + try { + node = markupToJSON(markup) + } catch { + return true + } + const references = extractReferences(node) + const mentionedPersonIds = references + .filter(({ objectClass }) => hierarchy.isDerived(objectClass, contact.class.Person)) + .map(({ objectId }) => objectId as Ref) + if (mentionedPersonIds.length === 0) return true + + const grantTarget = await resolveMentionGrantTarget(object, (cls, q) => client.findAll(cls, q)) + if (grantTarget == null) return true + + const space = (await client.findAll(core.class.Space, { _id: grantTarget.space }))[0] + if (space === undefined) return true + 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 true + + const names = newGrantees.map((p) => `${p.name ?? p.personUuid}`).join(', ') + const targetName: string = + (grantTarget as any).name ?? (grantTarget as any).title ?? grantTarget._id + const spaceName: string = (space as any).name ?? space._id + + return await new Promise((resolve) => { + showPopup( + MessageBox, + { + label: getEmbeddedLabel('Heads up — this mention grants access'), + message: getEmbeddedLabel( + `${names} ${ + newGrantees.length === 1 ? 'is not a member' : 'are not members' + } of "${spaceName}". Sending this comment will grant ${ + newGrantees.length === 1 ? 'them' : 'them' + } read access to "${targetName}" and the ability to post comments on it. They will not be able to edit the document's fields. This is enforced by server policy; if you cancel, no access is granted.` + ), + okLabel: getEmbeddedLabel('Send and grant access'), + dangerous: false, + canSubmit: true + }, + undefined, + (res?: boolean) => { + resolve(res === true) + } + ) + }) + } + async function handleCreate (event: CustomEvent, _id: Ref): Promise { try { + const proceed = await confirmMentionGrantsAccess(event.detail?.message ?? '') + if (!proceed) return + const res = await createMessage(event, _id, `chunter.create.${_class} ${object._class}`) console.log(`create.${_class} measure`, res.serverTime, res.time) From f5157a078211a64cb4ab6827ae37efa9d00f689b Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Mon, 25 May 2026 21:52:44 +0000 Subject: [PATCH 11/65] feat(middleware/spaceSecurity): let Guests read collab-secured docs across spaces SpaceSecurityMiddleware.findAll() pre-filtered every query by the caller's allowed-spaces set, which always strips docs in foreign spaces before the database adapter ever sees the query. That defeated the Postgres adapter's `collabRes` OR-branch (see postgres/src/storage.ts, getSecurityClause): a Guest who is a Collaborator on a single Issue in a project they are not a member of would never receive that Issue, even though the adapter has the SQL to surface it. This change skips the middleware-level space filter when: - the target class has ClassCollaborators.provideSecurity === true or provideAttachedSecurity === true, AND - the caller's role is Guest or ReadOnlyGuest. For those calls the Postgres adapter still applies its space-membership clause and additionally OR-joins Collaborator records, giving the user visibility on the individual docs they were added to (directly via provideSecurity, or via their parent via provideAttachedSecurity for ActivityMessage / DocUpdateMessage). All other roles (User, Maintainer, Owner, Admin, DocGuest, System) take the existing code path unchanged. Note for non-Postgres adapters: the Mongo adapter does not currently implement the collab OR-branch, so this bypass only takes effect on Postgres deployments (the supported production target). On Mongo the visibility behavior is unchanged from before because there was no collab OR-clause to fall through to. Signed-off-by: Michael Uray (cherry picked from commit 5db91841cae1fc9bdad898229e8b213f72a72c6b) Signed-off-by: Michael Uray --- .../packages/middleware/src/spaceSecurity.ts | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/foundations/server/packages/middleware/src/spaceSecurity.ts b/foundations/server/packages/middleware/src/spaceSecurity.ts index 4c8bd4223e1..985f47f2180 100644 --- a/foundations/server/packages/middleware/src/spaceSecurity.ts +++ b/foundations/server/packages/middleware/src/spaceSecurity.ts @@ -630,7 +630,26 @@ 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 + const collabReadBypass = + (collabSec?.provideSecurity === true || collabSec?.provideAttachedSecurity === true) && + [AccountRole.Guest, AccountRole.ReadOnlyGuest].includes(account.role) + + if ( + !isSystem(account, ctx) && + account.role !== AccountRole.DocGuest && + domain !== DOMAIN_MODEL && + !collabReadBypass + ) { if (!isOwner(account, ctx) || !isSpace || !showArchived) { if (newQuery[field] !== undefined) { const res = await this.mergeQuery(ctx, account, newQuery[field], domain, isSpace, showArchived) From e6f6fcfb7551ce8f0aea75d1dbd7ce4438be139a Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Mon, 25 May 2026 22:05:53 +0000 Subject: [PATCH 12/65] feat(security): allow non-System callers to read their own Collaborator records The tracker "Subscribed" tab in the client queries `findAll(Collaborator, { collaborator: self, attachedToClass: Issue })` to build the user's subscription list. Until now that query was silently filtered to spaces the user is a member of, so a Guest who became a Collaborator on an Issue in a project they are not a member of (e.g. via the new mentions-grant-access flow) saw an empty Subscribed list. This change introduces a self-Collaborator visibility rule consistent across the two enforcement layers: - foundations/server/packages/postgres/src/storage.ts `addSecurity` now appends `OR .collaborator = ''` whenever the queried domain is DOMAIN_COLLABORATOR. The user can always see their own Collaborator rows. - foundations/server/packages/middleware/src/spaceSecurity.ts `findAll` skips its space pre-filter when the requested class is core.class.Collaborator (`selfCollabBypass`), so the Postgres adapter actually receives the query and can apply its self-row OR-branch. Other rows in the same workspace remain hidden by the space-membership clause that still runs first. Scope: applies to all non-Admin/non-System callers (Users included). Users were not visibly affected before because they are typically members of every space whose docs they collaborate on, but the rule is the same: you may read your own subscription rows. Signed-off-by: Michael Uray (cherry picked from commit 51fa7ba162a7d1df7ed382c50d915e4fde4fab68) Signed-off-by: Michael Uray --- .../server/packages/middleware/src/spaceSecurity.ts | 9 ++++++++- foundations/server/packages/postgres/src/storage.ts | 8 ++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/foundations/server/packages/middleware/src/spaceSecurity.ts b/foundations/server/packages/middleware/src/spaceSecurity.ts index 985f47f2180..454dfed745b 100644 --- a/foundations/server/packages/middleware/src/spaceSecurity.ts +++ b/foundations/server/packages/middleware/src/spaceSecurity.ts @@ -643,12 +643,19 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar const collabReadBypass = (collabSec?.provideSecurity === true || collabSec?.provideAttachedSecurity === true) && [AccountRole.Guest, AccountRole.ReadOnlyGuest].includes(account.role) + // 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 = this.context.hierarchy.isDerived(_class, core.class.Collaborator) if ( !isSystem(account, ctx) && account.role !== AccountRole.DocGuest && domain !== DOMAIN_MODEL && - !collabReadBypass + !collabReadBypass && + !selfCollabBypass ) { if (!isOwner(account, ctx) || !isSpace || !showArchived) { if (newQuery[field] !== undefined) { diff --git a/foundations/server/packages/postgres/src/storage.ts b/foundations/server/packages/postgres/src/storage.ts index c2fec296227..413b19632a3 100644 --- a/foundations/server/packages/postgres/src/storage.ts +++ b/foundations/server/packages/postgres/src/storage.ts @@ -647,6 +647,14 @@ abstract class PostgresAdapterBase implements DbAdapter { 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}'` + } return `AND (${res}${collabRes})` } } From bc25a70d8f3e3e7a557f41a64196c764a401374b Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Mon, 25 May 2026 22:43:52 +0000 Subject: [PATCH 13/65] feat(security+nav): list collab-only spaces in workbench navigator for Guests A Guest who is a Collaborator on a doc inside a Space they are not a member of (e.g. via the new mentions-grant-access flow) could open the doc directly via URL and see it listed in tracker "Subscribed", but the containing project never appeared in the "Your Projects" nav tree because that tree only listed member-spaces. This change extends visibility on three layers: - foundations/server/packages/postgres/src/storage.ts `addSecurity` adds an `OR EXISTS (collaborator c WHERE c.space = space._id AND c.collaborator = '')` branch for Guest/ ReadOnlyGuest reads against DOMAIN_SPACE. A Space hosting any Collaborator record naming the caller becomes readable. - foundations/server/packages/middleware/src/spaceSecurity.ts `findAll` skips its space pre-filter when the target class is a Space and the caller is Guest/ReadOnlyGuest (`spaceCollabBypass`), so the new Postgres OR-branch actually fires. - plugins/workbench-resources/src/components/Navigator.svelte A second query against Collaborator (scoped to self by A6's self- collab visibility rule) collects the unique `space` IDs of the caller's Collaborator records, fetches those Spaces narrowed to the navigator's class set, and merges them into the displayed spaces alongside member-spaces. Existing member-spaces semantics for User+/Admin accounts are unchanged: the second query is skipped for admins, and `members: ` stays on the primary query for everyone, so public-but-not-member projects are not surfaced as a side effect. Clicking a collab-only project still opens the existing Issues view, which now shows only the docs the user can actually see (member-or- collab-on-doc) thanks to the earlier A5/A6 work. Components, Milestones and Templates sub-nodes will show empty for collab-only projects. Signed-off-by: Michael Uray (cherry picked from commit 64105d1f14bed4ef18a330861937273685fcc21c) Signed-off-by: Michael Uray --- .../packages/middleware/src/spaceSecurity.ts | 9 ++- .../server/packages/postgres/src/storage.ts | 10 ++++ .../src/components/Navigator.svelte | 59 ++++++++++++++++++- 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/foundations/server/packages/middleware/src/spaceSecurity.ts b/foundations/server/packages/middleware/src/spaceSecurity.ts index 454dfed745b..2676b6e1590 100644 --- a/foundations/server/packages/middleware/src/spaceSecurity.ts +++ b/foundations/server/packages/middleware/src/spaceSecurity.ts @@ -649,13 +649,20 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar // tab `{collaborator: self, attachedToClass: Issue}`, which must surface the // user's own subscriptions even on docs in non-member spaces. const selfCollabBypass = 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). + const spaceCollabBypass = + isSpace && [AccountRole.Guest, AccountRole.ReadOnlyGuest].includes(account.role) if ( !isSystem(account, ctx) && account.role !== AccountRole.DocGuest && domain !== DOMAIN_MODEL && !collabReadBypass && - !selfCollabBypass + !selfCollabBypass && + !spaceCollabBypass ) { if (!isOwner(account, ctx) || !isSpace || !showArchived) { if (newQuery[field] !== undefined) { diff --git a/foundations/server/packages/postgres/src/storage.ts b/foundations/server/packages/postgres/src/storage.ts index 413b19632a3..4ada23e60df 100644 --- a/foundations/server/packages/postgres/src/storage.ts +++ b/foundations/server/packages/postgres/src/storage.ts @@ -655,6 +655,16 @@ abstract class PostgresAdapterBase implements DbAdapter { 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. + if (domain === DOMAIN_SPACE && [AccountRole.Guest, AccountRole.ReadOnlyGuest].includes(acc.role)) { + 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/plugins/workbench-resources/src/components/Navigator.svelte b/plugins/workbench-resources/src/components/Navigator.svelte index ca09ae04e37..d76a7c290b9 100644 --- a/plugins/workbench-resources/src/components/Navigator.svelte +++ b/plugins/workbench-resources/src/components/Navigator.svelte @@ -35,17 +35,33 @@ const client = getClient() const hierarchy = client.getHierarchy() const query = createQuery() + // Collab-spaces: spaces that host docs the current account is a Collaborator on, + // even when the account is not in the space's `members`. Powers nav-tree visibility + // for collab-only Guests on classes that opted into ClassCollaborators.provideSecurity. + const collabSpaceLookupQuery = createQuery() + const collabSpaceQuery = createQuery() - let spaces: Space[] = [] + let memberSpaces: Space[] = [] + let collabSpaces: Space[] = [] + let collabSpaceIds: Set> = new Set>() let starred: Space[] = [] let shownSpaces: Space[] = [] const adminUser = isAdminUser() + let activeClasses: Ref[] = [] + + $: spaces = adminUser || collabSpaces.length === 0 + ? memberSpaces + : (() => { + const seen = new Set(memberSpaces.map((s) => s._id)) + return [...memberSpaces, ...collabSpaces.filter((s) => !seen.has(s._id))] + })() $: if (model) { const classes = Array.from(new Set(getSpecialSpaceClass(model).flatMap((c) => hierarchy.getDescendants(c)))).filter( (it) => !hierarchy.isMixin(it) ) + activeClasses = classes as Ref[] if (classes.length > 0) { query.query( classes.length === 1 ? classes[0] : core.class.Space, @@ -56,15 +72,54 @@ } : { ...(classes.length === 1 ? {} : { _class: { $in: classes } }) }, (result) => { - spaces = result + memberSpaces = result }, { sort: { name: SortingOrder.Ascending } } ) } else { query.unsubscribe() + memberSpaces = [] } } + // Track every Space that hosts a Collaborator record naming the current account. + // We track unconditionally (cheap query, scoped to self by A6) and let the second + // query narrow by the navigator's class set. + $: if (!adminUser) { + collabSpaceLookupQuery.query( + core.class.Collaborator, + { collaborator: getCurrentAccount().uuid }, + (collabs) => { + const next = new Set>(collabs.map((c) => c.space)) + if (next.size !== collabSpaceIds.size || !Array.from(next).every((id) => collabSpaceIds.has(id))) { + collabSpaceIds = next + } + }, + { projection: { space: 1 } } + ) + } else { + collabSpaceLookupQuery.unsubscribe() + collabSpaceIds = new Set>() + } + + $: if (!adminUser && collabSpaceIds.size > 0 && activeClasses.length > 0) { + const classes = activeClasses + collabSpaceQuery.query( + classes.length === 1 ? classes[0] : core.class.Space, + { + _id: { $in: Array.from(collabSpaceIds) }, + ...(classes.length === 1 ? {} : { _class: { $in: classes } }) + }, + (result) => { + collabSpaces = result + }, + { sort: { name: SortingOrder.Ascending } } + ) + } else { + collabSpaceQuery.unsubscribe() + collabSpaces = [] + } + let specials: SpecialNavModel[] = [] let preferences: Map, SpacePreference> = new Map, SpacePreference>() From 30ad68df7662aa47ad0521808e3da4689acaee67 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Tue, 26 May 2026 02:59:11 +0000 Subject: [PATCH 14/65] fix(nav): surface collab-only projects in tracker nav-tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workbench Navigator already fetched collab-only Spaces and merged them into the spaces array, but each SpacesNav then ran its `visibleIf` resource filter against the entries — and tracker's `IsProjectJoined` only returned true for members. The result was that collab-only projects were correctly fetched, hashed and passed to the component, then immediately filtered back out before render. Changes: - plugins/workbench-resources/src/components/Navigator.svelte Refactor activeClasses into a top-level reactive declaration so Svelte tracks it as a dependency in the downstream collab-space query. Drop the diagnostic console.log lines that helped track this down. - plugins/tracker-resources/src/index.ts Extend IsProjectJoined: return true also when the caller has any Collaborator record attached to a doc inside this project. Reuses A6 self-collaborator visibility so the lookup works for Guests that are not space members. Verified end-to-end against the dk3 test workspace: Florian (Guest, collab on GAME-4 only) now sees the Game Design project in his Your Projects tree, with the Issues special filtered down to only the docs he is actually a Collaborator on. Components / Milestones / Templates remain empty for the same caller. Signed-off-by: Michael Uray (cherry picked from commit 97b58bd5063bb690211e8bc40fbf024cb7e00aae) Signed-off-by: Michael Uray --- plugins/tracker-resources/src/index.ts | 16 ++++++- .../src/components/Navigator.svelte | 46 +++++++++---------- 2 files changed, 37 insertions(+), 25 deletions(-) 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/workbench-resources/src/components/Navigator.svelte b/plugins/workbench-resources/src/components/Navigator.svelte index d76a7c290b9..8be6f5d98c7 100644 --- a/plugins/workbench-resources/src/components/Navigator.svelte +++ b/plugins/workbench-resources/src/components/Navigator.svelte @@ -48,7 +48,12 @@ let shownSpaces: Space[] = [] const adminUser = isAdminUser() - let activeClasses: Ref[] = [] + + $: activeClasses = (model + ? Array.from(new Set(getSpecialSpaceClass(model).flatMap((c) => hierarchy.getDescendants(c)))).filter( + (it) => !hierarchy.isMixin(it) + ) + : []) as Ref[] $: spaces = adminUser || collabSpaces.length === 0 ? memberSpaces @@ -57,29 +62,24 @@ return [...memberSpaces, ...collabSpaces.filter((s) => !seen.has(s._id))] })() - $: if (model) { - const classes = Array.from(new Set(getSpecialSpaceClass(model).flatMap((c) => hierarchy.getDescendants(c)))).filter( - (it) => !hierarchy.isMixin(it) + $: if (model && activeClasses.length > 0) { + const classes = activeClasses + query.query( + classes.length === 1 ? classes[0] : core.class.Space, + !adminUser + ? { + ...(classes.length === 1 ? {} : { _class: { $in: classes } }), + members: getCurrentAccount().uuid + } + : { ...(classes.length === 1 ? {} : { _class: { $in: classes } }) }, + (result) => { + memberSpaces = result + }, + { sort: { name: SortingOrder.Ascending } } ) - activeClasses = classes as Ref[] - if (classes.length > 0) { - query.query( - classes.length === 1 ? classes[0] : core.class.Space, - !adminUser - ? { - ...(classes.length === 1 ? {} : { _class: { $in: classes } }), - members: getCurrentAccount().uuid - } - : { ...(classes.length === 1 ? {} : { _class: { $in: classes } }) }, - (result) => { - memberSpaces = result - }, - { sort: { name: SortingOrder.Ascending } } - ) - } else { - query.unsubscribe() - memberSpaces = [] - } + } else if (model) { + query.unsubscribe() + memberSpaces = [] } // Track every Space that hosts a Collaborator record naming the current account. From 2c916a23776bc58e0949e59cf1afe9e78faabecd Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Tue, 26 May 2026 05:37:06 +0000 Subject: [PATCH 15/65] fix(tracker/nav): hide non-Issues sub-nodes on non-member projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A9 follow-up to the mention-grants nav-tree work. When a Guest sees a project in 'Your Projects' because they are a Collaborator on a doc inside it (A7) — or any non-member who lands a project in the tree via the new IsProjectJoined fallback (A8) — only the Issues sub-node has anything to render. Components, Milestones, and Templates have no provideSecurity opt-in, so the postgres collab-OR-branch does not fire on them and the queries come back empty. The user sees three silent dead-end entries. Fix in ProjectSpacePresenter: derive isCollabOnlyProject from space.members membership and filter the specials down to id == 'issues' when the caller is not a member. Members keep all four sub-nodes; the visibleIf chain for collab-only projects keeps Issues and nothing else. This is a UI-only narrowing. Backend visibility is unchanged: A5/A6/A7 still control what the user can actually see when they navigate elsewhere, the postgres adapter still does its filter, and the middleware bypass remains scoped to provideSecurity classes. If a future class opts into provideSecurity / provideAttachedSecurity for a project sub-collection (Components etc), the filter here can be relaxed without touching the underlying security model. Signed-off-by: Michael Uray (cherry picked from commit c76073c0bf01601bd76150626005cae33a51bf88) Signed-off-by: Michael Uray --- .../projects/ProjectSpacePresenter.svelte | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/plugins/tracker-resources/src/components/projects/ProjectSpacePresenter.svelte b/plugins/tracker-resources/src/components/projects/ProjectSpacePresenter.svelte index 17f004068b6..20d90a035bd 100644 --- a/plugins/tracker-resources/src/components/projects/ProjectSpacePresenter.svelte +++ b/plugins/tracker-resources/src/components/projects/ProjectSpacePresenter.svelte @@ -13,7 +13,7 @@ // 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/plugin.ts b/plugins/tracker-resources/src/plugin.ts index eed088c59d5..dadcd1249b6 100644 --- a/plugins/tracker-resources/src/plugin.ts +++ b/plugins/tracker-resources/src/plugin.ts @@ -307,7 +307,8 @@ export default mergeIds(trackerId, tracker, { UnsetParent: '' as IntlString, PreviousAssigned: '' as IntlString, EditRelatedTargets: '' as IntlString, - RelatedIssueTargetDescription: '' as IntlString + RelatedIssueTargetDescription: '' as IntlString, + SharedWithYouTooltip: '' as IntlString }, component: { NopeComponent: '' as AnyComponent, From 8069e28572f380ea2a99fb1053712a1ff724cb28 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Wed, 27 May 2026 21:09:44 +0000 Subject: [PATCH 17/65] test(tracker): V2 - mention-grant persists after issue moves to Done Regression guard: a user mentioned in an issue comment stays a Collaborator (the record that backs read access) after the issue is moved to Done. Built on the sanity page-object helpers, mirroring the existing mentions.spec assertion style. Guest-perspective UI walk is a documented follow-up (needs a second auth storage state). Signed-off-by: Michael Uray (cherry picked from commit 27d2c58103cc5e530fba3af851a6426081400a42) Signed-off-by: Michael Uray --- .../mention-grants-closed-issue.spec.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 tests/sanity/tests/tracker/mention-grants-closed-issue.spec.ts diff --git a/tests/sanity/tests/tracker/mention-grants-closed-issue.spec.ts b/tests/sanity/tests/tracker/mention-grants-closed-issue.spec.ts new file mode 100644 index 00000000000..73f1a5646dc --- /dev/null +++ b/tests/sanity/tests/tracker/mention-grants-closed-issue.spec.ts @@ -0,0 +1,63 @@ +import { test } from '@playwright/test' +import { generateId, PlatformSetting, PlatformURI } from '../utils' +import { IssuesPage } from '../model/tracker/issues-page' +import { IssuesDetailsPage } from '../model/tracker/issues-details-page' +import { TrackerNavigationMenuPage } from '../model/tracker/tracker-navigation-menu-page' +import { NewIssue } from '../model/tracker/types' + +test.use({ + storageState: PlatformSetting +}) + +test.describe('mention-grants — closed issue access', () => { + let trackerNav: TrackerNavigationMenuPage + let issuesPage: IssuesPage + let issuesDetailsPage: IssuesDetailsPage + + test.beforeEach(async ({ page }) => { + trackerNav = new TrackerNavigationMenuPage(page) + issuesPage = new IssuesPage(page) + issuesDetailsPage = new IssuesDetailsPage(page) + + await (await page.goto(`${PlatformURI}/workbench/sanity-ws`))?.finished() + }) + + test('mentioned user remains a Collaborator after the issue is moved to Done', async ({ page }) => { + const issue: NewIssue = { + title: `V2 closed-issue mention-grant-${generateId()}`, + description: 'Mention a user, then close the issue; the mention-grant must persist.' + } + + // Navigate to the Default project's issues list and show all issues. + // openIssuesForProject + clickModelSelectorAll mirrors the pattern used + // by issues-duplicate.spec.ts and attachments.spec.ts. + await trackerNav.openIssuesForProject('Default') + await issuesPage.clickModelSelectorAll() + + await issuesPage.createNewIssue(issue) + await issuesPage.searchIssueByName(issue.title) + await issuesPage.openIssueByName(issue.title) + + // Mention a user via the real mention picker (Dirak Kainin is the seed + // contact used by the existing mentions.spec.ts:37). addMentions() opens + // the @-popup and selects the match — addComment() would only send literal + // text and would NOT create a reference node. + await issuesDetailsPage.addMentions('Dirak Kainin') + + // Move the issue to Done. editIssue accepts a partial Issue; the status + // field clicks the status button and selects from the dropdown. + await issuesDetailsPage.editIssue({ status: 'Done' }) + + // Reload and confirm the mentioned user is listed under Collaborators. + // The issue author ('Appleseed John') and the mentioned user ('Dirak Kainin') + // must both be present — this mirrors the assertion in mentions.spec.ts:40. + await page.reload() + await issuesDetailsPage.checkCollaborators(['Appleseed John', 'Dirak Kainin']) + }) +}) + +// TODO (V2b): add a guest storage state and assert the issue is visible + +// commentable from the guest's own session. The above test covers the +// server-side grant (Collaborator record exists) which is the actual +// regression risk; the guest-perspective UI walk needs a second authenticated +// Playwright storage state. From dc3e380c298b4b3fec1614363c91f1c8a38ce1dd Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Wed, 27 May 2026 21:13:45 +0000 Subject: [PATCH 18/65] feat(text-core): V3a - grantsAccess attr on mention references Add a string grantsAccess ('true'|'false'|undefined) to ReferenceMarkupNode and surface it on extractReferences() with any-wins dedup (a person is denied only if every reference to them is explicitly 'false'). undefined preserves pre-V3 grant-by-default. Tiptap ReferenceNode keeps the data-grants-access attribute across editor round-trips. Signed-off-by: Michael Uray (cherry picked from commit e21951022c909236113ade6aa75498b204fa595d) Signed-off-by: Michael Uray --- .../src/markup/__tests__/reference.test.ts | 80 +++++++++++++++++++ .../packages/text-core/src/markup/model.ts | 8 +- .../text-core/src/markup/reference.ts | 15 +++- .../core/packages/text/src/nodes/reference.ts | 10 +++ 4 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 foundations/core/packages/text-core/src/markup/__tests__/reference.test.ts 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..21479d166c2 --- /dev/null +++ b/foundations/core/packages/text-core/src/markup/__tests__/reference.test.ts @@ -0,0 +1,80 @@ +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..2d0d06bc512 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,12 @@ export const ReferenceNode = Node.create({ id: getDataAttribute('id'), objectclass: getDataAttribute('objectclass'), label: getDataAttribute('label'), + grantsAccess: { + default: null, + parseHTML: (element) => element.getAttribute('data-grants-access'), + renderHTML: (attributes) => + attributes.grantsAccess == null ? null : { 'data-grants-access': attributes.grantsAccess } + }, // V3: keep deny flag across edit round-trips fixed: { default: null, parseHTML: (element) => element.getAttribute('data-fixed') === 'true', @@ -110,10 +117,13 @@ function getAttrs (el: HTMLSpanElement): Attrs | false { return false } + const grantsAccess = el.dataset.grantsAccess + return { id, label, objectclass, + ...(grantsAccess !== undefined ? { grantsAccess } : {}), fixed: fixed || null } } From ddb5018d065d71cb216f90932f6612c17195c5e2 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Wed, 27 May 2026 21:20:15 +0000 Subject: [PATCH 19/65] feat(chunter): V3b - per-grantee grant choices in send disclosure Move the grant decision from the context-free mention popup into the send-time disclosure, which already knows the grant target, space members and new grantees. The dialog shows a checkbox per new grantee; unchecking rewrites every reference to that person in the message markup to grantsAccess='false' (all of them, so any-wins cannot re-grant) via the pure applyMentionGrantChoices helper. Existing members' mentions are left untouched. Signed-off-by: Michael Uray (cherry picked from commit 49c5117b5de572ff9744e6110fc1d7d161aa529e) Signed-off-by: Michael Uray --- .../src/__tests__/mentionGrants.test.ts | 50 +++++++++++ .../chat-message/ChatMessageInput.svelte | 88 +++++++++---------- .../chat-message/MentionGrantConfirm.svelte | 72 +++++++++++++++ .../chunter-resources/src/mentionGrants.ts | 48 ++++++++++ 4 files changed, 214 insertions(+), 44 deletions(-) create mode 100644 plugins/chunter-resources/src/__tests__/mentionGrants.test.ts create mode 100644 plugins/chunter-resources/src/components/chat-message/MentionGrantConfirm.svelte create mode 100644 plugins/chunter-resources/src/mentionGrants.ts 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..bbf5f5eab9f --- /dev/null +++ b/plugins/chunter-resources/src/__tests__/mentionGrants.test.ts @@ -0,0 +1,50 @@ +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]) + }) +}) diff --git a/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte b/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte index 9abc5218196..f8150c178eb 100644 --- a/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte +++ b/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte @@ -29,8 +29,7 @@ type Space, type CommitResult } from '@hcengineering/core' - import { getEmbeddedLabel } from '@hcengineering/platform' - import { createQuery, DraftController, draftsStore, getClient, MessageBox } from '@hcengineering/presentation' + import { createQuery, DraftController, draftsStore, getClient } from '@hcengineering/presentation' import { EmptyMarkup, isEmptyMarkup, markupToJSON } from '@hcengineering/text' import { extractReferences } from '@hcengineering/text-core' import { createEventDispatcher } from 'svelte' @@ -39,7 +38,9 @@ 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 @@ -140,76 +141,73 @@ } /** - * Disclosure UX for the mention-grants-access flow. If the message - * mentions a Person whose AccountUuid is NOT already in the resolved - * grant-target's space.members, show a confirmation dialog before - * submitting. The actual access grant happens server-side via the - * chunter trigger (Collaborator record) + SpaceSecurity middleware - * (provideSecurity OR-branch). This dialog only surfaces the fact - * to the actor — a scripted API client could still bypass it. - * - * Returns true if the message should be sent, false to cancel. + * 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 confirmMentionGrantsAccess (markup: string): Promise { - if (markup === undefined || markup === '' || isEmptyMarkup(markup)) return true + async function resolveMentionGrantChoices (markup: string): Promise | null> { + if (markup === undefined || markup === '' || isEmptyMarkup(markup)) return new Map() let node try { node = markupToJSON(markup) } catch { - return true + 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 true + if (mentionedPersonIds.length === 0) return new Map() const grantTarget = await resolveMentionGrantTarget(object, (cls, q) => client.findAll(cls, q)) - if (grantTarget == null) return true + if (grantTarget == null) return new Map() const space = (await client.findAll(core.class.Space, { _id: grantTarget.space }))[0] - if (space === undefined) return true + 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 true - - const names = newGrantees.map((p) => `${p.name ?? p.personUuid}`).join(', ') - const targetName: string = - (grantTarget as any).name ?? (grantTarget as any).title ?? grantTarget._id + 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((resolve) => { + return await new Promise | null>((resolve) => { showPopup( - MessageBox, - { - label: getEmbeddedLabel('Heads up — this mention grants access'), - message: getEmbeddedLabel( - `${names} ${ - newGrantees.length === 1 ? 'is not a member' : 'are not members' - } of "${spaceName}". Sending this comment will grant ${ - newGrantees.length === 1 ? 'them' : 'them' - } read access to "${targetName}" and the ability to post comments on it. They will not be able to edit the document's fields. This is enforced by server policy; if you cancel, no access is granted.` - ), - okLabel: getEmbeddedLabel('Send and grant access'), - dangerous: false, - canSubmit: true - }, + MentionGrantConfirm, + { grantees, targetName, spaceName }, undefined, - (res?: boolean) => { - resolve(res === true) + (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 + } + async function handleCreate (event: CustomEvent, _id: Ref): Promise { try { - const proceed = await confirmMentionGrantsAccess(event.detail?.message ?? '') - if (!proceed) return + if (!(await prepareMentionGrantChoices(event))) return const res = await createMessage(event, _id, `chunter.create.${_class} ${object._class}`) @@ -225,6 +223,8 @@ async function handleEdit (event: CustomEvent): Promise { try { + if (!(await prepareMentionGrantChoices(event))) return + await editMessage(event) const objectId = await getObjectId(object, client.getHierarchy()) Analytics.handleEvent(ChunterEvents.MessageEdited, { ok: true, objectId, objectClass: object._class }) 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..bed99407fee --- /dev/null +++ b/plugins/chunter-resources/src/components/chat-message/MentionGrantConfirm.svelte @@ -0,0 +1,72 @@ + + + +
+
+
+
+
+ {#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..802f2196d0c --- /dev/null +++ b/plugins/chunter-resources/src/mentionGrants.ts @@ -0,0 +1,48 @@ +// +// 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 => { + if (n.type === MarkupNodeType.reference && n.attrs !== undefined) { + 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) +} From b72c062392accc228164321557c010af8f9c4e87 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Wed, 27 May 2026 21:31:36 +0000 Subject: [PATCH 20/65] feat(chunter-trigger): V3c - honour grantsAccess='false' on mentions The mention-grants trigger now skips Collaborator creation for any reference whose grantsAccess is explicitly 'false' (set by the V3b send-time disclosure). undefined still grants (pre-V3 default). This is the authoritative server-side enforcement; the client disclosure filter is UX only. Covered by a ChunterTrigger unit test that asserts a denied mention produces no Collaborator tx. Signed-off-by: Michael Uray (cherry picked from commit 1c8cd3efd771e89df017e8d6b740117a9acf2b46) Signed-off-by: Michael Uray --- .../__tests__/mentionGrantsTrigger.test.ts | 153 ++++++++++++++++++ server-plugins/chunter-resources/src/index.ts | 1 + 2 files changed, 154 insertions(+) create mode 100644 server-plugins/chunter-resources/src/__tests__/mentionGrantsTrigger.test.ts diff --git a/server-plugins/chunter-resources/src/__tests__/mentionGrantsTrigger.test.ts b/server-plugins/chunter-resources/src/__tests__/mentionGrantsTrigger.test.ts new file mode 100644 index 00000000000..0bc712fe194 --- /dev/null +++ b/server-plugins/chunter-resources/src/__tests__/mentionGrantsTrigger.test.ts @@ -0,0 +1,153 @@ +import { type MeasureContext, type Tx, type TxCreateDoc } from '@hcengineering/core' +import { jsonToMarkup, MarkupNodeType } from '@hcengineering/text-core' + +// ------------------------------------------------------------------ +// jest.mock for @hcengineering/core +// +// jest.requireActual('@hcengineering/core') crashes in ts-jest via spread +// due to a circular-dependency in the compiled lib (import_component2 +// undefined during getter evaluation when all properties are accessed +// eagerly). We work around this by requiring the actual module and only +// accessing specific known-safe properties instead of spreading. +// ------------------------------------------------------------------ +jest.mock('@hcengineering/core', () => { + // Load the real module but access only the specific properties we need. + // DO NOT spread with {...actual}: that triggers all lazy getters eagerly, + // hitting the circular-dep crash in import_component2. + const actual = jest.requireActual('@hcengineering/core') as any + + // Build a proxy that delegates unknown property reads to the actual module. + // This avoids the eager spread while still giving server-core etc. access + // to toFindResult, TxProcessor, etc. + const proxy = new Proxy(actual, { + get (target: any, prop: string) { + if (prop === 'getClassCollaborators') return mockGetClassCollaborators + if (prop === 'resolveMentionGrantTarget') return mockResolveMentionGrantTarget + return target[prop] + } + }) + + const mockGetClassCollaborators = jest.fn(() => ({ provideSecurity: true, mentionsGrantAccess: true })) + const mockResolveMentionGrantTarget = jest.fn(async (doc: any) => doc) + + return proxy +}) + +jest.mock('@hcengineering/server-contact', () => ({ + getAccountBySocialId: jest.fn(async () => 'author-acc'), + getPerson: jest.fn(async () => undefined) +})) + +// Imported AFTER the mocks so the trigger picks up the mocked helpers. +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { ChunterTrigger } = require('../index') +// eslint-disable-next-line @typescript-eslint/no-var-requires +const coreModule = require('@hcengineering/core') +const coreDefault = coreModule.default + +function makeMarkup (...refs: Array<{ id: string, grantsAccess?: 'true' | 'false' }>): string { + return jsonToMarkup({ + type: MarkupNodeType.doc, + content: refs.map((r) => ({ + type: MarkupNodeType.reference, + attrs: { + id: r.id, + label: r.id, + objectclass: 'contact:class:Person', + ...(r.grantsAccess !== undefined ? { grantsAccess: r.grantsAccess } : {}) + } + })) + }) +} + +const TARGET = { _id: 'issue-1', _class: 'tracker:class:Issue', space: 'space-1' } + +function makeControl (): any { + return { + hierarchy: { + // ChunterTrigger dispatches on isDerived(tx.objectClass, ChatMessage) + // FIRST (index.ts:360) — without the ChatMessage branch OnChatMessageCreated + // never runs. Mentioned refs are Persons; ThreadMessage/Channel stay false. + isDerived: (cls: string, base: string) => + (base === 'chunter:class:ChatMessage' && cls === 'chunter:class:ChatMessage') || + (base === 'contact:class:Person' && cls === 'contact:class:Person') + }, + modelDb: {}, + txFactory: { + createTxCreateDoc: (_class: string, space: string, attributes: any) => ({ + _class: coreDefault.class.TxCreateDoc, + objectClass: _class, + objectSpace: space, + attributes + }) + }, + findAll: jest.fn(async (_ctx: any, _class: string, query: any) => { + if (_class === coreDefault.class.Collaborator) { + // NON-EMPTY -> skip the legacy init branch (index.ts:204) and provide + // the grant-target dedup basis (existing-acc already a collaborator). + return [{ collaborator: 'existing-acc' }] + } + if (_class === coreDefault.class.ClassCollaborators) { + return [{ provideSecurity: true }] + } + if (typeof _class === 'string' && _class.includes('Employee')) { + // Query-based: return an Employee for EVERY requested id. If the mock + // hardcoded only p1, the test would pass even WITHOUT the grantsAccess + // filter (p2 never returned) — a false positive. Returning all requested + // ids means a missing filter WOULD grant p2, so the test is red before + // Step 3 and green after. + const ids: string[] = query?._id?.$in ?? [] + return ids.map((id: string) => ({ _id: id, personUuid: `${id}-acc` })) + } + // targetDoc lookup (message.attachedTo) + return [TARGET] + }), + ctx: { + with: async (_name: string, _params: any, fn: any) => await fn({}), + contextData: {} + } + } +} + +function makeCreateTx (markup: string): any { + return { + _class: coreDefault.class.TxCreateDoc, + objectClass: 'chunter:class:ChatMessage', + objectId: 'msg-1', + objectSpace: 'space-1', + modifiedBy: 'social-author', + attributes: { + attachedTo: TARGET._id, + attachedToClass: TARGET._class, + message: markup, + collection: 'comments' + } + } +} + +describe('ChunterTrigger mention grants — grantsAccess filter', () => { + test("a reference with grantsAccess='false' yields no Collaborator tx for that person", async () => { + const ctx = {} as unknown as MeasureContext + const control = makeControl() + const tx = makeCreateTx(makeMarkup({ id: 'p1' }, { id: 'p2', grantsAccess: 'false' })) + + const res: Tx[] = await ChunterTrigger([tx], control) + + const granted = res + .filter( + (t): t is TxCreateDoc => + t._class === coreDefault.class.TxCreateDoc && (t as any).objectClass === coreDefault.class.Collaborator + ) + .map((t) => (t as any).attributes.collaborator) + + expect(granted).toContain('p1-acc') // p1 granted (no deny flag) + expect(granted).not.toContain('p2-acc') // p2 denied via grantsAccess='false' + + // Stronger: the denied person must be filtered BEFORE the Employee query, + // so the query's id list must be exactly ['p1'] (not ['p1','p2']). + const employeeCall = control.findAll.mock.calls.find( + (c: any[]) => typeof c[1] === 'string' && c[1].includes('Employee') + ) + expect(employeeCall?.[2]?._id?.$in).toEqual(['p1']) + }) +}) diff --git a/server-plugins/chunter-resources/src/index.ts b/server-plugins/chunter-resources/src/index.ts index 582f7fc5baf..d95af2deabc 100644 --- a/server-plugins/chunter-resources/src/index.ts +++ b/server-plugins/chunter-resources/src/index.ts @@ -189,6 +189,7 @@ async function OnChatMessageCreated (ctx: MeasureContext, tx: TxCUD, contro const references = extractReferences(node) const mentionedPersons = references .filter(({ objectClass }) => control.hierarchy.isDerived(objectClass, contact.class.Person)) + .filter(({ grantsAccess }) => grantsAccess !== 'false') // V3c: skip explicitly-denied mentions .map(({ objectId }) => objectId as Ref) const employees = mentionedPersons.length > 0 From 1a8f32444f637fedd525aedc1dbb500d585dc540 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Wed, 27 May 2026 21:35:36 +0000 Subject: [PATCH 21/65] feat(chunter-trigger): V3d - add-only re-grant on message edit Add an OnChatMessageUpdated branch that re-runs the mention-grant logic on an edited message's new text. It only ADDS Collaborator records (existing ones dedup to no-ops) and never removes: TxUpdateDoc carries no pre-update state to diff against, and Collaborator has no provenance to safely remove mention-sourced grants by. Revoke-on-removal is deferred to a future provenance model (shared with the rendered-mention revoke stream). Signed-off-by: Michael Uray (cherry picked from commit 650ed5e78732202897b750ddb00de9e18a52fb4d) Signed-off-by: Michael Uray --- server-plugins/chunter-resources/src/index.ts | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/server-plugins/chunter-resources/src/index.ts b/server-plugins/chunter-resources/src/index.ts index d95af2deabc..7ca386c3356 100644 --- a/server-plugins/chunter-resources/src/index.ts +++ b/server-plugins/chunter-resources/src/index.ts @@ -277,6 +277,74 @@ async function OnChatMessageCreated (ctx: MeasureContext, tx: TxCUD, contro return res } +// V3d: self-contained grant helper used ONLY by OnChatMessageUpdated. +// Do NOT refactor OnChatMessageCreated to call this — the create path is live-tested +// and must remain provably unchanged. The ~25-line overlap is intentional. +async function applyMentionGrants (ctx: MeasureContext, message: ChatMessage, control: TriggerControl): Promise { + if (message.modifiedBy === core.account.System) return [] + const mixin = getClassCollaborators(control.modelDb, control.hierarchy, message.attachedToClass) + if (mixin === undefined) return [] + + const targetDoc = (await control.findAll(ctx, message.attachedToClass, { _id: message.attachedTo }, { limit: 1 }))[0] + if (targetDoc === undefined) return [] + + const node = markupToJSON(message.message) + const references = extractReferences(node) + const mentionedPersons = references + .filter(({ objectClass }) => control.hierarchy.isDerived(objectClass, contact.class.Person)) + .filter(({ grantsAccess }) => grantsAccess !== 'false') // V3c + .map(({ objectId }) => objectId as Ref) + // V3d is "a newly-added mention grants access". With no granting mention there + // is nothing to do — return early so a plain text edit never re-runs grant + // machinery, and the author is NOT re-added as a collaborator on every edit. + if (mentionedPersons.length === 0) return [] + const employees = await control.findAll(ctx, contact.mixin.Employee, { + _id: { $in: mentionedPersons as Ref[] } + }) + // Update path grants ONLY the mentioned employees (not the author — the + // author was already added at create time; the create path is unchanged). + const collaboratorsFromMessage = employees.map((it) => it.personUuid).filter(notEmpty) + if (collaboratorsFromMessage.length === 0) return [] + + const grantTarget = await resolveMentionGrantTarget(targetDoc, (cls, q) => control.findAll(control.ctx, cls, q)) + if (grantTarget == null) return [] // update-grant only applies to opted-in (protected) targets + + const grantCollabs = ( + await control.findAll(control.ctx, core.class.Collaborator, { attachedTo: grantTarget._id }) + ).map((c) => c.collaborator) + + const res: Tx[] = [] + for (const collab of collaboratorsFromMessage) { + if (grantCollabs.includes(collab)) continue // add-only: skip existing + res.push( + control.txFactory.createTxCreateDoc(core.class.Collaborator, grantTarget.space, { + attachedTo: grantTarget._id, + attachedToClass: grantTarget._class, + collaborator: collab, + collection: 'collaborators' + }) + ) + } + return res +} + +async function OnChatMessageUpdated (ctx: MeasureContext, tx: TxCUD, control: TriggerControl): Promise { + const actualTx = tx as TxUpdateDoc + // Only act when the message body changed (a new mention may have been added). + if (actualTx.operations.message === undefined) return [] + + const current = (await control.findAll(ctx, tx.objectClass, { _id: tx.objectId }, { limit: 1 }))[0] as + | ChatMessage + | undefined + if (current === undefined) return [] + + // Use the new text from the update operations (add-only: we grant for all + // currently-mentioned people; existing grants are deduped to no-ops, and we + // never remove — Collaborator has no provenance to safely remove by). + const message: ChatMessage = { ...current, message: actualTx.operations.message } + return await applyMentionGrants(ctx, message, control) +} + async function ChatNotificationsHandler (txes: TxCUD[], control: TriggerControl): Promise { const result: Tx[] = [] for (const tx of txes) { @@ -364,6 +432,14 @@ export async function ChunterTrigger (txes: TxCUD[], control: TriggerContro ) { res.push(...(await control.ctx.with('OnChatMessageCreated', {}, (ctx) => OnChatMessageCreated(ctx, tx, control)))) } + if ( + tx._class === core.class.TxUpdateDoc && + control.hierarchy.isDerived(tx.objectClass, chunter.class.ChatMessage) + ) { + res.push( + ...(await control.ctx.with('OnChatMessageUpdated', {}, (ctx) => OnChatMessageUpdated(ctx, tx, control))) + ) + } } return res } From 765da62591a2b7dbd0440649256a89bde8d3dc9f Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Wed, 27 May 2026 21:52:33 +0000 Subject: [PATCH 22/65] fix(chunter-trigger): V3d update-grant uses the edit actor + add tests OnChatMessageUpdated rebuilt the message via { ...current, message } which kept the original author's modifiedBy, so applyMentionGrants' System guard checked the wrong actor. Use TxProcessor.updateDoc2Doc so modifiedBy reflects the edit tx. Add ChunterTrigger unit tests for the update path: new-mention grant, denied-mention (V3c) on edit, existing-collaborator dedup, System-authored edit grants nothing, and non-message update no-op. Signed-off-by: Michael Uray (cherry picked from commit 7cb590ff1c6b0bd4ff6dac17e5e3f5d7c38a86b9) Signed-off-by: Michael Uray --- .../__tests__/mentionGrantsTrigger.test.ts | 92 +++++++++++++++++++ server-plugins/chunter-resources/src/index.ts | 10 +- 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/server-plugins/chunter-resources/src/__tests__/mentionGrantsTrigger.test.ts b/server-plugins/chunter-resources/src/__tests__/mentionGrantsTrigger.test.ts index 0bc712fe194..6b2d2f36717 100644 --- a/server-plugins/chunter-resources/src/__tests__/mentionGrantsTrigger.test.ts +++ b/server-plugins/chunter-resources/src/__tests__/mentionGrantsTrigger.test.ts @@ -99,6 +99,24 @@ function makeControl (): any { const ids: string[] = query?._id?.$in ?? [] return ids.map((id: string) => ({ _id: id, personUuid: `${id}-acc` })) } + if (_class === 'chunter:class:ChatMessage') { + // OnChatMessageUpdated loads the current stored message by _id before + // applying the update operations. Return a ChatMessage shape with the + // OLD (no-mention) body; the update tx supplies the new text. + return [ + { + _id: 'msg-1', + _class: 'chunter:class:ChatMessage', + space: 'space-1', + attachedTo: TARGET._id, + attachedToClass: TARGET._class, + collection: 'comments', + message: makeMarkup(), + modifiedBy: 'social-author', + modifiedOn: 1 + } + ] + } // targetDoc lookup (message.attachedTo) return [TARGET] }), @@ -125,6 +143,27 @@ function makeCreateTx (markup: string): any { } } +function makeUpdateTx (operations: any, modifiedBy = 'social-editor'): any { + return { + _class: coreDefault.class.TxUpdateDoc, + objectClass: 'chunter:class:ChatMessage', + objectId: 'msg-1', + objectSpace: 'space-1', + modifiedBy, + modifiedOn: 2, + operations + } +} + +function collaboratorGrants (res: Tx[]): string[] { + return res + .filter( + (t): t is TxCreateDoc => + t._class === coreDefault.class.TxCreateDoc && (t as any).objectClass === coreDefault.class.Collaborator + ) + .map((t) => (t as any).attributes.collaborator) +} + describe('ChunterTrigger mention grants — grantsAccess filter', () => { test("a reference with grantsAccess='false' yields no Collaborator tx for that person", async () => { const ctx = {} as unknown as MeasureContext @@ -151,3 +190,56 @@ describe('ChunterTrigger mention grants — grantsAccess filter', () => { expect(employeeCall?.[2]?._id?.$in).toEqual(['p1']) }) }) + +describe('ChunterTrigger mention grants — V3d add-only re-grant on edit', () => { + test('a newly-added mention on edit grants the mentioned employee', async () => { + const control = makeControl() + const tx = makeUpdateTx({ message: makeMarkup({ id: 'p3' }) }) + + const res: Tx[] = await ChunterTrigger([tx], control) + + expect(collaboratorGrants(res)).toContain('p3-acc') + }) + + test('a denied mention on edit grants nothing (V3c filter still applies)', async () => { + const control = makeControl() + const tx = makeUpdateTx({ message: makeMarkup({ id: 'p3', grantsAccess: 'false' }) }) + + const res: Tx[] = await ChunterTrigger([tx], control) + + expect(collaboratorGrants(res)).not.toContain('p3-acc') + }) + + test('an already-granted collaborator is deduped — add-only no-op', async () => { + const control = makeControl() + // 'existing' resolves to personUuid 'existing-acc', which the grant target + // already lists (findAll Collaborator returns existing-acc) -> no new tx. + const tx = makeUpdateTx({ message: makeMarkup({ id: 'existing' }) }) + + const res: Tx[] = await ChunterTrigger([tx], control) + + expect(collaboratorGrants(res)).not.toContain('existing-acc') + expect(collaboratorGrants(res)).toHaveLength(0) + }) + + test('a System-authored edit grants nothing (stale modifiedBy guard — uses edit actor)', async () => { + const control = makeControl() + // updateDoc2Doc sets message.modifiedBy = tx.modifiedBy = System, so the + // applyMentionGrants System guard fires. If the handler used the stored + // doc's (non-System) author instead, this would wrongly grant. + const tx = makeUpdateTx({ message: makeMarkup({ id: 'p3' }) }, coreDefault.account.System) + + const res: Tx[] = await ChunterTrigger([tx], control) + + expect(collaboratorGrants(res)).toHaveLength(0) + }) + + test('a non-message update (no operations.message) is a no-op', async () => { + const control = makeControl() + const tx = makeUpdateTx({ reactions: 1 }) + + const res: Tx[] = await ChunterTrigger([tx], control) + + expect(collaboratorGrants(res)).toHaveLength(0) + }) +}) diff --git a/server-plugins/chunter-resources/src/index.ts b/server-plugins/chunter-resources/src/index.ts index 7ca386c3356..d50b6dae768 100644 --- a/server-plugins/chunter-resources/src/index.ts +++ b/server-plugins/chunter-resources/src/index.ts @@ -338,10 +338,12 @@ async function OnChatMessageUpdated (ctx: MeasureContext, tx: TxCUD, contro | undefined if (current === undefined) return [] - // Use the new text from the update operations (add-only: we grant for all - // currently-mentioned people; existing grants are deduped to no-ops, and we - // never remove — Collaborator has no provenance to safely remove by). - const message: ChatMessage = { ...current, message: actualTx.operations.message } + // Apply the update to the stored doc so the message text AND the actor + // (modifiedBy) reflect THIS edit — not the original author. applyMentionGrants + // guards on message.modifiedBy === System, so it must see the edit actor. + // Add-only: we grant for all currently-mentioned people; existing grants dedup + // to no-ops, and we never remove (Collaborator has no provenance to remove by). + const message = TxProcessor.updateDoc2Doc({ ...current }, actualTx) return await applyMentionGrants(ctx, message, control) } From f9fe39f71c6682ae85b165c4dda49e3648c756a5 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Wed, 27 May 2026 21:52:33 +0000 Subject: [PATCH 23/65] fix(chunter): preserve unsent comment when grant dialog is cancelled V3b made the send flow async (the per-grantee disclosure popup), but onMessage removed the draft and cleared the input BEFORE the popup resolved and did not await handleCreate. Cancelling the grant dialog therefore lost the unsent comment. handleCreate/handleEdit now return a boolean; onMessage awaits both and only drops the draft + clears the input + dispatches submit on a successful send/edit. Signed-off-by: Michael Uray (cherry picked from commit 34bc13a333318c2bc04457ae1cdcc87f5b97ca4a) Signed-off-by: Michael Uray --- .../chat-message/ChatMessageInput.svelte | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte b/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte index f8150c178eb..a7238d3a82f 100644 --- a/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte +++ b/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte @@ -205,51 +205,63 @@ return true } - async function handleCreate (event: CustomEvent, _id: Ref): Promise { + // 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 + 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 + 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 } From 2f6657737b8b9d7cca600df433bfb34a45045cb0 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sat, 30 May 2026 07:24:04 +0000 Subject: [PATCH 24/65] test(middleware): stub hierarchy.getAncestors for guest-permission uncovered-class test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isForbiddenCollabOnlyGuestFieldUpdate (added by 8e825a54db) walks the class hierarchy via getClassCollaborators → getAncestors. The "uncovered class" test scaffold patched classHierarchyMixin and isDerived but not getAncestors, so the new code path threw "ancestors not found: test:class:UncoveredClass" before reaching the production-style early-return. Returning [] from the stub matches the intent: an uncovered class has no ancestors to inherit ClassCollaborators from. Signed-off-by: Michael Uray --- .../packages/middleware/src/tests/guestPermissions.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/foundations/server/packages/middleware/src/tests/guestPermissions.test.ts b/foundations/server/packages/middleware/src/tests/guestPermissions.test.ts index e124aee8cbe..2d9c09cb45e 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)) From d388c5a891d42033fd2f36f46a8de375a5bdc64b Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sat, 30 May 2026 07:34:21 +0000 Subject: [PATCH 25/65] fix(workbench-resources/Navigator): correct activeClasses cast to Ref>[] 64105d1f14 introduced the activeClasses derived reactive but cast it to Ref[]. Since `typeof core.class.Space` is already Ref>, that double-wrapped the type to Ref>>[], which svelte-check correctly flagged as incompatible with the collab/member query callsites that expect Ref>[]. Drop the extra Ref<> wrapper and import Class so the cast is to the actually intended Ref>[]. Signed-off-by: Michael Uray --- plugins/workbench-resources/src/components/Navigator.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/workbench-resources/src/components/Navigator.svelte b/plugins/workbench-resources/src/components/Navigator.svelte index 8be6f5d98c7..34925c73a6e 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. --> + +
+
+
+ +
+ + { + level = e.detail + }} + /> +
+ +
+
+ +
+
+
+ + + diff --git a/plugins/tracker-resources/src/plugin.ts b/plugins/tracker-resources/src/plugin.ts index dadcd1249b6..a790876ae38 100644 --- a/plugins/tracker-resources/src/plugin.ts +++ b/plugins/tracker-resources/src/plugin.ts @@ -308,7 +308,27 @@ export default mergeIds(trackerId, tracker, { PreviousAssigned: '' as IntlString, EditRelatedTargets: '' as IntlString, RelatedIssueTargetDescription: '' as IntlString, - SharedWithYouTooltip: '' 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 }, component: { NopeComponent: '' as AnyComponent, From f02b5f7d5719aacb89d74aad19fb9a4582d2e99d Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Wed, 8 Jul 2026 14:41:52 +0000 Subject: [PATCH 42/65] feat(tracker): issue Access panel with provenance, level and grant management (P3.1/P3.3) Adds IssueAccessPanel to the issue aside, replacing the generic notification Collaborators mixin for issues in ControlPanel. Shows read-only project members plus an 'additional access' list of granted Collaborator records with provenance labels (mention/manual/group) and per-grant access level. Manual grants: add-person picker + level-aware confirm, revoke, and in-place level change (remove+create, since grant records are immutable per P1). Self-decline for the grantee. Level dropdown is editable only for manual grants; mention/group levels are shown read-only. All grant authority is enforced server-side by CollaboratorGuardMiddleware (P1.2); this UI gating is courtesy only. Write failures surface via setPlatformStatus, never silent. Signed-off-by: Michael Uray --- .../issues/edit/ControlPanel.svelte | 19 +- .../issues/edit/IssueAccessPanel.svelte | 373 ++++++++++++++++++ 2 files changed, 389 insertions(+), 3 deletions(-) create mode 100644 plugins/tracker-resources/src/components/issues/edit/IssueAccessPanel.svelte 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/IssueAccessPanel.svelte b/plugins/tracker-resources/src/components/issues/edit/IssueAccessPanel.svelte new file mode 100644 index 00000000000..b9a51187859 --- /dev/null +++ b/plugins/tracker-resources/src/components/issues/edit/IssueAccessPanel.svelte @@ -0,0 +1,373 @@ + + + +
+
+
+ + +
+ + +
(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} + + {#if canGrant} +
+
+ {/if} +
+
+ + From 1a810e09163916d8631e24326cd3e68fcab9edc9 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Wed, 8 Jul 2026 14:54:29 +0000 Subject: [PATCH 43/65] feat(core): AccessGroup and GroupGrant (with level) classes + guarded writes Signed-off-by: Michael Uray --- foundations/core/packages/core/src/classes.ts | 40 ++++- .../core/packages/core/src/component.ts | 4 + .../middleware/src/collaboratorGuard.ts | 92 +++++++++- .../src/tests/collaboratorGuard.test.ts | 162 +++++++++++++++++- models/core/src/core.ts | 30 +++- models/core/src/index.ts | 4 + 6 files changed, 326 insertions(+), 6 deletions(-) diff --git a/foundations/core/packages/core/src/classes.ts b/foundations/core/packages/core/src/classes.ts index 4b3690bc145..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 */ @@ -1047,13 +1055,43 @@ export interface Collaborator extends AttachedDoc { grantedVia?: CollaboratorProvenance grantedBy?: AccountUuid // actor account that triggered the grant grantedByMessage?: Ref // only when grantedVia === 'mention' - grantedByGroup?: Ref // only when grantedVia === 'group'; P4 sharpens to Ref + // 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' +} + /** * @public */ 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/server/packages/middleware/src/collaboratorGuard.ts b/foundations/server/packages/middleware/src/collaboratorGuard.ts index 8592e5fa0e3..9c1ed4ae322 100644 --- a/foundations/server/packages/middleware/src/collaboratorGuard.ts +++ b/foundations/server/packages/middleware/src/collaboratorGuard.ts @@ -27,6 +27,7 @@ import core, { type Collaborator, type Doc, getClassCollaborators, + type GroupGrant, hasAccountRole, hasAtLeast, type MeasureContext, @@ -37,7 +38,8 @@ import core, { type TxApplyIf, type TxCreateDoc, type TxCUD, - TxProcessor + TxProcessor, + type TxUpdateDoc } from '@hcengineering/core' import platform, { PlatformError, Severity, Status } from '@hcengineering/platform' @@ -74,6 +76,10 @@ function forbidden (): PlatformError> { * 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. * * Collaborators of non-secured classes (channels etc.) are untouched. * @@ -109,6 +115,10 @@ export class CollaboratorGuardMiddleware extends BaseMiddleware implements Middl } 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.Collaborator)) return if (cud._class === core.class.TxCreateDoc) { @@ -193,6 +203,86 @@ export class CollaboratorGuardMiddleware extends BaseMiddleware implements Middl 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 + if (!(await this.canGrantGroup(ctx, createTx.objectSpace, 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() + } + + /** + * 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 diff --git a/foundations/server/packages/middleware/src/tests/collaboratorGuard.test.ts b/foundations/server/packages/middleware/src/tests/collaboratorGuard.test.ts index 4408dd0b41d..b825d86a00f 100644 --- a/foundations/server/packages/middleware/src/tests/collaboratorGuard.test.ts +++ b/foundations/server/packages/middleware/src/tests/collaboratorGuard.test.ts @@ -34,6 +34,7 @@ import core, { type Class, type Collaborator, type Doc, + type GroupGrant, type MeasureContext, type PersonId, type Ref, @@ -116,8 +117,8 @@ function makeSpace (members: AccountUuid[], owners: AccountUuid[] = []): Space { return { _id: ISSUE_SPACE, members, owners } as any } -/** findAll that serves an optional space and an optional list of existing collaborators. */ -function serve (opts: { space?: Space, collaborators?: Collaborator[] }): FindAllFn { +/** findAll that serves an optional space and optional collaborators / group grants. */ +function serve (opts: { space?: Space, collaborators?: Collaborator[], groupGrants?: GroupGrant[] }): FindAllFn { return async (_ctx, _class, query) => { if (_class === core.class.Space) { return opts.space !== undefined && query?._id === opts.space._id ? [opts.space] : [] @@ -129,6 +130,11 @@ function serve (opts: { space?: Space, collaborators?: Collaborator[] }): FindAl 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) + return list as any + } return [] } } @@ -154,6 +160,43 @@ function makeUpdateTx (account: Account, collabId: Ref): Tx { 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()) as Ref, + _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 +} + function collabRecord (over: Partial): Collaborator { return { _id: (over._id ?? generateId()) as Ref, @@ -275,7 +318,11 @@ describe('CollaboratorGuardMiddleware', () => { return {} }) // group-provenance create that a client could never do: - const tx = makeCreateTx(sys, { collaborator: uuid(), grantedVia: 'group', grantedByGroup: generateId() }) + const tx = makeCreateTx(sys, { + collaborator: uuid(), + grantedVia: 'group', + grantedByGroup: generateId() as Ref + }) await mw.tx(makeCtx(sys), [tx]) expect(nextCalled).toBe(true) }) @@ -376,4 +423,113 @@ describe('CollaboratorGuardMiddleware', () => { 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() as Ref)]) + ).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() + }) }) diff --git a/models/core/src/core.ts b/models/core/src/core.ts index 6488bb02615..8c31bcdccca 100644 --- a/models/core/src/core.ts +++ b/models/core/src/core.ts @@ -14,6 +14,7 @@ // import { + type AccessGroup, type AccessLevel, type AccountUuid, type AnyAttribute, @@ -27,11 +28,13 @@ import { 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, @@ -66,12 +69,14 @@ import { type VersionableClass } from '@hcengineering/core' import { + ArrOf, Hidden, Index, Mixin as MMixin, Model, Prop, ReadOnly, + TypeAccountUuid, TypeBoolean, TypeFileSize, TypeIntlString, @@ -449,7 +454,30 @@ export class TCollaborator extends TAttachedDoc implements Collaborator { grantedVia?: CollaboratorProvenance grantedBy?: AccountUuid grantedByMessage?: Ref - grantedByGroup?: Ref // P1: typed as Ref; P4 sharpens to 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(ArrOf(TypeAccountUuid()), core.string.Members) + members!: AccountUuid[] + + @Prop(ArrOf(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 } 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 ) From 7023bd994ed91a78166abd40bb7511a6b65d31bc Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Wed, 8 Jul 2026 15:05:01 +0000 Subject: [PATCH 44/65] feat(server): access-group grant materialization + reconcile triggers Signed-off-by: Michael Uray --- dev/tool/package.json | 2 + dev/tool/src/__start.ts | 2 + models/all/package.json | 1 + models/all/src/index.ts | 2 + models/server-access-group/.eslintrc.js | 7 + models/server-access-group/.npmignore | 4 + models/server-access-group/jest.config.js | 7 + models/server-access-group/package.json | 43 +++ models/server-access-group/src/index.ts | 44 +++ models/server-access-group/tsconfig.json | 12 + rush.json | 15 + .../access-group-resources/.eslintrc.js | 7 + .../access-group-resources/.npmignore | 4 + .../access-group-resources/jest.config.js | 7 + .../access-group-resources/package.json | 45 +++ .../src/__tests__/groupGrantTrigger.test.ts | 280 ++++++++++++++++++ .../src/__tests__/reconcile.test.ts | 116 ++++++++ .../access-group-resources/src/index.ts | 163 ++++++++++ .../access-group-resources/src/reconcile.ts | 100 +++++++ .../access-group-resources/tsconfig.json | 12 + server-plugins/access-group/.eslintrc.js | 7 + server-plugins/access-group/.npmignore | 4 + server-plugins/access-group/package.json | 45 +++ server-plugins/access-group/src/index.ts | 38 +++ server-plugins/access-group/tsconfig.json | 12 + server/server-pipeline/package.json | 2 + server/server-pipeline/src/serverPlugins.ts | 2 + 27 files changed, 983 insertions(+) create mode 100644 models/server-access-group/.eslintrc.js create mode 100644 models/server-access-group/.npmignore create mode 100644 models/server-access-group/jest.config.js create mode 100644 models/server-access-group/package.json create mode 100644 models/server-access-group/src/index.ts create mode 100644 models/server-access-group/tsconfig.json create mode 100644 server-plugins/access-group-resources/.eslintrc.js create mode 100644 server-plugins/access-group-resources/.npmignore create mode 100644 server-plugins/access-group-resources/jest.config.js create mode 100644 server-plugins/access-group-resources/package.json create mode 100644 server-plugins/access-group-resources/src/__tests__/groupGrantTrigger.test.ts create mode 100644 server-plugins/access-group-resources/src/__tests__/reconcile.test.ts create mode 100644 server-plugins/access-group-resources/src/index.ts create mode 100644 server-plugins/access-group-resources/src/reconcile.ts create mode 100644 server-plugins/access-group-resources/tsconfig.json create mode 100644 server-plugins/access-group/.eslintrc.js create mode 100644 server-plugins/access-group/.npmignore create mode 100644 server-plugins/access-group/package.json create mode 100644 server-plugins/access-group/src/index.ts create mode 100644 server-plugins/access-group/tsconfig.json 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/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/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/rush.json b/rush.json index c04e21467cd..ac485df165d 100644 --- a/rush.json +++ b/rush.json @@ -1340,6 +1340,21 @@ "projectFolder": "server-plugins/chunter-resources", "shouldPublish": false }, + { + "packageName": "@hcengineering/server-access-group", + "projectFolder": "server-plugins/access-group", + "shouldPublish": false + }, + { + "packageName": "@hcengineering/model-server-access-group", + "projectFolder": "models/server-access-group", + "shouldPublish": false + }, + { + "packageName": "@hcengineering/server-access-group-resources", + "projectFolder": "server-plugins/access-group-resources", + "shouldPublish": false + }, { "packageName": "@hcengineering/server-inventory", "projectFolder": "server-plugins/inventory", diff --git a/server-plugins/access-group-resources/.eslintrc.js b/server-plugins/access-group-resources/.eslintrc.js new file mode 100644 index 00000000000..72235dc2833 --- /dev/null +++ b/server-plugins/access-group-resources/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/server-plugins/access-group-resources/.npmignore b/server-plugins/access-group-resources/.npmignore new file mode 100644 index 00000000000..e3ec093c383 --- /dev/null +++ b/server-plugins/access-group-resources/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/server-plugins/access-group-resources/jest.config.js b/server-plugins/access-group-resources/jest.config.js new file mode 100644 index 00000000000..2cfd408b679 --- /dev/null +++ b/server-plugins/access-group-resources/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/server-plugins/access-group-resources/package.json b/server-plugins/access-group-resources/package.json new file mode 100644 index 00000000000..ff1594ecddb --- /dev/null +++ b/server-plugins/access-group-resources/package.json @@ -0,0 +1,45 @@ +{ + "name": "@hcengineering/server-access-group-resources", + "version": "0.7.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "tsconfig.json" + ], + "author": "Anticrm Platform Contributors", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "test": "jest --passWithNoTests --silent", + "build:watch": "compile", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "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", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5" + }, + "dependencies": { + "@hcengineering/core": "workspace:^0.7.26", + "@hcengineering/platform": "workspace:^0.7.20", + "@hcengineering/server-core": "workspace:^0.7.19", + "@hcengineering/server-access-group": "workspace:^0.7.0" + } +} diff --git a/server-plugins/access-group-resources/src/__tests__/groupGrantTrigger.test.ts b/server-plugins/access-group-resources/src/__tests__/groupGrantTrigger.test.ts new file mode 100644 index 00000000000..53b0213b381 --- /dev/null +++ b/server-plugins/access-group-resources/src/__tests__/groupGrantTrigger.test.ts @@ -0,0 +1,280 @@ +// +// 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 core, { type Tx } from '@hcengineering/core' +import { OnAccessGroupChanged, OnGroupGrantChanged } from '../index' + +interface Store { + groups: any[] + grants: any[] + collaborators: any[] +} + +function makeControl (store: Store): any { + return { + ctx: {}, + hierarchy: { + isDerived: (cls: string, base: string) => cls === base + }, + txFactory: { + createTxCreateDoc: (_class: string, space: string, attributes: any) => ({ + _class: core.class.TxCreateDoc, + objectClass: _class, + objectSpace: space, + objectId: `new-${String(attributes.collaborator)}`, + attributes + }), + createTxRemoveDoc: (_class: string, space: string, objectId: string) => ({ + _class: core.class.TxRemoveDoc, + objectClass: _class, + objectSpace: space, + objectId + }) + }, + findAll: jest.fn(async (_ctx: any, _class: string, query: any) => { + if (_class === core.class.AccessGroup) { + return store.groups.filter((g) => query?._id === undefined || g._id === query._id) + } + if (_class === core.class.GroupGrant) { + return store.grants.filter( + (g) => + (query?._id === undefined || g._id === query._id) && (query?.group === undefined || g.group === query.group) + ) + } + if (_class === core.class.Collaborator) { + return store.collaborators.filter( + (c) => + (query?.attachedTo === undefined || c.attachedTo === query.attachedTo) && + (query?.grantedVia === undefined || c.grantedVia === query.grantedVia) && + (query?.grantedByGroup === undefined || c.grantedByGroup === query.grantedByGroup) + ) + } + return [] + }) + } +} + +function grant (over: any): any { + return { + _id: 'grant-1', + _class: core.class.GroupGrant, + space: 'space-1', + attachedTo: 'issue-1', + attachedToClass: 'tracker:class:Issue', + collection: 'groupGrants', + group: 'group-1', + grantedBy: 'granter', + level: 'read', + ...over + } +} + +function collab (over: any): any { + return { + _id: `collab-${String(over.collaborator)}`, + _class: core.class.Collaborator, + space: 'space-1', + attachedTo: 'issue-1', + attachedToClass: 'tracker:class:Issue', + collection: 'collaborators', + grantedVia: 'group', + grantedByGroup: 'grant-1', + ...over + } +} + +function makeGrantCreateTx (g: any): any { + return { + _class: core.class.TxCreateDoc, + objectClass: core.class.GroupGrant, + objectId: g._id, + objectSpace: g.space, + attachedTo: g.attachedTo, + attachedToClass: g.attachedToClass, + collection: 'groupGrants', + modifiedBy: 'sys', + modifiedOn: 1, + attributes: { group: g.group, grantedBy: g.grantedBy, level: g.level } + } +} + +function createdCollabs (res: Tx[]): Array<{ collaborator: string, level: string }> { + return res + .filter((t) => t._class === core.class.TxCreateDoc && (t as any).objectClass === core.class.Collaborator) + .map((t) => ({ collaborator: (t as any).attributes.collaborator, level: (t as any).attributes.level })) +} + +function removedIds (res: Tx[]): string[] { + return res + .filter((t) => t._class === core.class.TxRemoveDoc && (t as any).objectClass === core.class.Collaborator) + .map((t) => (t as any).objectId) +} + +describe('OnGroupGrantChanged', () => { + it('GroupGrant create materializes one group-collaborator per member at the grant level', async () => { + const store: Store = { + groups: [{ _id: 'group-1', members: ['a', 'b'] }], + grants: [], + collaborators: [] + } + const control = makeControl(store) + const res = await OnGroupGrantChanged([makeGrantCreateTx(grant({ level: 'write' }))], control) + const created = createdCollabs(res) + expect(created).toEqual( + expect.arrayContaining([ + { collaborator: 'a', level: 'write' }, + { collaborator: 'b', level: 'write' } + ]) + ) + expect(created).toHaveLength(2) + expect(removedIds(res)).toEqual([]) + }) + + it('changing GroupGrant.level re-materializes members at the new level (remove old + create new)', async () => { + const store: Store = { + groups: [{ _id: 'group-1', members: ['a'] }], + grants: [grant({ level: 'admin' })], + collaborators: [collab({ collaborator: 'a', level: 'read' })] + } + const control = makeControl(store) + const upd = { + _class: core.class.TxUpdateDoc, + objectClass: core.class.GroupGrant, + objectId: 'grant-1', + objectSpace: 'space-1', + operations: { level: 'admin' } + } + const res = await OnGroupGrantChanged([upd], control) + expect(removedIds(res)).toEqual(['collab-a']) + expect(createdCollabs(res)).toEqual([{ collaborator: 'a', level: 'admin' }]) + }) + + it('GroupGrant remove tears down exactly its grantedByGroup records', async () => { + const store: Store = { + groups: [], + grants: [], + collaborators: [ + collab({ collaborator: 'a' }), + collab({ collaborator: 'b' }), + // a DIFFERENT grant's record — must NOT be torn down + collab({ collaborator: 'c', _id: 'collab-c', grantedByGroup: 'grant-OTHER' }) + ] + } + const control = makeControl(store) + const rm = { + _class: core.class.TxRemoveDoc, + objectClass: core.class.GroupGrant, + objectId: 'grant-1', + objectSpace: 'space-1' + } + const res = await OnGroupGrantChanged([rm], control) + expect(removedIds(res).sort()).toEqual(['collab-a', 'collab-b']) + expect(removedIds(res)).not.toContain('collab-c') + }) + + it('member in two granted groups keeps access until the last grant is removed', async () => { + // Removing grant-1 must not touch grant-2's record for the same person. + const store: Store = { + groups: [], + grants: [], + collaborators: [ + collab({ collaborator: 'a', _id: 'collab-a-g1', grantedByGroup: 'grant-1' }), + collab({ collaborator: 'a', _id: 'collab-a-g2', grantedByGroup: 'grant-2' }) + ] + } + const control = makeControl(store) + const rm = { + _class: core.class.TxRemoveDoc, + objectClass: core.class.GroupGrant, + objectId: 'grant-1', + objectSpace: 'space-1' + } + const res = await OnGroupGrantChanged([rm], control) + expect(removedIds(res)).toEqual(['collab-a-g1']) + expect(removedIds(res)).not.toContain('collab-a-g2') + }) +}) + +describe('OnAccessGroupChanged', () => { + it('adding a member to the group adds collaborators on every granted doc', async () => { + const store: Store = { + groups: [{ _id: 'group-1', members: ['a', 'b'] }], + grants: [ + grant({ _id: 'grant-1', attachedTo: 'issue-1' }), + grant({ _id: 'grant-2', attachedTo: 'issue-2' }) + ], + // only 'a' materialized so far on both docs + collaborators: [ + collab({ collaborator: 'a', _id: 'c1', grantedByGroup: 'grant-1', attachedTo: 'issue-1' }), + collab({ collaborator: 'a', _id: 'c2', grantedByGroup: 'grant-2', attachedTo: 'issue-2' }) + ] + } + const control = makeControl(store) + const upd = { + _class: core.class.TxUpdateDoc, + objectClass: core.class.AccessGroup, + objectId: 'group-1', + objectSpace: 'space-1', + operations: { $push: { members: 'b' } } + } + const res = await OnAccessGroupChanged([upd], control) + const created = createdCollabs(res) + expect(created.filter((c) => c.collaborator === 'b')).toHaveLength(2) // b added on both docs + expect(created.every((c) => c.collaborator === 'b')).toBe(true) // 'a' already present, untouched + }) + + it('removing a member removes ONLY its group-provenance records (manual/mention untouched)', async () => { + const store: Store = { + groups: [{ _id: 'group-1', members: ['a'] }], // 'b' removed + grants: [grant({ _id: 'grant-1' })], + collaborators: [ + collab({ collaborator: 'a', _id: 'g-a' }), + collab({ collaborator: 'b', _id: 'g-b' }), + // manual grant for b on the same doc — different provenance, must survive + { ...collab({ collaborator: 'b', _id: 'm-b' }), grantedVia: 'manual', grantedByGroup: undefined } + ] + } + const control = makeControl(store) + const upd = { + _class: core.class.TxUpdateDoc, + objectClass: core.class.AccessGroup, + objectId: 'group-1', + objectSpace: 'space-1', + operations: { $pull: { members: 'b' } } + } + const res = await OnAccessGroupChanged([upd], control) + expect(removedIds(res)).toEqual(['g-b']) // only the group record for b + expect(removedIds(res)).not.toContain('m-b') // manual record untouched + expect(removedIds(res)).not.toContain('g-a') + }) + + it('ignores AccessGroup updates that do not touch members (idempotent no-op)', async () => { + const store: Store = { + groups: [{ _id: 'group-1', members: ['a'] }], + grants: [grant({ _id: 'grant-1' })], + collaborators: [collab({ collaborator: 'a', _id: 'g-a' })] + } + const control = makeControl(store) + const upd = { + _class: core.class.TxUpdateDoc, + objectClass: core.class.AccessGroup, + objectId: 'group-1', + objectSpace: 'space-1', + operations: { name: 'Renamed' } + } + const res = await OnAccessGroupChanged([upd], control) + expect(res).toEqual([]) + }) +}) diff --git a/server-plugins/access-group-resources/src/__tests__/reconcile.test.ts b/server-plugins/access-group-resources/src/__tests__/reconcile.test.ts new file mode 100644 index 00000000000..67381a79954 --- /dev/null +++ b/server-plugins/access-group-resources/src/__tests__/reconcile.test.ts @@ -0,0 +1,116 @@ +// +// 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, AccountUuid, Collaborator, Ref } from '@hcengineering/core' +import { type GroupCollabRecord, reconcileGrant, teardownGrant } from '../reconcile' + +const acc = (s: string): AccountUuid => s as unknown as AccountUuid +let idSeq = 0 +function rec (collaborator: string, level?: AccessLevel, id?: string): GroupCollabRecord { + return { + _id: (id ?? `rec-${idSeq++}`) as Ref, + collaborator: acc(collaborator), + level + } +} + +/** + * Simulate applying a reconcile plan to the current records: drop removed ids, + * append a fresh record per created member at the desired level. Used to prove + * idempotency (a second reconcile of the applied state is a no-op). + */ +function applyPlan ( + existing: GroupCollabRecord[], + members: string[], + level: AccessLevel, + plan: { toCreate: AccountUuid[], toRemove: Array> } +): GroupCollabRecord[] { + const next = existing.filter((r) => !plan.toRemove.includes(r._id)) + for (const m of plan.toCreate) next.push(rec(m as unknown as string, level)) + return next +} + +describe('reconcileGrant (pure)', () => { + it('materializes one record per member at the grant level (empty start)', () => { + const plan = reconcileGrant([acc('a'), acc('b')], 'write', []) + expect(plan.toCreate.sort()).toEqual([acc('a'), acc('b')]) + expect(plan.toRemove).toEqual([]) + }) + + it('is a no-op when every member already has a correct-level record', () => { + const existing = [rec('a', 'write'), rec('b', 'write')] + const plan = reconcileGrant([acc('a'), acc('b')], 'write', existing) + expect(plan.toCreate).toEqual([]) + expect(plan.toRemove).toEqual([]) + }) + + it('adds a record for a newly added member, keeps existing', () => { + const existing = [rec('a', 'read')] + const plan = reconcileGrant([acc('a'), acc('b')], 'read', existing) + expect(plan.toCreate).toEqual([acc('b')]) + expect(plan.toRemove).toEqual([]) + }) + + it('removes the record of a member that left the group', () => { + const gone = rec('b', 'read', 'rec-b') + const existing = [rec('a', 'read'), gone] + const plan = reconcileGrant([acc('a')], 'read', existing) + expect(plan.toCreate).toEqual([]) + expect(plan.toRemove).toEqual([gone._id]) + }) + + it('re-materializes at a new level (remove old + create new)', () => { + const old = rec('a', 'read', 'rec-a') + const plan = reconcileGrant([acc('a')], 'admin', [old]) + expect(plan.toRemove).toEqual([old._id]) + expect(plan.toCreate).toEqual([acc('a')]) + }) + + it('collapses duplicate records for the same member to one', () => { + const keep = rec('a', 'read', 'rec-a1') + const dup = rec('a', 'read', 'rec-a2') + const plan = reconcileGrant([acc('a')], 'read', [keep, dup]) + expect(plan.toCreate).toEqual([]) + expect(plan.toRemove).toEqual([dup._id]) // exactly one duplicate removed, first kept + }) + + it('treats a missing level as read (fail-safe): read grant + undefined record = no-op', () => { + const existing = [rec('a', undefined)] + const plan = reconcileGrant([acc('a')], 'read', existing) + expect(plan.toCreate).toEqual([]) + expect(plan.toRemove).toEqual([]) + }) + + it('teardownGrant removes every in-scope record', () => { + const existing = [rec('a', 'read'), rec('b', 'write')] + const plan = teardownGrant(existing) + expect(plan.toCreate).toEqual([]) + expect(plan.toRemove.sort()).toEqual(existing.map((r) => r._id).sort()) + }) + + // ── idempotency: apply once, re-run must be a no-op for varied scenarios ── + it.each([ + { members: ['a', 'b', 'c'], level: 'read' as AccessLevel, start: [] as GroupCollabRecord[] }, + { members: ['a'], level: 'admin' as AccessLevel, start: [rec('a', 'read')] }, + { members: ['a', 'b'], level: 'write' as AccessLevel, start: [rec('a', 'write'), rec('z', 'write')] }, + { members: [], level: 'read' as AccessLevel, start: [rec('a', 'read'), rec('b', 'read')] } + ])('is idempotent for %j', ({ members, level, start }) => { + const first = reconcileGrant(members.map(acc), level, start) + const applied = applyPlan(start, members, level, first) + const second = reconcileGrant(members.map(acc), level, applied) + expect(second.toCreate).toEqual([]) + expect(second.toRemove).toEqual([]) + }) +}) diff --git a/server-plugins/access-group-resources/src/index.ts b/server-plugins/access-group-resources/src/index.ts new file mode 100644 index 00000000000..55ac393caa6 --- /dev/null +++ b/server-plugins/access-group-resources/src/index.ts @@ -0,0 +1,163 @@ +// +// 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 core, { + type AccessGroup, + type AccountUuid, + type Collaborator, + type Doc, + type GroupGrant, + type Ref, + type Tx, + type TxCreateDoc, + type TxRemoveDoc, + type TxUpdateDoc, + TxProcessor +} from '@hcengineering/core' +import type { TriggerControl } from '@hcengineering/server-core' + +import { type GroupCollabRecord, normalizeLevel, reconcileGrant } from './reconcile' + +/** + * Load the group-provenance Collaborator records materialized for a single + * GroupGrant (filtered by grantedByGroup — never touches other provenance). + */ +async function existingForGrant (control: TriggerControl, grant: GroupGrant): Promise { + return await control.findAll(control.ctx, core.class.Collaborator, { + attachedTo: grant.attachedTo, + grantedVia: 'group', + grantedByGroup: grant._id + }) +} + +/** + * Turn the pure reconcile diff for one GroupGrant into concrete Collaborator + * create/remove transactions. All derived collaborators live on the grant's doc + * and share its space. + */ +function reconcileTxes (control: TriggerControl, grant: GroupGrant, members: AccountUuid[], existing: Collaborator[]): Tx[] { + const minimal: GroupCollabRecord[] = existing.map((c) => ({ _id: c._id, collaborator: c.collaborator, level: c.level })) + const spaceById = new Map, Collaborator['space']>(existing.map((c) => [c._id, c.space])) + const plan = reconcileGrant(members, normalizeLevel(grant.level), minimal) + const res: Tx[] = [] + + for (const collaborator of plan.toCreate) { + res.push( + control.txFactory.createTxCreateDoc(core.class.Collaborator, grant.space, { + attachedTo: grant.attachedTo, + attachedToClass: grant.attachedToClass, + collaborator, + collection: 'collaborators', + grantedVia: 'group', + grantedBy: grant.grantedBy, + grantedByGroup: grant._id, + level: normalizeLevel(grant.level) + } as any) + ) + } + for (const id of plan.toRemove) { + res.push(control.txFactory.createTxRemoveDoc(core.class.Collaborator, spaceById.get(id) ?? grant.space, id)) + } + return res +} + +/** Resolve the group's current membership; missing group ⇒ no members (teardown). */ +async function groupMembers (control: TriggerControl, group: Ref): Promise { + const g = (await control.findAll(control.ctx, core.class.AccessGroup, { _id: group }, { limit: 1 }))[0] + return g?.members ?? [] +} + +/** + * Trigger for GroupGrant create / remove / level-update. Materializes and + * reconciles the derived group-collaborators (design 2.2 / P4.2). Runs as + * System, so the CollaboratorGuardMiddleware lets the derived writes through. + * @public + */ +export async function OnGroupGrantChanged (txes: Tx[], control: TriggerControl): Promise { + const result: Tx[] = [] + for (const tx of txes) { + if (!control.hierarchy.isDerived((tx as any).objectClass, core.class.GroupGrant)) continue + + if (tx._class === core.class.TxCreateDoc) { + const grant = TxProcessor.createDoc2Doc(tx as TxCreateDoc) + const members = await groupMembers(control, grant.group) + result.push(...reconcileTxes(control, grant, members, await existingForGrant(control, grant))) + } else if (tx._class === core.class.TxUpdateDoc) { + const upd = tx as TxUpdateDoc + if (!('level' in upd.operations)) continue // only level changes affect materialization + const grant = ( + await control.findAll(control.ctx, core.class.GroupGrant, { _id: upd.objectId }, { limit: 1 }) + )[0] + if (grant === undefined) continue + const members = await groupMembers(control, grant.group) + result.push(...reconcileTxes(control, grant, members, await existingForGrant(control, grant))) + } else if (tx._class === core.class.TxRemoveDoc) { + const rm = tx as TxRemoveDoc + // The grant is gone: every record it materialized is surplus. Query by the + // removed grant's id (grantedByGroup) — teardown = reconcile against ∅. + const orphans = await control.findAll(control.ctx, core.class.Collaborator, { + grantedVia: 'group', + grantedByGroup: rm.objectId as unknown as Ref + }) + for (const c of orphans) { + result.push(control.txFactory.createTxRemoveDoc(core.class.Collaborator, c.space, c._id)) + } + } + } + return result +} + +/** True iff an AccessGroup update touches its `members` (set / $push / $pull). */ +function touchesMembers (operations: Record): boolean { + if ('members' in operations) return true + for (const op of ['$push', '$pull', '$pullAll'] as const) { + const v = operations[op] + if (v != null && typeof v === 'object' && 'members' in v) return true + } + return false +} + +/** + * Trigger for AccessGroup membership changes. Re-reconciles every GroupGrant of + * the group so members added/removed gain/lose access on all granted docs. + * @public + */ +export async function OnAccessGroupChanged (txes: Tx[], control: TriggerControl): Promise { + const result: Tx[] = [] + for (const tx of txes) { + if (tx._class !== core.class.TxUpdateDoc) continue + const upd = tx as TxUpdateDoc + if (!control.hierarchy.isDerived(upd.objectClass, core.class.AccessGroup)) continue + if (!touchesMembers(upd.operations as Record)) continue + + const members = await groupMembers(control, upd.objectId) + const grants = await control.findAll(control.ctx, core.class.GroupGrant, { group: upd.objectId }) + for (const grant of grants) { + result.push(...reconcileTxes(control, grant, members, await existingForGrant(control, grant))) + } + } + return result +} + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export default async () => ({ + trigger: { + OnGroupGrantChanged, + OnAccessGroupChanged + } +}) + +export { reconcileGrant, teardownGrant, normalizeLevel } from './reconcile' +export type { GroupCollabRecord, ReconcilePlan } from './reconcile' diff --git a/server-plugins/access-group-resources/src/reconcile.ts b/server-plugins/access-group-resources/src/reconcile.ts new file mode 100644 index 00000000000..cfb2d33007f --- /dev/null +++ b/server-plugins/access-group-resources/src/reconcile.ts @@ -0,0 +1,100 @@ +// +// 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, AccountUuid, Collaborator, Ref } from '@hcengineering/core' + +/** + * The subset of a materialized group-provenance Collaborator that the reconcile + * diff needs. Kept minimal so the algorithm stays pure and unit-testable + * without a running DB. + */ +export interface GroupCollabRecord { + _id: Ref + collaborator: AccountUuid + level?: AccessLevel +} + +/** + * The desired-vs-actual diff for a single GroupGrant. `toCreate` lists the + * members that need a fresh group-collaborator at `desiredLevel`; `toRemove` + * lists the record ids to delete (non-members, wrong-level, or duplicates). + */ +export interface ReconcilePlan { + toCreate: AccountUuid[] + toRemove: Array> +} + +/** + * Normalize an access level: a missing level counts as 'read' (fail-safe + * minimal), matching the Collaborator/GroupGrant schema contract. + */ +export function normalizeLevel (level?: AccessLevel): AccessLevel { + return level ?? 'read' +} + +/** + * Pure reconcile for ONE GroupGrant. + * + * Desired state: for every member `m` of the grant's group, exactly one + * Collaborator `{ collaborator: m, level: desiredLevel, grantedVia: 'group', + * grantedByGroup: }`. `existing` MUST already be pre-filtered to the + * records carrying this grant's `grantedByGroup` — so records of other + * provenance (manual/mention/structural) and of other grants are never in + * scope and are therefore never removed. + * + * Guarantees (unit-tested in reconcile.test.ts): + * - level change → the wrong-level record is removed and a new one created + * (Collaborator records are immutable, so re-level = remove + create); + * - duplicates for the same member collapse to one; + * - a member removed from the group loses exactly its record here; + * - IDEMPOTENT: applying the plan and re-running yields an empty plan. + */ +export function reconcileGrant ( + members: AccountUuid[], + desiredLevel: AccessLevel, + existing: GroupCollabRecord[] +): ReconcilePlan { + const wanted = normalizeLevel(desiredLevel) + const memberSet = new Set(members) + const toCreate: AccountUuid[] = [] + const toRemove: Array> = [] + // Members that already have exactly one kept record at the correct level. + const satisfied = new Set() + + for (const rec of existing) { + const good = + memberSet.has(rec.collaborator) && normalizeLevel(rec.level) === wanted && !satisfied.has(rec.collaborator) + if (good) { + satisfied.add(rec.collaborator) + } else { + // non-member, wrong level, or a duplicate of an already-kept member + toRemove.push(rec._id) + } + } + + for (const m of members) { + if (!satisfied.has(m)) toCreate.push(m) + } + + return { toCreate, toRemove } +} + +/** + * Teardown for a removed GroupGrant: every record in scope is surplus. Equivalent + * to reconciling against an empty membership. + */ +export function teardownGrant (existing: GroupCollabRecord[]): ReconcilePlan { + return reconcileGrant([], 'read', existing) +} diff --git a/server-plugins/access-group-resources/tsconfig.json b/server-plugins/access-group-resources/tsconfig.json new file mode 100644 index 00000000000..b5ae22f6e46 --- /dev/null +++ b/server-plugins/access-group-resources/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/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/server-plugins/access-group/.eslintrc.js b/server-plugins/access-group/.eslintrc.js new file mode 100644 index 00000000000..72235dc2833 --- /dev/null +++ b/server-plugins/access-group/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/server-plugins/access-group/.npmignore b/server-plugins/access-group/.npmignore new file mode 100644 index 00000000000..e3ec093c383 --- /dev/null +++ b/server-plugins/access-group/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/server-plugins/access-group/package.json b/server-plugins/access-group/package.json new file mode 100644 index 00000000000..6063b1d6366 --- /dev/null +++ b/server-plugins/access-group/package.json @@ -0,0 +1,45 @@ +{ + "name": "@hcengineering/server-access-group", + "version": "0.7.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "tsconfig.json" + ], + "author": "Anticrm Platform Contributors", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "format": "format src", + "test": "jest --passWithNoTests --silent", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "workspace:^0.7.21", + "@types/node": "^22.18.1", + "@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", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5" + }, + "dependencies": { + "@hcengineering/core": "workspace:^0.7.26", + "@hcengineering/platform": "workspace:^0.7.20", + "@hcengineering/server-core": "workspace:^0.7.19" + } +} diff --git a/server-plugins/access-group/src/index.ts b/server-plugins/access-group/src/index.ts new file mode 100644 index 00000000000..c6fe7924fbf --- /dev/null +++ b/server-plugins/access-group/src/index.ts @@ -0,0 +1,38 @@ +// +// 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 { Plugin, Resource } from '@hcengineering/platform' +import { plugin } from '@hcengineering/platform' +import { TriggerFunc } from '@hcengineering/server-core' + +/** + * @public + */ +export const serverAccessGroupId = 'server-access-group' as Plugin + +/** + * Server plugin that materializes group access-grants into per-member + * Collaborator records and keeps them reconciled with group membership and + * grant level (design section 2.2, G2 = materialized + reconcile trigger). + * @public + */ +export default plugin(serverAccessGroupId, { + trigger: { + // Fires on GroupGrant create / remove / level-update. + OnGroupGrantChanged: '' as Resource, + // Fires on AccessGroup membership update. + OnAccessGroupChanged: '' as Resource + } +}) diff --git a/server-plugins/access-group/tsconfig.json b/server-plugins/access-group/tsconfig.json new file mode 100644 index 00000000000..b5ae22f6e46 --- /dev/null +++ b/server-plugins/access-group/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/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/server/server-pipeline/package.json b/server/server-pipeline/package.json index a80e185f085..02bf10badb6 100644 --- a/server/server-pipeline/package.json +++ b/server/server-pipeline/package.json @@ -53,6 +53,8 @@ "@hcengineering/server-setting-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-inventory": "workspace:^0.7.0", "@hcengineering/server-inventory-resources": "workspace:^0.7.0", "@hcengineering/server-lead": "workspace:^0.7.0", diff --git a/server/server-pipeline/src/serverPlugins.ts b/server/server-pipeline/src/serverPlugins.ts index 36298ce9d5d..af34f2cbacf 100644 --- a/server/server-pipeline/src/serverPlugins.ts +++ b/server/server-pipeline/src/serverPlugins.ts @@ -1,4 +1,5 @@ import { addLocation } from '@hcengineering/platform' +import { serverAccessGroupId } from '@hcengineering/server-access-group' import { serverActivityId } from '@hcengineering/server-activity' import { serverAttachmentId } from '@hcengineering/server-attachment' import { serverCardId } from '@hcengineering/server-card' @@ -38,6 +39,7 @@ export function registerServerPlugins (): void { addLocation(serverNotificationId, () => import('@hcengineering/server-notification-resources')) addLocation(serverSettingId, () => import('@hcengineering/server-setting-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')) From b3c123ce2544c911a88f3201ed4a60c353098dd4 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Wed, 8 Jul 2026 15:10:06 +0000 Subject: [PATCH 45/65] feat(tracker): group grants in issue Access panel Signed-off-by: Michael Uray --- plugins/tracker-assets/lang/de.json | 8 +- plugins/tracker-assets/lang/en.json | 8 +- .../edit/IssueAccessGrantConfirm.svelte | 30 ++- .../issues/edit/IssueAccessPanel.svelte | 191 +++++++++++++++++- .../issues/edit/SelectAccessGroupPopup.svelte | 98 +++++++++ plugins/tracker-resources/src/plugin.ts | 8 +- 6 files changed, 330 insertions(+), 13 deletions(-) create mode 100644 plugins/tracker-resources/src/components/issues/edit/SelectAccessGroupPopup.svelte diff --git a/plugins/tracker-assets/lang/de.json b/plugins/tracker-assets/lang/de.json index f419f7ec382..c3acd74df6f 100644 --- a/plugins/tracker-assets/lang/de.json +++ b/plugins/tracker-assets/lang/de.json @@ -314,7 +314,13 @@ "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" + "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 b35aa9566b1..bea396efe10 100644 --- a/plugins/tracker-assets/lang/en.json +++ b/plugins/tracker-assets/lang/en.json @@ -314,7 +314,13 @@ "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" + "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/IssueAccessGrantConfirm.svelte b/plugins/tracker-resources/src/components/issues/edit/IssueAccessGrantConfirm.svelte index 89f0160a04a..25c871d985a 100644 --- a/plugins/tracker-resources/src/components/issues/edit/IssueAccessGrantConfirm.svelte +++ b/plugins/tracker-resources/src/components/issues/edit/IssueAccessGrantConfirm.svelte @@ -15,18 +15,28 @@ @@ -291,15 +365,89 @@ {/each} - {#if canGrant} + {#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} @@ -368,6 +516,33 @@ color: var(--theme-dark-color); } .actions { + display: flex; + gap: 0.5rem; margin-top: 0.25rem; } + .group-head { + display: flex; + align-items: center; + gap: 0.375rem; + min-width: 0; + + &.clickable { + cursor: pointer; + } + .count { + font-size: 0.75rem; + color: var(--theme-dark-color); + } + } + .group-name { + color: var(--theme-content-color); + } + .group-members { + padding-left: 0.5rem; + border-left: 1px solid var(--theme-divider-color); + margin-left: 0.25rem; + } + .grant-row.indented { + min-height: 1.75rem; + } 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..ecb23f24e3d --- /dev/null +++ b/plugins/tracker-resources/src/components/issues/edit/SelectAccessGroupPopup.svelte @@ -0,0 +1,98 @@ + + + +
+
+
+
+ {#if groups.length === 0} +
+ {/if} + {#each groups as group (group._id)} + + +
select(group)}> + {group.name} + + +
+ {/each} +
+
+ + diff --git a/plugins/tracker-resources/src/plugin.ts b/plugins/tracker-resources/src/plugin.ts index a790876ae38..d86a8e072cc 100644 --- a/plugins/tracker-resources/src/plugin.ts +++ b/plugins/tracker-resources/src/plugin.ts @@ -328,7 +328,13 @@ export default mergeIds(trackerId, tracker, { AccessGrantWarningRead: '' as IntlString, AccessGrantWarningWrite: '' as IntlString, AccessGrantWarningAdmin: '' as IntlString, - GrantAccessConfirm: '' 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, From 1a48a14a88aa51ee61071c4ee7d5690c68c1b3f8 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Wed, 8 Jul 2026 15:16:50 +0000 Subject: [PATCH 46/65] feat(setting): access-group management UI + fail-closed AccessGroup guard Signed-off-by: Michael Uray --- .../middleware/src/collaboratorGuard.ts | 55 +++++ .../src/tests/collaboratorGuard.test.ts | 108 ++++++++- models/setting/src/index.ts | 13 + plugins/setting-assets/lang/de.json | 12 +- plugins/setting-assets/lang/en.json | 12 +- .../src/components/AccessGroups.svelte | 223 ++++++++++++++++++ plugins/setting-resources/src/index.ts | 4 +- plugins/setting/src/index.ts | 15 +- 8 files changed, 435 insertions(+), 7 deletions(-) create mode 100644 plugins/setting-resources/src/components/AccessGroups.svelte diff --git a/foundations/server/packages/middleware/src/collaboratorGuard.ts b/foundations/server/packages/middleware/src/collaboratorGuard.ts index 9c1ed4ae322..c9636f0c62b 100644 --- a/foundations/server/packages/middleware/src/collaboratorGuard.ts +++ b/foundations/server/packages/middleware/src/collaboratorGuard.ts @@ -20,6 +20,7 @@ import { type TxMiddlewareResult } from '@hcengineering/server-core' import core, { + type AccessGroup, type Account, AccountRole, type AccessLevel, @@ -80,6 +81,9 @@ function forbidden (): PlatformError> { * 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. * @@ -119,6 +123,10 @@ export class CollaboratorGuardMiddleware extends BaseMiddleware implements Middl 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) { @@ -263,6 +271,53 @@ export class CollaboratorGuardMiddleware extends BaseMiddleware implements Middl 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) === true || 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() + return + } + // TxUpdateDoc / TxMixin by an owner: allowed. + } + /** * Authority to create / remove / re-level a GroupGrant on the doc: * - workspace Maintainer+, OR diff --git a/foundations/server/packages/middleware/src/tests/collaboratorGuard.test.ts b/foundations/server/packages/middleware/src/tests/collaboratorGuard.test.ts index b825d86a00f..3e06af1bb1c 100644 --- a/foundations/server/packages/middleware/src/tests/collaboratorGuard.test.ts +++ b/foundations/server/packages/middleware/src/tests/collaboratorGuard.test.ts @@ -33,6 +33,7 @@ import core, { type AccountUuid, type Class, type Collaborator, + type AccessGroup, type Doc, type GroupGrant, type MeasureContext, @@ -117,8 +118,13 @@ 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. */ -function serve (opts: { space?: Space, collaborators?: Collaborator[], groupGrants?: GroupGrant[] }): FindAllFn { +/** findAll that serves an optional space and optional collaborators / group grants / access groups. */ +function serve (opts: { + space?: Space + 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] : [] @@ -133,6 +139,12 @@ function serve (opts: { space?: Space, collaborators?: Collaborator[], groupGran 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 [] @@ -197,6 +209,37 @@ function groupGrantRecord (over: Partial): GroupGrant { } 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) as Ref, + _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()) as Ref, @@ -532,4 +575,65 @@ describe('CollaboratorGuardMiddleware', () => { ) 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/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/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-resources/src/components/AccessGroups.svelte b/plugins/setting-resources/src/components/AccessGroups.svelte new file mode 100644 index 00000000000..97c3f2534fc --- /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, From 34b942b89ece2d977277965bd297696af5ff6740 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Wed, 8 Jul 2026 15:19:36 +0000 Subject: [PATCH 47/65] fix(server): drop unused Doc import in access-group resources Signed-off-by: Michael Uray --- server-plugins/access-group-resources/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server-plugins/access-group-resources/src/index.ts b/server-plugins/access-group-resources/src/index.ts index 55ac393caa6..e69e5710c9f 100644 --- a/server-plugins/access-group-resources/src/index.ts +++ b/server-plugins/access-group-resources/src/index.ts @@ -17,7 +17,6 @@ import core, { type AccessGroup, type AccountUuid, type Collaborator, - type Doc, type GroupGrant, type Ref, type Tx, From 7f08f62679f1cf5589ecaedb02fc122051edd035 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Wed, 8 Jul 2026 15:23:01 +0000 Subject: [PATCH 48/65] chore(rush): register access-group packages + update lockfile (P4) Signed-off-by: Michael Uray --- common/config/rush/pnpm-lock.yaml | 186 ++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index e8383843775..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': @@ -31652,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': @@ -36755,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 From d3eb12ce8bc84670ac7392361bff04e6128f751a Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Wed, 8 Jul 2026 15:39:39 +0000 Subject: [PATCH 49/65] feat(chunter): pure mention-grant reconcile helper + unit tests (P5.3) Signed-off-by: Michael Uray --- .../src/__tests__/mentionGrantsDelta.test.ts | 60 ++++++++++++++++ .../src/mentionGrantsDelta.ts | 70 +++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 server-plugins/chunter-resources/src/__tests__/mentionGrantsDelta.test.ts create mode 100644 server-plugins/chunter-resources/src/mentionGrantsDelta.ts diff --git a/server-plugins/chunter-resources/src/__tests__/mentionGrantsDelta.test.ts b/server-plugins/chunter-resources/src/__tests__/mentionGrantsDelta.test.ts new file mode 100644 index 00000000000..95573081043 --- /dev/null +++ b/server-plugins/chunter-resources/src/__tests__/mentionGrantsDelta.test.ts @@ -0,0 +1,60 @@ +import { computeMentionGrantDelta, type ExistingMentionGrant } from '../mentionGrantsDelta' + +function rec (collaborator: string, id = `c-${collaborator}`): ExistingMentionGrant { + return { + _id: id as any, + _class: 'core:class:Collaborator' as any, + space: 'space-1' as any, + collaborator: collaborator as any + } +} + +describe('computeMentionGrantDelta', () => { + test('fresh message (no existing records): all desired are created, nothing removed', () => { + const { toCreate, toRemove } = computeMentionGrantDelta(['a', 'b'] as any, []) + expect(new Set(toCreate)).toEqual(new Set(['a', 'b'])) + expect(toRemove).toHaveLength(0) + }) + + test('desired already has a mention record: deduped, not re-created', () => { + const { toCreate, toRemove } = computeMentionGrantDelta(['a'] as any, [rec('a')]) + expect(toCreate).toHaveLength(0) + expect(toRemove).toHaveLength(0) + }) + + test('mention removed (desired empty): existing record is removed', () => { + const existing = [rec('a')] + const { toCreate, toRemove } = computeMentionGrantDelta([] as any, existing) + expect(toCreate).toHaveLength(0) + expect(toRemove).toEqual(existing) + }) + + test('edit drops one grantee, keeps another: only the dropped record is removed', () => { + const keep = rec('a') + const drop = rec('b') + const { toCreate, toRemove } = computeMentionGrantDelta(['a'] as any, [keep, drop]) + expect(toCreate).toHaveLength(0) + expect(toRemove).toEqual([drop]) + }) + + test('edit adds a new grantee alongside an existing one', () => { + const keep = rec('a') + const { toCreate, toRemove } = computeMentionGrantDelta(['a', 'c'] as any, [keep]) + expect(toCreate).toEqual(['c']) + expect(toRemove).toHaveLength(0) + }) + + test('desired list is deduplicated before create', () => { + const { toCreate } = computeMentionGrantDelta(['a', 'a', 'b'] as any, []) + expect(toCreate.filter((x) => x === ('a' as any))).toHaveLength(1) + expect(new Set(toCreate)).toEqual(new Set(['a', 'b'])) + }) + + test('never reasons about accounts outside the passed existing set (other provenance untouched)', () => { + // Caller passes ONLY this-message mention records; a manual/structural record + // for the same person is not in `existing`, so it is neither created nor removed. + const { toCreate, toRemove } = computeMentionGrantDelta([] as any, []) + expect(toCreate).toHaveLength(0) + expect(toRemove).toHaveLength(0) + }) +}) diff --git a/server-plugins/chunter-resources/src/mentionGrantsDelta.ts b/server-plugins/chunter-resources/src/mentionGrantsDelta.ts new file mode 100644 index 00000000000..7bbed7f823b --- /dev/null +++ b/server-plugins/chunter-resources/src/mentionGrantsDelta.ts @@ -0,0 +1,70 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. 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 { AccountUuid, Class, Collaborator, Ref, Space } from '@hcengineering/core' + +/** + * A `grantedVia:'mention'` Collaborator record scoped to a single message + * (`grantedByMessage === message._id`). Only the fields needed to build a + * `TxRemoveDoc` plus the account for diffing are carried. + * + * @public + */ +export interface ExistingMentionGrant { + _id: Ref + _class: Ref> + space: Ref + collaborator: AccountUuid +} + +/** + * @public + */ +export interface MentionGrantDelta { + toCreate: AccountUuid[] + toRemove: ExistingMentionGrant[] +} + +/** + * Pure reconciliation of the mention-provenance Collaborator records for ONE + * message against the set of accounts that should currently hold a mention + * grant (the `desired` list — already consent- and author-gate-filtered by the + * caller). + * + * - `toCreate`: desired accounts that have no existing mention record yet + * (deduplicated). The caller writes them with `grantedVia:'mention'`, + * `grantedByMessage:`, `level:'read'`. + * - `toRemove`: existing mention records whose account is no longer desired. + * Passing `desired = []` (message deleted, or the last mention removed on an + * edit) yields `toRemove = existing`, `toCreate = []`. + * + * Contract: the caller MUST pass only records scoped to this message + * (`grantedVia:'mention' && grantedByMessage === message._id`). The function + * never sees structural (`grantedVia === undefined`), `manual` or `group` + * records, so those are provably never created or removed — a person granted + * via several provenances keeps access until the LAST source is gone. + * + * @public + */ +export function computeMentionGrantDelta ( + desired: AccountUuid[], + existing: ExistingMentionGrant[] +): MentionGrantDelta { + const existingAccounts = new Set(existing.map((r) => r.collaborator)) + const desiredSet = new Set(desired) + const toCreate = Array.from(desiredSet).filter((a) => !existingAccounts.has(a)) + const toRemove = existing.filter((r) => !desiredSet.has(r.collaborator)) + return { toCreate, toRemove } +} From 8b9c61e3f4f9eb70934957f1ac2bdb521120d105 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Wed, 8 Jul 2026 15:39:39 +0000 Subject: [PATCH 50/65] feat(chunter): mention grants as Grant-v2 special case (P5.1/5.2/5.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrate the existing mention-grant trigger with the P1-P4 grant infrastructure: - provenance: grant path writes grantedVia:'mention' + grantedBy + grantedByMessage + level:'read' (P1 schema). Dedup basis is now per (collaborator, mention, message), not global — a structural/manual collaborator still gets a revocable mention record. - fail-closed consent (M-G.2): only grantsAccess === 'true' grants; the notification fan-out for non-secured targets keeps a separate (!== 'false') list so channel mentions still notify without granting. - author-membership gate (M-G.3): a mention only grants when the message author resolves to an account that is a member of the grant-target space — stops transitive spread by collab-only guests. Fail-closed on unresolvable author. - revoke (M-G.4): OnChatMessageUpdated reconciles this message's mention records (added->create, removed->TxRemoveDoc); OnChatMessageRemoved tears down its grantedByMessage records. Only grantedVia:'mention' records with the matching grantedByMessage are touched. Signed-off-by: Michael Uray --- .../__tests__/mentionGrantsTrigger.test.ts | 320 ++++++++++++++---- server-plugins/chunter-resources/src/index.ts | 182 +++++++--- 2 files changed, 385 insertions(+), 117 deletions(-) diff --git a/server-plugins/chunter-resources/src/__tests__/mentionGrantsTrigger.test.ts b/server-plugins/chunter-resources/src/__tests__/mentionGrantsTrigger.test.ts index 15d76500ddc..19cbdb34cb3 100644 --- a/server-plugins/chunter-resources/src/__tests__/mentionGrantsTrigger.test.ts +++ b/server-plugins/chunter-resources/src/__tests__/mentionGrantsTrigger.test.ts @@ -4,7 +4,7 @@ import { jsonToMarkup, MarkupNodeType } from '@hcengineering/text-core' // Imported AFTER the mocks (jest.mock above is hoisted by ts-jest, so even // these top-level ESM imports see the mocked module). Avoid `require()` so // the file passes `tsc --noEmit` (the package's tsconfig only ships @types/jest). -import { ChunterTrigger } from '../index' +import { ChunterTrigger, OnChatMessageRemoved } from '../index' import coreDefault from '@hcengineering/core' // ------------------------------------------------------------------ @@ -15,16 +15,18 @@ import coreDefault from '@hcengineering/core' // undefined during getter evaluation when all properties are accessed // eagerly). We work around this by requiring the actual module and only // accessing specific known-safe properties instead of spreading. +// +// P5: resolveMentionGrantTarget is driven by a globalThis flag so a single +// test can exercise the notification-only (non-grant-target) path. // ------------------------------------------------------------------ jest.mock('@hcengineering/core', () => { - // Load the real module but access only the specific properties we need. - // DO NOT spread with {...actual}: that triggers all lazy getters eagerly, - // hitting the circular-dep crash in import_component2. const actual = jest.requireActual('@hcengineering/core') - // Build a proxy that delegates unknown property reads to the actual module. - // This avoids the eager spread while still giving server-core etc. access - // to toFindResult, TxProcessor, etc. + const mockGetClassCollaborators = jest.fn(() => ({ provideSecurity: true, mentionsGrantAccess: true })) + const mockResolveMentionGrantTarget = jest.fn(async (doc: any) => + (globalThis as any).__noGrantTarget === true ? null : doc + ) + const proxy = new Proxy(actual, { get (target: any, prop: string) { if (prop === 'getClassCollaborators') return mockGetClassCollaborators @@ -33,9 +35,6 @@ jest.mock('@hcengineering/core', () => { } }) - const mockGetClassCollaborators = jest.fn(() => ({ provideSecurity: true, mentionsGrantAccess: true })) - const mockResolveMentionGrantTarget = jest.fn(async (doc: any) => doc) - return proxy }) @@ -61,12 +60,25 @@ function makeMarkup (...refs: Array<{ id: string, grantsAccess?: 'true' | 'false const TARGET = { _id: 'issue-1', _class: 'tracker:class:Issue', space: 'space-1' } -function makeControl (): any { +interface ControlOpts { + spaceMembers?: string[] + // Existing grantedVia:'mention' records for THIS message (msg-1). + mentionRecords?: Array<{ _id: string, collaborator: string, grantedByMessage?: string }> + // All collaborators on the grant target (structural / any provenance). + targetCollaborators?: Array<{ _id: string, collaborator: string, grantedVia?: string, grantedByMessage?: string }> + classCollabProvideSecurity?: boolean +} + +function makeControl (opts: ControlOpts = {}): any { + const spaceMembers = opts.spaceMembers ?? ['author-acc'] + const mentionRecords = opts.mentionRecords ?? [] + const targetCollaborators = opts.targetCollaborators ?? [ + { _id: 'col-existing', collaborator: 'existing-acc' } // structural, no provenance + ] + const classCollabProvideSecurity = opts.classCollabProvideSecurity ?? true + return { hierarchy: { - // ChunterTrigger dispatches on isDerived(tx.objectClass, ChatMessage) - // FIRST (index.ts:360) — without the ChatMessage branch OnChatMessageCreated - // never runs. Mentioned refs are Persons; ThreadMessage/Channel stay false. isDerived: (cls: string, base: string) => (base === 'chunter:class:ChatMessage' && cls === 'chunter:class:ChatMessage') || (base === 'contact:class:Person' && cls === 'contact:class:Person') @@ -78,30 +90,51 @@ function makeControl (): any { objectClass: _class, objectSpace: space, attributes + }), + createTxRemoveDoc: (_class: string, space: string, objectId: string) => ({ + _class: coreDefault.class.TxRemoveDoc, + objectClass: _class, + objectSpace: space, + objectId }) }, findAll: jest.fn(async (_ctx: any, _class: string, query: any) => { if (_class === coreDefault.class.Collaborator) { - // NON-EMPTY -> skip the legacy init branch (index.ts:204) and provide - // the grant-target dedup basis (existing-acc already a collaborator). - return [{ collaborator: 'existing-acc' }] + if (query?.grantedVia === 'mention') { + // Message-scoped mention records (revoke + dedup basis). + return mentionRecords.map((r) => ({ + _id: r._id, + _class: coreDefault.class.Collaborator, + space: 'space-1', + collaborator: r.collaborator, + grantedVia: 'mention', + grantedByMessage: r.grantedByMessage ?? 'msg-1' + })) + } + // Full collaborator list on the target (structural + any provenance). + return targetCollaborators.map((c) => ({ + _id: c._id, + _class: coreDefault.class.Collaborator, + space: 'space-1', + collaborator: c.collaborator, + grantedVia: c.grantedVia, + grantedByMessage: c.grantedByMessage + })) } if (_class === coreDefault.class.ClassCollaborators) { - return [{ provideSecurity: true }] + return [{ provideSecurity: classCollabProvideSecurity }] + } + if (_class === coreDefault.class.Space) { + return [{ _id: 'space-1', members: spaceMembers }] + } + if (typeof _class === 'string' && _class.includes('InboxNotification')) { + return [] } if (typeof _class === 'string' && _class.includes('Employee')) { - // Query-based: return an Employee for EVERY requested id. If the mock - // hardcoded only p1, the test would pass even WITHOUT the grantsAccess - // filter (p2 never returned) — a false positive. Returning all requested - // ids means a missing filter WOULD grant p2, so the test is red before - // Step 3 and green after. const ids: string[] = query?._id?.$in ?? [] return ids.map((id: string) => ({ _id: id, personUuid: `${id}-acc` })) } if (_class === 'chunter:class:ChatMessage') { - // OnChatMessageUpdated loads the current stored message by _id before - // applying the update operations. Return a ChatMessage shape with the - // OLD (no-mention) body; the update tx supplies the new text. return [ { _id: 'msg-1', @@ -116,7 +149,6 @@ function makeControl (): any { } ] } - // targetDoc lookup (message.attachedTo) return [TARGET] }), ctx: { @@ -154,90 +186,236 @@ function makeUpdateTx (operations: any, modifiedBy = 'social-editor'): any { } } -function collaboratorGrants (res: Tx[]): string[] { +function makeRemoveTx (): any { + return { + _class: coreDefault.class.TxRemoveDoc, + objectClass: 'chunter:class:ChatMessage', + objectId: 'msg-1', + objectSpace: 'space-1' + } +} + +// All created Collaborator records (attributes). +function collaboratorCreates (res: Tx[]): any[] { return res .filter( (t): t is TxCreateDoc => t._class === coreDefault.class.TxCreateDoc && (t as any).objectClass === coreDefault.class.Collaborator ) - .map((t) => (t as any).attributes.collaborator) + .map((t) => (t as any).attributes) } -describe('ChunterTrigger mention grants — grantsAccess filter', () => { - test("a reference with grantsAccess='false' yields no Collaborator tx for that person", async () => { +// Only mention-provenance grant creates. +function mentionGrantCreates (res: Tx[]): any[] { + return collaboratorCreates(res).filter((a) => a.grantedVia === 'mention') +} + +// Collaborator removals -> the removed record _id. +function collaboratorRemovals (res: Tx[]): string[] { + return res + .filter( + (t: any) => t._class === coreDefault.class.TxRemoveDoc && t.objectClass === coreDefault.class.Collaborator + ) + .map((t: any) => t.objectId) +} + +afterEach(() => { + delete (globalThis as any).__noGrantTarget +}) + +// ------------------------------------------------------------------ +// 5.1 — provenance + default level +// ------------------------------------------------------------------ +describe('P5.1 mention grants carry provenance + default level', () => { + test('a consented mention grant carries grantedVia=mention, grantedBy, grantedByMessage, level=read', async () => { + const control = makeControl() + const tx = makeCreateTx(makeMarkup({ id: 'p1', grantsAccess: 'true' })) + + const res = await ChunterTrigger([tx], control) + const grants = mentionGrantCreates(res) + + expect(grants).toHaveLength(1) + expect(grants[0]).toMatchObject({ + collaborator: 'p1-acc', + grantedVia: 'mention', + grantedBy: 'author-acc', + grantedByMessage: 'msg-1', + level: 'read' + }) + }) + + test('double provenance: a person with a structural record still gets a mention record', async () => { + // 'existing-acc' is already a structural collaborator on the target; a + // consented mention of that person must still add a mention-read record + // (dedup is per (collaborator, mention, message), NOT global). + const control = makeControl() + const tx = makeCreateTx(makeMarkup({ id: 'existing', grantsAccess: 'true' })) + + const res = await ChunterTrigger([tx], control) + const grants = mentionGrantCreates(res) + + expect(grants.map((g) => g.collaborator)).toContain('existing-acc') + }) +}) + +// ------------------------------------------------------------------ +// 5.2 — fail-closed consent + author gate + notification separation +// ------------------------------------------------------------------ +describe('P5.2 fail-closed consent', () => { + test('a mention WITHOUT grantsAccess grants nothing (fail-closed)', async () => { const control = makeControl() - const tx = makeCreateTx(makeMarkup({ id: 'p1' }, { id: 'p2', grantsAccess: 'false' })) + const tx = makeCreateTx(makeMarkup({ id: 'p1' })) // no grantsAccess flag + + const res = await ChunterTrigger([tx], control) + + expect(mentionGrantCreates(res)).toHaveLength(0) + }) - const res: Tx[] = await ChunterTrigger([tx], control) + test("grantsAccess='false' grants nothing; grantsAccess='true' grants; denied filtered before Employee query", async () => { + const control = makeControl() + const tx = makeCreateTx(makeMarkup({ id: 'p1', grantsAccess: 'true' }, { id: 'p2', grantsAccess: 'false' })) - const granted = res - .filter( - (t): t is TxCreateDoc => - t._class === coreDefault.class.TxCreateDoc && (t as any).objectClass === coreDefault.class.Collaborator - ) - .map((t) => (t as any).attributes.collaborator) + const res = await ChunterTrigger([tx], control) + const granted = mentionGrantCreates(res).map((g) => g.collaborator) - expect(granted).toContain('p1-acc') // p1 granted (no deny flag) - expect(granted).not.toContain('p2-acc') // p2 denied via grantsAccess='false' + expect(granted).toContain('p1-acc') + expect(granted).not.toContain('p2-acc') - // Stronger: the denied person must be filtered BEFORE the Employee query, - // so the query's id list must be exactly ['p1'] (not ['p1','p2']). + // Employee query is driven by the notify list (!== 'false'); p2 is excluded. const employeeCall = control.findAll.mock.calls.find( (c: any[]) => typeof c[1] === 'string' && c[1].includes('Employee') ) expect(employeeCall?.[2]?._id?.$in).toEqual(['p1']) }) + + test('author who is NOT a space member cannot grant (author-membership gate)', async () => { + const control = makeControl({ spaceMembers: [] }) // author-acc not a member + const tx = makeCreateTx(makeMarkup({ id: 'p1', grantsAccess: 'true' })) + + const res = await ChunterTrigger([tx], control) + + expect(mentionGrantCreates(res)).toHaveLength(0) + }) + + test('notification fan-out on a NON-grant target still creates collaborators without provenance', async () => { + ;(globalThis as any).__noGrantTarget = true + const control = makeControl({ + classCollabProvideSecurity: false, + targetCollaborators: [{ _id: 'col-x', collaborator: 'other-acc' }] + }) + const tx = makeCreateTx(makeMarkup({ id: 'p1' })) // no grant flag -> notify only + + const res = await ChunterTrigger([tx], control) + const creates = collaboratorCreates(res) + + // p1 (and the author) are added for notification, and NONE carry a grant. + expect(creates.map((c) => c.collaborator)).toContain('p1-acc') + expect(creates.every((c) => c.grantedVia === undefined)).toBe(true) + }) }) -describe('ChunterTrigger mention grants — V3d add-only re-grant on edit', () => { - test('a newly-added mention on edit grants the mentioned employee', async () => { +// ------------------------------------------------------------------ +// 5.3 — reconcile on edit + revoke on delete +// ------------------------------------------------------------------ +describe('P5.3 reconcile on edit', () => { + test('a newly-consented mention on edit grants the mentioned employee', async () => { const control = makeControl() - const tx = makeUpdateTx({ message: makeMarkup({ id: 'p3' }) }) + const tx = makeUpdateTx({ message: makeMarkup({ id: 'p3', grantsAccess: 'true' }) }) - const res: Tx[] = await ChunterTrigger([tx], control) + const res = await ChunterTrigger([tx], control) - expect(collaboratorGrants(res)).toContain('p3-acc') + expect(mentionGrantCreates(res).map((g) => g.collaborator)).toContain('p3-acc') }) - test('a denied mention on edit grants nothing (V3c filter still applies)', async () => { + test('an unconsented mention on edit grants nothing (fail-closed)', async () => { const control = makeControl() - const tx = makeUpdateTx({ message: makeMarkup({ id: 'p3', grantsAccess: 'false' }) }) + const tx = makeUpdateTx({ message: makeMarkup({ id: 'p3' }) }) - const res: Tx[] = await ChunterTrigger([tx], control) + const res = await ChunterTrigger([tx], control) - expect(collaboratorGrants(res)).not.toContain('p3-acc') + expect(mentionGrantCreates(res)).toHaveLength(0) }) - test('an already-granted collaborator is deduped — add-only no-op', async () => { - const control = makeControl() - // 'existing' resolves to personUuid 'existing-acc', which the grant target - // already lists (findAll Collaborator returns existing-acc) -> no new tx. - const tx = makeUpdateTx({ message: makeMarkup({ id: 'existing' }) }) + test('an already-granted mention is deduped — no new create, no remove', async () => { + const control = makeControl({ + mentionRecords: [{ _id: 'col-p3', collaborator: 'p3-acc' }] + }) + const tx = makeUpdateTx({ message: makeMarkup({ id: 'p3', grantsAccess: 'true' }) }) - const res: Tx[] = await ChunterTrigger([tx], control) + const res = await ChunterTrigger([tx], control) - expect(collaboratorGrants(res)).not.toContain('existing-acc') - expect(collaboratorGrants(res)).toHaveLength(0) + expect(mentionGrantCreates(res)).toHaveLength(0) + expect(collaboratorRemovals(res)).toHaveLength(0) }) - test('a System-authored edit grants nothing (stale modifiedBy guard — uses edit actor)', async () => { - const control = makeControl() - // updateDoc2Doc sets message.modifiedBy = tx.modifiedBy = System, so the - // applyMentionGrants System guard fires. If the handler used the stored - // doc's (non-System) author instead, this would wrongly grant. - const tx = makeUpdateTx({ message: makeMarkup({ id: 'p3' }) }, coreDefault.account.System) + test('removing a mention on edit revokes ONLY that message\'s mention record', async () => { + // p3 was granted by this message; the edit drops the mention -> its record + // is removed. A record from ANOTHER message (col-other) is out of scope. + const control = makeControl({ + mentionRecords: [{ _id: 'col-p3', collaborator: 'p3-acc' }] + }) + const tx = makeUpdateTx({ message: makeMarkup() }) // no mentions left - const res: Tx[] = await ChunterTrigger([tx], control) + const res = await ChunterTrigger([tx], control) - expect(collaboratorGrants(res)).toHaveLength(0) + expect(collaboratorRemovals(res)).toEqual(['col-p3']) + }) + + test('edit keeps one mention, drops another -> only the dropped record is removed', async () => { + const control = makeControl({ + mentionRecords: [ + { _id: 'col-p3', collaborator: 'p3-acc' }, + { _id: 'col-p4', collaborator: 'p4-acc' } + ] + }) + const tx = makeUpdateTx({ message: makeMarkup({ id: 'p3', grantsAccess: 'true' }) }) + + const res = await ChunterTrigger([tx], control) + + expect(mentionGrantCreates(res)).toHaveLength(0) // p3 already granted + expect(collaboratorRemovals(res)).toEqual(['col-p4']) + }) + + test('a System-authored edit reconciles nothing (System guard)', async () => { + const control = makeControl({ mentionRecords: [{ _id: 'col-p3', collaborator: 'p3-acc' }] }) + const tx = makeUpdateTx({ message: makeMarkup() }, coreDefault.account.System) + + const res = await ChunterTrigger([tx], control) + + expect(collaboratorCreates(res)).toHaveLength(0) + expect(collaboratorRemovals(res)).toHaveLength(0) }) test('a non-message update (no operations.message) is a no-op', async () => { - const control = makeControl() + const control = makeControl({ mentionRecords: [{ _id: 'col-p3', collaborator: 'p3-acc' }] }) const tx = makeUpdateTx({ reactions: 1 }) - const res: Tx[] = await ChunterTrigger([tx], control) + const res = await ChunterTrigger([tx], control) + + expect(collaboratorCreates(res)).toHaveLength(0) + expect(collaboratorRemovals(res)).toHaveLength(0) + }) +}) + +describe('P5.3 revoke on message delete', () => { + test('deleting a message revokes exactly its mention grants', async () => { + const control = makeControl({ mentionRecords: [{ _id: 'col-p3', collaborator: 'p3-acc' }] }) + + const res = await OnChatMessageRemoved([makeRemoveTx()], control) + + expect(collaboratorRemovals(res)).toEqual(['col-p3']) + // The Collaborator query is scoped to this message's mention provenance. + const collabCall = control.findAll.mock.calls.find( + (c: any[]) => c[1] === coreDefault.class.Collaborator && c[2]?.grantedVia === 'mention' + ) + expect(collabCall?.[2]).toMatchObject({ grantedVia: 'mention', grantedByMessage: 'msg-1' }) + }) + + test('deleting a message with no mention grants removes no collaborators', async () => { + const control = makeControl({ mentionRecords: [] }) + + const res = await OnChatMessageRemoved([makeRemoveTx()], control) - expect(collaboratorGrants(res)).toHaveLength(0) + expect(collaboratorRemovals(res)).toHaveLength(0) }) }) diff --git a/server-plugins/chunter-resources/src/index.ts b/server-plugins/chunter-resources/src/index.ts index ee8d622c70f..6994aa05244 100644 --- a/server-plugins/chunter-resources/src/index.ts +++ b/server-plugins/chunter-resources/src/index.ts @@ -29,6 +29,7 @@ import core, { notEmpty, PersonId, Ref, + Space, Timestamp, Tx, TxCreateDoc, @@ -41,6 +42,7 @@ import core, { type MeasureContext, type Collaborator } from '@hcengineering/core' +import { computeMentionGrantDelta, type ExistingMentionGrant } from './mentionGrantsDelta' import notification, { DocNotifyContext, NotificationContent } from '@hcengineering/notification' import { getMetadata, IntlString, translate } from '@hcengineering/platform' import { getAccountBySocialId, getPerson } from '@hcengineering/server-contact' @@ -187,15 +189,34 @@ async function OnChatMessageCreated (ctx: MeasureContext, tx: TxCUD, contro const account = await getAccountBySocialId(control, message.modifiedBy) const node = markupToJSON(message.message) const references = extractReferences(node) - const mentionedPersons = references - .filter(({ objectClass }) => control.hierarchy.isDerived(objectClass, contact.class.Person)) - .filter(({ grantsAccess }) => grantsAccess !== 'false') // V3c: skip explicitly-denied mentions + const personRefs = references.filter(({ objectClass }) => + control.hierarchy.isDerived(objectClass, contact.class.Person) + ) + // Notification fan-out list (Channels/DMs): everyone not explicitly denied. + // This is deliberately NOT the grant list — a channel mention must keep + // notifying people WITHOUT granting document access (M-G.2 separation). + const mentionedForNotify = personRefs + .filter(({ grantsAccess }) => grantsAccess !== 'false') .map(({ objectId }) => objectId as Ref) + // Grant list (fail-closed, M-G.2): ONLY mentions the author explicitly + // consented to via the send-time disclosure (grantsAccess === 'true'). A + // missing/unknown flag grants nothing. + const mentionedForGrant = new Set( + personRefs + .filter(({ grantsAccess }) => grantsAccess === 'true') + .map(({ objectId }) => objectId as Ref) + ) const employees = - mentionedPersons.length > 0 - ? await control.findAll(ctx, contact.mixin.Employee, { _id: { $in: mentionedPersons as Ref[] } }) + mentionedForNotify.length > 0 + ? await control.findAll(ctx, contact.mixin.Employee, { _id: { $in: mentionedForNotify as Ref[] } }) : [] - const collaboratorsFromMessage = [...employees.map((it) => it.personUuid), account].filter(notEmpty) + // Author is added as a structural (provenance-free) subscriber, exactly as + // before — participation subscription, never auto-revoked. + const notifyAccounts = [...employees.map((it) => it.personUuid), account].filter(notEmpty) + const grantAccounts = employees + .filter((e) => mentionedForGrant.has(e._id as Ref)) + .map((e) => e.personUuid) + .filter(notEmpty) let currentCollaborators = ( await control.findAll(ctx, core.class.Collaborator, { attachedTo: targetDoc._id @@ -228,21 +249,49 @@ async function OnChatMessageCreated (ctx: MeasureContext, tx: TxCUD, contro const isProtectedTarget = targetClassCollab?.provideSecurity === true if (grantTarget != null) { - const grantCollabs = ( - await control.findAll(control.ctx, core.class.Collaborator, { - attachedTo: grantTarget._id - }) - ).map((c) => c.collaborator) - - for (const collab of collaboratorsFromMessage) { - if (grantCollabs.includes(collab)) { - continue - } + const grantTargetCollabs = await control.findAll(control.ctx, core.class.Collaborator, { + attachedTo: grantTarget._id + }) + const allCollabAccounts = new Set(grantTargetCollabs.map((c) => c.collaborator)) + + // Author-membership gate (M-G.3): only a member of the grant-target space + // may spread access through a mention. A collab-only guest (who was granted + // access themselves) must NOT be able to transitively re-grant to others. + const grantSpace = ( + await control.findAll(control.ctx, core.class.Space, { _id: grantTarget.space }, { limit: 1 }) + )[0] + const authorIsMember = account != null && grantSpace?.members?.includes(account) + + // Dedup basis is now per (collaborator, grantedVia:'mention', grantedByMessage) + // — NOT global. A person who is a structural/manual collaborator still gets a + // mention-read record so that removing the mention revokes exactly that record. + const existingMention: ExistingMentionGrant[] = grantTargetCollabs + .filter((c) => c.grantedVia === 'mention' && c.grantedByMessage === message._id) + .map((c) => ({ _id: c._id, _class: c._class, space: c.space, collaborator: c.collaborator })) + const desired = authorIsMember ? grantAccounts : [] + const { toCreate } = computeMentionGrantDelta(desired, existingMention) + for (const collab of toCreate) { res.push( control.txFactory.createTxCreateDoc(core.class.Collaborator, grantTarget.space, { attachedTo: grantTarget._id, attachedToClass: grantTarget._class, collaborator: collab, + collection: 'collaborators', + grantedVia: 'mention', + grantedBy: account ?? undefined, + grantedByMessage: message._id, + level: 'read' + }) + ) + } + + // Author subscription (structural, provenance-free) — preserves prior behavior. + if (account != null && !allCollabAccounts.has(account)) { + res.push( + control.txFactory.createTxCreateDoc(core.class.Collaborator, grantTarget.space, { + attachedTo: grantTarget._id, + attachedToClass: grantTarget._class, + collaborator: account, collection: 'collaborators' }) ) @@ -251,7 +300,7 @@ async function OnChatMessageCreated (ctx: MeasureContext, tx: TxCUD, contro // Legacy notification-routing path: targetDoc is not provideSecurity, // so Collaborator records here are purely for notification fan-out // (today's behavior for Channels, DirectMessages, etc.). - for (const collab of collaboratorsFromMessage) { + for (const collab of notifyAccounts) { if (currentCollaborators.includes(collab)) { continue } @@ -275,9 +324,18 @@ async function OnChatMessageCreated (ctx: MeasureContext, tx: TxCUD, contro return res } -// V3d: self-contained grant helper used ONLY by OnChatMessageUpdated. -// Do NOT refactor OnChatMessageCreated to call this — the create path is live-tested -// and must remain provably unchanged. The ~25-line overlap is intentional. +// P5 (M-G.4a): self-contained mention-grant RECONCILER used ONLY by +// OnChatMessageUpdated. Do NOT refactor OnChatMessageCreated to call this — +// the create path is live-tested and follows its own (structural-author + +// grant) shape. Both paths share only the small pure `computeMentionGrantDelta` +// helper, which is unit-tested in isolation. +// +// Unlike the old add-only V3d version, this diffs the CURRENTLY mentioned + +// consented people against the mention records THIS message already seeded: +// - a mention added on edit -> new grantedVia:'mention' record +// - a mention removed on edit -> TxRemoveDoc of exactly that record +// Only records with grantedByMessage === message._id are ever touched; +// structural and other-message/other-provenance records are left untouched. async function applyMentionGrants (ctx: MeasureContext, message: ChatMessage, control: TriggerControl): Promise { if (message.modifiedBy === core.account.System) return [] const mixin = getClassCollaborators(control.modelDb, control.hierarchy, message.attachedToClass) @@ -286,43 +344,63 @@ async function applyMentionGrants (ctx: MeasureContext, message: ChatMessage, co const targetDoc = (await control.findAll(ctx, message.attachedToClass, { _id: message.attachedTo }, { limit: 1 }))[0] if (targetDoc === undefined) return [] + const grantTarget = await resolveMentionGrantTarget(targetDoc, (cls, q) => control.findAll(control.ctx, cls, q)) + if (grantTarget == null) return [] // grants only apply to opted-in (protected) targets + + // Full revocation scope: the mention records THIS message seeded on the target. + const existingMention: ExistingMentionGrant[] = ( + await control.findAll(control.ctx, core.class.Collaborator, { + attachedTo: grantTarget._id, + grantedVia: 'mention', + grantedByMessage: message._id + }) + ).map((c) => ({ _id: c._id, _class: c._class, space: c.space, collaborator: c.collaborator })) + const node = markupToJSON(message.message) const references = extractReferences(node) - const mentionedPersons = references - .filter(({ objectClass }) => control.hierarchy.isDerived(objectClass, contact.class.Person)) - .filter(({ grantsAccess }) => grantsAccess !== 'false') // V3c - .map(({ objectId }) => objectId as Ref) - // V3d is "a newly-added mention grants access". With no granting mention there - // is nothing to do — return early so a plain text edit never re-runs grant - // machinery, and the author is NOT re-added as a collaborator on every edit. - if (mentionedPersons.length === 0) return [] - const employees = await control.findAll(ctx, contact.mixin.Employee, { - _id: { $in: mentionedPersons as Ref[] } - }) - // Update path grants ONLY the mentioned employees (not the author — the - // author was already added at create time; the create path is unchanged). - const collaboratorsFromMessage = employees.map((it) => it.personUuid).filter(notEmpty) - if (collaboratorsFromMessage.length === 0) return [] + const mentionedForGrant = new Set( + references + .filter(({ objectClass }) => control.hierarchy.isDerived(objectClass, contact.class.Person)) + .filter(({ grantsAccess }) => grantsAccess === 'true') // fail-closed consent (M-G.2) + .map(({ objectId }) => objectId as Ref) + ) - const grantTarget = await resolveMentionGrantTarget(targetDoc, (cls, q) => control.findAll(control.ctx, cls, q)) - if (grantTarget == null) return [] // update-grant only applies to opted-in (protected) targets + // Author-membership gate (M-G.3), evaluated against the EDIT actor. A + // non-member editor cannot spread access (desired stays empty → any prior + // mention grants from this message are reconciled away). + const account = await getAccountBySocialId(control, message.modifiedBy) + const grantSpace = ( + await control.findAll(control.ctx, core.class.Space, { _id: grantTarget.space }, { limit: 1 }) + )[0] + const authorIsMember = account != null && grantSpace?.members?.includes(account) - const grantCollabs = ( - await control.findAll(control.ctx, core.class.Collaborator, { attachedTo: grantTarget._id }) - ).map((c) => c.collaborator) + let desired: AccountUuid[] = [] + if (authorIsMember && mentionedForGrant.size > 0) { + const employees = await control.findAll(ctx, contact.mixin.Employee, { + _id: { $in: Array.from(mentionedForGrant) as Ref[] } + }) + desired = employees.map((it) => it.personUuid).filter(notEmpty) + } + const { toCreate, toRemove } = computeMentionGrantDelta(desired, existingMention) const res: Tx[] = [] - for (const collab of collaboratorsFromMessage) { - if (grantCollabs.includes(collab)) continue // add-only: skip existing + for (const collab of toCreate) { res.push( control.txFactory.createTxCreateDoc(core.class.Collaborator, grantTarget.space, { attachedTo: grantTarget._id, attachedToClass: grantTarget._class, collaborator: collab, - collection: 'collaborators' + collection: 'collaborators', + grantedVia: 'mention', + grantedBy: account ?? undefined, + grantedByMessage: message._id, + level: 'read' }) ) } + for (const record of toRemove) { + res.push(control.txFactory.createTxRemoveDoc(record._class, record.space, record._id)) + } return res } @@ -339,8 +417,8 @@ async function OnChatMessageUpdated (ctx: MeasureContext, tx: TxCUD, contro // Apply the update to the stored doc so the message text AND the actor // (modifiedBy) reflect THIS edit — not the original author. applyMentionGrants // guards on message.modifiedBy === System, so it must see the edit actor. - // Add-only: we grant for all currently-mentioned people; existing grants dedup - // to no-ops, and we never remove (Collaborator has no provenance to remove by). + // Reconcile: currently-mentioned+consented people are (re)granted, mentions + // removed on this edit have their grantedVia:'mention' record revoked (M-G.4a). const message = TxProcessor.updateDoc2Doc({ ...current }, actualTx) return await applyMentionGrants(ctx, message, control) } @@ -493,7 +571,7 @@ export async function getChunterNotificationContent ( } } -async function OnChatMessageRemoved (txes: TxCUD[], control: TriggerControl): Promise { +export async function OnChatMessageRemoved (txes: TxCUD[], control: TriggerControl): Promise { const res: Tx[] = [] for (const tx of txes) { if (tx._class !== core.class.TxRemoveDoc) { @@ -507,6 +585,18 @@ async function OnChatMessageRemoved (txes: TxCUD[], control: Trigge notifications.forEach((notification) => { res.push(control.txFactory.createTxRemoveDoc(notification._class, notification.space, notification._id)) }) + + // M-G.4(b): revoke the mention grants this deleted message seeded. Only + // grantedVia:'mention' records with this grantedByMessage are torn down; + // structural, manual and group records — and mention grants from OTHER + // messages — are left untouched. + const staleGrants = await control.findAll(control.ctx, core.class.Collaborator, { + grantedVia: 'mention', + grantedByMessage: tx.objectId + }) + staleGrants.forEach((grant) => { + res.push(control.txFactory.createTxRemoveDoc(grant._class, grant.space, grant._id)) + }) } return res } From 93f1d6e9c54ea1b210b68d22976a89f01bca4f33 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Wed, 8 Jul 2026 15:42:33 +0000 Subject: [PATCH 51/65] feat(chunter): fail-closed mention consent UI + inline grant warning (P5.2/M-G.5) - MentionGrantConfirm defaults every grantee row to unchecked; the disclosure text now states nobody is granted unless checked and that access is revocable from the item's Access panel. - ChatMessageInput shows a dezent inline warning while typing whenever a non-denied person mention lands on a grant-target document, before the authoritative confirm dialog. - client test: fail-closed default (all unchecked) yields no grantsAccess='true' references. Signed-off-by: Michael Uray --- .../src/__tests__/mentionGrants.test.ts | 15 ++++++ .../chat-message/ChatMessageInput.svelte | 54 ++++++++++++++++++- .../chat-message/MentionGrantConfirm.svelte | 12 +++-- 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/plugins/chunter-resources/src/__tests__/mentionGrants.test.ts b/plugins/chunter-resources/src/__tests__/mentionGrants.test.ts index bbf5f5eab9f..f2cf32007ad 100644 --- a/plugins/chunter-resources/src/__tests__/mentionGrants.test.ts +++ b/plugins/chunter-resources/src/__tests__/mentionGrants.test.ts @@ -47,4 +47,19 @@ describe('applyMentionGrantChoices', () => { 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 cfd1fc88672..e8275770eaa 100644 --- a/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte +++ b/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte @@ -34,7 +34,8 @@ import { extractReferences } from '@hcengineering/text-core' import { createEventDispatcher } from 'svelte' import { getObjectId } from '@hcengineering/view-resources' - import { showPopup, ThrottledCaller } from '@hcengineering/ui' + import { showPopup, ThrottledCaller, Label } from '@hcengineering/ui' + import { getEmbeddedLabel } from '@hcengineering/platform' import { getSpace, editingMessageStore } from '@hcengineering/activity-resources' import { getChannelSpace } from '../../utils' @@ -140,6 +141,35 @@ currentMessage.attachments = attachments } + // 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 @@ -344,6 +374,28 @@ 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 index fa5e29a8fd1..14461b5821f 100644 --- a/plugins/chunter-resources/src/components/chat-message/MentionGrantConfirm.svelte +++ b/plugins/chunter-resources/src/components/chat-message/MentionGrantConfirm.svelte @@ -17,10 +17,12 @@ const dispatch = createEventDispatcher() - // Default: grant everyone (preserves pre-V3 behaviour). A plain array with - // per-row `granted` so `bind:checked` stays reactive (mutating a Map would - // not re-render the CheckBox in Svelte 4). - const rows = grantees.map((g) => ({ id: g.id, name: g.name, granted: true })) + // Fail-closed default (M-G.2/M-G.5): grant NOBODY unless the author actively + // checks them. Combined with the server-side `grantsAccess === 'true'` gate, + // "nothing checked" means "no access granted". A plain array with per-row + // `granted` so `bind:checked` stays reactive (mutating a Map would not + // re-render the CheckBox in Svelte 4). + const rows = grantees.map((g) => ({ id: g.id, name: g.name, granted: false })) function onCancel (): void { dispatch('close', undefined) // undefined => caller treats as cancel @@ -39,7 +41,7 @@
From d3b06f3b15dfd1c039215beea0de3ac24b3c1e54 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Wed, 8 Jul 2026 16:20:23 +0000 Subject: [PATCH 52/65] fix(middleware): C-01 cross-space privilege escalation in collaborator guard Die Grant-Autorisierung pruefte gegen die client-gewaehlte objectSpace des Collaborator/GroupGrant-Records, waehrend die Sichtbarkeitswirkung allein am attachedTo-Doc haengt (Postgres addSecurity keyed collab_sec.attachedTo = domain._id, ohne space-Abgleich). Ein regulaerer User konnte damit einen Admin-Grant auf ein Issue in einem fremden privaten Space schreiben, indem er den Record in einen eigenen Space legte. Fix: neuer fail-closed Helper resolveTargetSpace laedt das attachedTo-Doc, erzwingt objectSpace === doc.space und autorisiert gegen den ECHTEN Space des Docs (checkCreate + checkGroupGrantTx). Nicht ladbar / Space-Mismatch -> reject. Tests: 3 C-01-Repro-Faelle (manual, group, fail-closed unresolvable), 94/94 gruen. Signed-off-by: Michael Uray --- .../middleware/src/collaboratorGuard.ts | 40 ++++++++++++++++++- .../src/tests/collaboratorGuard.test.ts | 40 +++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/foundations/server/packages/middleware/src/collaboratorGuard.ts b/foundations/server/packages/middleware/src/collaboratorGuard.ts index c9636f0c62b..d413c675c4b 100644 --- a/foundations/server/packages/middleware/src/collaboratorGuard.ts +++ b/foundations/server/packages/middleware/src/collaboratorGuard.ts @@ -183,7 +183,14 @@ export class CollaboratorGuardMiddleware extends BaseMiddleware implements Middl if (attrs.level !== undefined && !VALID_LEVELS.has(attrs.level)) throw forbidden() const attachedTo = createTx.attachedTo ?? (createTx.attributes as any)?.attachedTo - const authorized = await this.canGrant(ctx, createTx.objectSpace, attachedTo, account) + 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() } @@ -236,7 +243,11 @@ export class CollaboratorGuardMiddleware extends BaseMiddleware implements Middl // 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 - if (!(await this.canGrantGroup(ctx, createTx.objectSpace, attachedTo, account))) throw forbidden() + // 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 } @@ -379,4 +390,29 @@ export class CollaboratorGuardMiddleware extends BaseMiddleware implements Middl 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/tests/collaboratorGuard.test.ts b/foundations/server/packages/middleware/src/tests/collaboratorGuard.test.ts index 3e06af1bb1c..f8b599855a9 100644 --- a/foundations/server/packages/middleware/src/tests/collaboratorGuard.test.ts +++ b/foundations/server/packages/middleware/src/tests/collaboratorGuard.test.ts @@ -121,6 +121,9 @@ function makeSpace (members: AccountUuid[], owners: AccountUuid[] = []): Space { /** 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[] @@ -129,6 +132,12 @@ function serve (opts: { 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 as any] : [] + } if (_class === core.class.Collaborator) { let list = opts.collaborators ?? [] if (query?._id !== undefined) list = list.filter((c) => c._id === query._id) @@ -352,6 +361,37 @@ describe('CollaboratorGuardMiddleware', () => { 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) From 119a4243ada630a5d3deb80094e65856a3b45e4d Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Wed, 8 Jul 2026 16:24:54 +0000 Subject: [PATCH 53/65] fix(middleware): M-01 scope collab-read widening to grant classes only Die P2.2-Rollen-Ausweitung des collabReadBypass feuerte fuer jede provideSecurity-Klasse. love und controlled-documents (QMS) setzen provideSecurity ohne mentionsGrantAccess -> ein regulaerer Member, der dort nur strukturell Collaborator (createdBy/assignee) ist, erhielt neue Cross-Space-Read-Sichtbarkeit ausserhalb des Feature-Ziels. Fix: die Ausweitung auf Member-Rollen nur fuer mentionsGrantAccess-Klassen (= Write-Veto-Scope); die urspruengliche Guest/ReadOnlyGuest-Sichtbarkeit bleibt fuer alle provideSecurity-Klassen erhalten. Tests: +2 (User ohne mentionsGrantAccess behaelt Space-Filter; Guest behaelt Bypass), 96/96 gruen. Signed-off-by: Michael Uray --- .../packages/middleware/src/spaceSecurity.ts | 12 +++++- .../src/tests/collabSpaceSecurity.test.ts | 41 +++++++++++++++++-- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/foundations/server/packages/middleware/src/spaceSecurity.ts b/foundations/server/packages/middleware/src/spaceSecurity.ts index 1a5dda17f81..67b8d5555d7 100644 --- a/foundations/server/packages/middleware/src/spaceSecurity.ts +++ b/foundations/server/packages/middleware/src/spaceSecurity.ts @@ -673,11 +673,21 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar // 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 + 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" diff --git a/foundations/server/packages/middleware/src/tests/collabSpaceSecurity.test.ts b/foundations/server/packages/middleware/src/tests/collabSpaceSecurity.test.ts index 0ee7b342780..27be2b1fa58 100644 --- a/foundations/server/packages/middleware/src/tests/collabSpaceSecurity.test.ts +++ b/foundations/server/packages/middleware/src/tests/collabSpaceSecurity.test.ts @@ -101,12 +101,18 @@ 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 } as any] : [] + const collabConfig = + opts.provideSecurity === true + ? [{ attachedTo: ISSUE_CLASS, provideSecurity: true, mentionsGrantAccess: opts.mentionsGrantAccess === true } as any] + : [] const hierarchy = { getDomain: (_class: Ref>): Domain => { @@ -250,7 +256,7 @@ describe('SpaceSecurityMiddleware - collab-read backend guard (H5)', () => { // 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 }) + 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 @@ -261,7 +267,7 @@ describe('SpaceSecurityMiddleware - collab-read backend guard (H5)', () => { }) it('role Maintainer with backend support: drops the space filter', async () => { - const { mw, captured } = makeMiddleware({ backendSupportsCollab: true, provideSecurity: true }) + const { mw, captured } = makeMiddleware({ backendSupportsCollab: true, provideSecurity: true, mentionsGrantAccess: true }) const account = makeAccount(AccountRole.Maintainer) ;(mw as any).allowedSpaces = { [account.uuid]: [S1] } @@ -270,8 +276,35 @@ describe('SpaceSecurityMiddleware - collab-read backend guard (H5)', () => { 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 }) + const { mw, captured } = makeMiddleware({ backendSupportsCollab: false, provideSecurity: true, mentionsGrantAccess: true }) const account = makeAccount(AccountRole.User) ;(mw as any).allowedSpaces = { [account.uuid]: [S1] } From 96bc4ab81212c7e70534eb3b8643aba70245651b Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Wed, 8 Jul 2026 16:29:51 +0000 Subject: [PATCH 54/65] fix(access-group): M-02 teardown group-collaborators on AccessGroup delete OnAccessGroupChanged behandelte nur TxUpdateDoc. Ein System-/Migrations-Delete einer AccessGroup (der die CollaboratorGuard-Blockade umgeht) liess die materialisierten grantedVia:'group'-Collaborators als Waisen zurueck -> Zugriff blieb bestehen. Fix: TxRemoveDoc-Zweig raeumt alle GroupGrants der Gruppe + deren abgeleitete group-Collaborators ab (kein verwaister Zugriff). Zusaetzlich: 6 vorbestehende tsc-Fehler in der P4-Trigger-Testdatei behoben (inline Tx-Mock-Literale ohne Pflichtfelder -> : any), Paket kompiliert jetzt sauber mit tsc. Tests 20/20 (inkl. M-02 Delete-Teardown). Signed-off-by: Michael Uray --- .../src/__tests__/groupGrantTrigger.test.ts | 43 ++++++++++++++++--- .../access-group-resources/src/index.ts | 31 +++++++++---- 2 files changed, 60 insertions(+), 14 deletions(-) diff --git a/server-plugins/access-group-resources/src/__tests__/groupGrantTrigger.test.ts b/server-plugins/access-group-resources/src/__tests__/groupGrantTrigger.test.ts index 53b0213b381..0edbf0368db 100644 --- a/server-plugins/access-group-resources/src/__tests__/groupGrantTrigger.test.ts +++ b/server-plugins/access-group-resources/src/__tests__/groupGrantTrigger.test.ts @@ -149,7 +149,7 @@ describe('OnGroupGrantChanged', () => { collaborators: [collab({ collaborator: 'a', level: 'read' })] } const control = makeControl(store) - const upd = { + const upd: any = { _class: core.class.TxUpdateDoc, objectClass: core.class.GroupGrant, objectId: 'grant-1', @@ -173,7 +173,7 @@ describe('OnGroupGrantChanged', () => { ] } const control = makeControl(store) - const rm = { + const rm: any = { _class: core.class.TxRemoveDoc, objectClass: core.class.GroupGrant, objectId: 'grant-1', @@ -195,7 +195,7 @@ describe('OnGroupGrantChanged', () => { ] } const control = makeControl(store) - const rm = { + const rm: any = { _class: core.class.TxRemoveDoc, objectClass: core.class.GroupGrant, objectId: 'grant-1', @@ -222,7 +222,7 @@ describe('OnAccessGroupChanged', () => { ] } const control = makeControl(store) - const upd = { + const upd: any = { _class: core.class.TxUpdateDoc, objectClass: core.class.AccessGroup, objectId: 'group-1', @@ -247,7 +247,7 @@ describe('OnAccessGroupChanged', () => { ] } const control = makeControl(store) - const upd = { + const upd: any = { _class: core.class.TxUpdateDoc, objectClass: core.class.AccessGroup, objectId: 'group-1', @@ -267,7 +267,7 @@ describe('OnAccessGroupChanged', () => { collaborators: [collab({ collaborator: 'a', _id: 'g-a' })] } const control = makeControl(store) - const upd = { + const upd: any = { _class: core.class.TxUpdateDoc, objectClass: core.class.AccessGroup, objectId: 'group-1', @@ -277,4 +277,35 @@ describe('OnAccessGroupChanged', () => { const res = await OnAccessGroupChanged([upd], control) expect(res).toEqual([]) }) + + it('M-02: AccessGroup remove tears down every GroupGrant and its group-collaborators (no orphans)', async () => { + const store: Store = { + groups: [], // group already deleted + grants: [ + grant({ _id: 'grant-1', attachedTo: 'issue-1' }), + grant({ _id: 'grant-2', attachedTo: 'issue-2' }) + ], + collaborators: [ + collab({ collaborator: 'a', _id: 'g-a1', grantedByGroup: 'grant-1', attachedTo: 'issue-1' }), + collab({ collaborator: 'b', _id: 'g-b1', grantedByGroup: 'grant-1', attachedTo: 'issue-1' }), + collab({ collaborator: 'a', _id: 'g-a2', grantedByGroup: 'grant-2', attachedTo: 'issue-2' }) + ] + } + const control = makeControl(store) + const rm: any = { + _class: core.class.TxRemoveDoc, + objectClass: core.class.AccessGroup, + objectId: 'group-1', + objectSpace: 'space-1' + } + const res = await OnAccessGroupChanged([rm], control) + // all three materialized collaborators torn down + expect(removedIds(res).sort()).toEqual(['g-a1', 'g-a2', 'g-b1']) + // both now-orphan grants removed too + const grantRemoves = res + .filter((t) => t._class === core.class.TxRemoveDoc && (t as any).objectClass === core.class.GroupGrant) + .map((t) => (t as any).objectId) + .sort() + expect(grantRemoves).toEqual(['grant-1', 'grant-2']) + }) }) diff --git a/server-plugins/access-group-resources/src/index.ts b/server-plugins/access-group-resources/src/index.ts index e69e5710c9f..4abcdcac7b6 100644 --- a/server-plugins/access-group-resources/src/index.ts +++ b/server-plugins/access-group-resources/src/index.ts @@ -136,15 +136,30 @@ function touchesMembers (operations: Record): boolean { export async function OnAccessGroupChanged (txes: Tx[], control: TriggerControl): Promise { const result: Tx[] = [] for (const tx of txes) { - if (tx._class !== core.class.TxUpdateDoc) continue - const upd = tx as TxUpdateDoc - if (!control.hierarchy.isDerived(upd.objectClass, core.class.AccessGroup)) continue - if (!touchesMembers(upd.operations as Record)) continue + if (!control.hierarchy.isDerived((tx as any).objectClass, core.class.AccessGroup)) continue - const members = await groupMembers(control, upd.objectId) - const grants = await control.findAll(control.ctx, core.class.GroupGrant, { group: upd.objectId }) - for (const grant of grants) { - result.push(...reconcileTxes(control, grant, members, await existingForGrant(control, grant))) + if (tx._class === core.class.TxUpdateDoc) { + const upd = tx as TxUpdateDoc + if (!touchesMembers(upd.operations as Record)) continue + const members = await groupMembers(control, upd.objectId) + const grants = await control.findAll(control.ctx, core.class.GroupGrant, { group: upd.objectId }) + for (const grant of grants) { + result.push(...reconcileTxes(control, grant, members, await existingForGrant(control, grant))) + } + } else if (tx._class === core.class.TxRemoveDoc) { + // M-02: the group is gone. Tear down every GroupGrant it owns and all the + // group-provenance collaborators those grants materialized, so no access + // survives an orphaned group. The CollaboratorGuardMiddleware blocks a client + // delete while grants live; this covers system / migration deletes that + // bypass the guard. + const rm = tx as TxRemoveDoc + const grants = await control.findAll(control.ctx, core.class.GroupGrant, { group: rm.objectId }) + for (const grant of grants) { + for (const c of await existingForGrant(control, grant)) { + result.push(control.txFactory.createTxRemoveDoc(core.class.Collaborator, c.space, c._id)) + } + result.push(control.txFactory.createTxRemoveDoc(core.class.GroupGrant, grant.space, grant._id)) + } } } return result From cdab027cfe9c45ff319a4598a21779aa5d4c0669 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Wed, 8 Jul 2026 16:30:54 +0000 Subject: [PATCH 55/65] fix(chunter-resources): H-02 wire up jest so the P5 client test runs in CI Das Paket hatte jest + jest.config.js, aber kein test/_phase:test-Script -> der P5-Client-Test mentionGrants.test.ts (inkl. fail-closed Consent-Default-Assertion) lief nie in rush test/CI (Scheinsicherheit). Scripts nach setting-resources-Muster ergaenzt; Test laeuft jetzt (6/6). Signed-off-by: Michael Uray --- plugins/chunter-resources/package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/chunter-resources/package.json b/plugins/chunter-resources/package.json index ff2c420eb37..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": { From 23cc06a2fb747a874ec0069bf8ef4cd5d4bd3b18 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Wed, 8 Jul 2026 16:33:55 +0000 Subject: [PATCH 56/65] fix(chunter): L-02 externalize mention-grant hint to IntlString (EN+DE) Der Inline-Grant-Warnhinweis nutzte ein embedded Englisch-Literal (getEmbeddedLabel) und wurde nie uebersetzt. Neue IntlString chunter.string.MentionGrantsAccessHint mit EN+DE; ungenutzten getEmbeddedLabel-Import entfernt. svelte-check + Client-Test (6/6) gruen. Signed-off-by: Michael Uray --- plugins/chunter-assets/lang/de.json | 1 + plugins/chunter-assets/lang/en.json | 1 + .../src/components/chat-message/ChatMessageInput.svelte | 7 +------ plugins/chunter/src/index.ts | 1 + 4 files changed, 4 insertions(+), 6 deletions(-) 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-resources/src/components/chat-message/ChatMessageInput.svelte b/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte index e8275770eaa..a8bc56a3e66 100644 --- a/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte +++ b/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte @@ -35,7 +35,6 @@ import { createEventDispatcher } from 'svelte' import { getObjectId } from '@hcengineering/view-resources' import { showPopup, ThrottledCaller, Label } from '@hcengineering/ui' - import { getEmbeddedLabel } from '@hcengineering/platform' import { getSpace, editingMessageStore } from '@hcengineering/activity-resources' import { getChannelSpace } from '../../utils' @@ -376,11 +375,7 @@ {#if showGrantWarning}
-
{/if} 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, From 0a331b98e5f23e2a50e996420b2d693f18ec5f26 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Thu, 9 Jul 2026 08:26:07 +0000 Subject: [PATCH 57/65] fix(model-core): alias ArrOf model import to avoid duplicate identifier AccessGroup Members/Owners use the ArrOf value helper from @hcengineering/model while TArrOf implements the ArrOf type from @hcengineering/core; importing both as ArrOf collides (TS2300). Alias the model value as ArrOfProp. Surfaced by the full CI type-check; local transpile-only builds did not catch it. Signed-off-by: Michael Uray --- models/core/src/core.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/models/core/src/core.ts b/models/core/src/core.ts index 8c31bcdccca..3f0cb48cf00 100644 --- a/models/core/src/core.ts +++ b/models/core/src/core.ts @@ -69,7 +69,7 @@ import { type VersionableClass } from '@hcengineering/core' import { - ArrOf, + ArrOf as ArrOfProp, Hidden, Index, Mixin as MMixin, @@ -467,10 +467,10 @@ export class TAccessGroup extends TDoc implements AccessGroup { @Prop(TypeString(), core.string.Description) description?: string - @Prop(ArrOf(TypeAccountUuid()), core.string.Members) + @Prop(ArrOfProp(TypeAccountUuid()), core.string.Members) members!: AccountUuid[] - @Prop(ArrOf(TypeAccountUuid()), core.string.Owners) + @Prop(ArrOfProp(TypeAccountUuid()), core.string.Owners) owners!: AccountUuid[] } From ff5e4470213f7607ae3f82ce695000937351e3bd Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Thu, 9 Jul 2026 09:38:20 +0000 Subject: [PATCH 58/65] fix(tracker-assets): i18n locale parity for access-management strings Add the 27 access-management string keys (P3 issue-access panel + P4 settings) to every non-EN/DE locale using the English value as fallback, so the tracker-assets lang.test key-parity check (en vs ru) passes again. DE stays translated; RU now matches EN key-for-key. Signed-off-by: Michael Uray --- plugins/tracker-assets/lang/cs.json | 29 +++++++++++++++++++++++++- plugins/tracker-assets/lang/es.json | 29 +++++++++++++++++++++++++- plugins/tracker-assets/lang/fr.json | 29 +++++++++++++++++++++++++- plugins/tracker-assets/lang/it.json | 29 +++++++++++++++++++++++++- plugins/tracker-assets/lang/ja.json | 29 +++++++++++++++++++++++++- plugins/tracker-assets/lang/ko.json | 29 +++++++++++++++++++++++++- plugins/tracker-assets/lang/pl.json | 29 +++++++++++++++++++++++++- plugins/tracker-assets/lang/pt-br.json | 29 +++++++++++++++++++++++++- plugins/tracker-assets/lang/pt.json | 29 +++++++++++++++++++++++++- plugins/tracker-assets/lang/ru.json | 29 +++++++++++++++++++++++++- plugins/tracker-assets/lang/tr.json | 29 +++++++++++++++++++++++++- plugins/tracker-assets/lang/zh.json | 29 +++++++++++++++++++++++++- 12 files changed, 336 insertions(+), 12 deletions(-) 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/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": {} } From 17c2887075330b10cfe17b87e75d4a6cdd9cbf55 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Thu, 9 Jul 2026 09:38:29 +0000 Subject: [PATCH 59/65] fix(access-management): satisfy formatting gate (prettier + array-sort-compare) Reformat access-management sources to prettier width and add explicit comparators to bare Array.sort() calls in the access-group reconcile tests (@typescript-eslint/require-array-sort-compare), so rush fast-format produces no diff and the CI formatting gate passes. No behavioural changes. Signed-off-by: Michael Uray --- .../middleware/src/collaboratorGuard.ts | 44 ++++--------- .../middleware/src/guestPermissions.ts | 2 +- .../packages/middleware/src/spaceSecurity.ts | 5 +- .../src/tests/collabSpaceSecurity.test.ts | 38 ++++++++++-- .../src/tests/collaboratorGuard.test.ts | 61 +++++++++++-------- .../postgres/src/__tests__/utils.spec.ts | 8 +-- .../src/components/AccessGroups.svelte | 9 +-- .../issues/edit/IssueAccessPanel.svelte | 2 +- .../issues/edit/SelectAccessGroupPopup.svelte | 2 +- .../src/__tests__/groupGrantTrigger.test.ts | 16 ++--- .../src/__tests__/reconcile.test.ts | 6 +- .../access-group-resources/src/index.ts | 13 +++- .../__tests__/mentionGrantsTrigger.test.ts | 6 +- server-plugins/chunter-resources/src/index.ts | 4 +- .../src/mentionGrantsDelta.ts | 5 +- 15 files changed, 112 insertions(+), 109 deletions(-) diff --git a/foundations/server/packages/middleware/src/collaboratorGuard.ts b/foundations/server/packages/middleware/src/collaboratorGuard.ts index d413c675c4b..462546f000f 100644 --- a/foundations/server/packages/middleware/src/collaboratorGuard.ts +++ b/foundations/server/packages/middleware/src/collaboratorGuard.ts @@ -194,11 +194,7 @@ export class CollaboratorGuardMiddleware extends BaseMiddleware implements Middl if (!authorized) throw forbidden() } - private async checkRemove ( - ctx: MeasureContext, - record: Collaborator, - account: Account - ): Promise { + 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 @@ -230,11 +226,7 @@ export class CollaboratorGuardMiddleware extends BaseMiddleware implements Middl * `level` (with a valid value). Any other field update, or a TxMixin, is * rejected. */ - private async checkGroupGrantTx ( - ctx: MeasureContext, - cud: TxCUD, - account: Account - ): Promise { + 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 @@ -253,12 +245,7 @@ export class CollaboratorGuardMiddleware extends BaseMiddleware implements Middl // Remove / update / mixin: resolve the existing grant. const existing = ( - await this.findAll( - ctx, - core.class.GroupGrant, - { _id: cud.objectId as Ref }, - { limit: 1 } - ) + 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() @@ -294,11 +281,7 @@ export class CollaboratorGuardMiddleware extends BaseMiddleware implements Middl * - 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 { + 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 @@ -308,23 +291,22 @@ export class CollaboratorGuardMiddleware extends BaseMiddleware implements Middl } const existing = ( - await this.findAll(ctx, core.class.AccessGroup, { _id: cud.objectId as Ref }, { limit: 1 }) + 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) === true || hasAccountRole(account, AccountRole.Maintainer) + 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 } - ) + const grants = await this.findAll(ctx, core.class.GroupGrant, { group: existing._id }, { limit: 1 }) if (grants.length > 0) throw forbidden() - return } // TxUpdateDoc / TxMixin by an owner: allowed. } @@ -368,7 +350,7 @@ export class CollaboratorGuardMiddleware extends BaseMiddleware implements Middl 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) === true) return true + if (hasAccountRole(account, AccountRole.User) && spaceDoc.members?.includes(account.uuid)) return true return await this.hasAdminGrant(ctx, attachedTo, account) } diff --git a/foundations/server/packages/middleware/src/guestPermissions.ts b/foundations/server/packages/middleware/src/guestPermissions.ts index e5138e7ca52..c9baa8ff6f8 100644 --- a/foundations/server/packages/middleware/src/guestPermissions.ts +++ b/foundations/server/packages/middleware/src/guestPermissions.ts @@ -218,7 +218,7 @@ export class GuestPermissionsMiddleware extends BaseMiddleware implements Middle // 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 === false) return false + if (!space.private) return false } // Collab-only caller. Field updates require level >= write; removes stay diff --git a/foundations/server/packages/middleware/src/spaceSecurity.ts b/foundations/server/packages/middleware/src/spaceSecurity.ts index 67b8d5555d7..1ffb0ba0828 100644 --- a/foundations/server/packages/middleware/src/spaceSecurity.ts +++ b/foundations/server/packages/middleware/src/spaceSecurity.ts @@ -702,10 +702,7 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar // 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 + collabBackendSupported && isSpace && account.role !== AccountRole.Admin && account.role !== AccountRole.DocGuest if ( !isSystem(account, ctx) && diff --git a/foundations/server/packages/middleware/src/tests/collabSpaceSecurity.test.ts b/foundations/server/packages/middleware/src/tests/collabSpaceSecurity.test.ts index 27be2b1fa58..05366cfdc79 100644 --- a/foundations/server/packages/middleware/src/tests/collabSpaceSecurity.test.ts +++ b/foundations/server/packages/middleware/src/tests/collabSpaceSecurity.test.ts @@ -111,7 +111,13 @@ function makeMiddleware (opts: { const collabConfig = opts.provideSecurity === true - ? [{ attachedTo: ISSUE_CLASS, provideSecurity: true, mentionsGrantAccess: opts.mentionsGrantAccess === true } as any] + ? [ + { + attachedTo: ISSUE_CLASS, + provideSecurity: true, + mentionsGrantAccess: opts.mentionsGrantAccess === true + } as any + ] : [] const hierarchy = { @@ -256,7 +262,11 @@ describe('SpaceSecurityMiddleware - collab-read backend guard (H5)', () => { // 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 { 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 @@ -267,7 +277,11 @@ describe('SpaceSecurityMiddleware - collab-read backend guard (H5)', () => { }) it('role Maintainer with backend support: drops the space filter', async () => { - const { mw, captured } = makeMiddleware({ backendSupportsCollab: true, provideSecurity: true, mentionsGrantAccess: true }) + const { mw, captured } = makeMiddleware({ + backendSupportsCollab: true, + provideSecurity: true, + mentionsGrantAccess: true + }) const account = makeAccount(AccountRole.Maintainer) ;(mw as any).allowedSpaces = { [account.uuid]: [S1] } @@ -280,7 +294,11 @@ describe('SpaceSecurityMiddleware - collab-read backend guard (H5)', () => { // 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 { mw, captured } = makeMiddleware({ + backendSupportsCollab: true, + provideSecurity: true, + mentionsGrantAccess: false + }) const account = makeAccount(AccountRole.User) ;(mw as any).allowedSpaces = { [account.uuid]: [S1] } @@ -294,7 +312,11 @@ describe('SpaceSecurityMiddleware - collab-read backend guard (H5)', () => { 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 { mw, captured } = makeMiddleware({ + backendSupportsCollab: true, + provideSecurity: true, + mentionsGrantAccess: false + }) const account = makeGuest() ;(mw as any).allowedSpaces = { [account.uuid]: [S1] } @@ -304,7 +326,11 @@ describe('SpaceSecurityMiddleware - collab-read backend guard (H5)', () => { }) 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 { mw, captured } = makeMiddleware({ + backendSupportsCollab: false, + provideSecurity: true, + mentionsGrantAccess: true + }) const account = makeAccount(AccountRole.User) ;(mw as any).allowedSpaces = { [account.uuid]: [S1] } diff --git a/foundations/server/packages/middleware/src/tests/collaboratorGuard.test.ts b/foundations/server/packages/middleware/src/tests/collaboratorGuard.test.ts index f8b599855a9..4cfc1afc806 100644 --- a/foundations/server/packages/middleware/src/tests/collaboratorGuard.test.ts +++ b/foundations/server/packages/middleware/src/tests/collaboratorGuard.test.ts @@ -106,9 +106,9 @@ function makeMw ( const next = nextFn !== undefined ? { tx: nextFn } : { tx: async (_c: MeasureContext, _t: Tx[]) => ({}) } const mw = new (CollaboratorGuardMiddleware as any)(context, next) mw.findAll = findAll - ;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => a === b - ;(mw as any).context.hierarchy.getAncestors = (id: any) => [id] - ;(mw as any).context.modelDb = { + ;(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 @@ -136,7 +136,7 @@ function serve (opts: { // 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 as any] : [] + return query?._id === doc._id ? [doc] : [] } if (_class === core.class.Collaborator) { let list = opts.collaborators ?? [] @@ -204,7 +204,7 @@ function makeGroupUpdateTx (account: Account, grantId: Ref, operatio function groupGrantRecord (over: Partial): GroupGrant { return { - _id: (over._id ?? generateId()) as Ref, + _id: (over._id ?? generateId()), _class: core.class.GroupGrant, space: ISSUE_SPACE, attachedTo: ISSUE_ID, @@ -237,7 +237,7 @@ function makeAccessGroupRemoveTx (account: Account, id: Ref): Tx { function accessGroupRecord (over: Partial): AccessGroup { return { - _id: (over._id ?? GROUP_ID) as Ref, + _id: (over._id ?? GROUP_ID), _class: core.class.AccessGroup, space: ISSUE_SPACE, modifiedOn: Date.now(), @@ -251,7 +251,7 @@ function accessGroupRecord (over: Partial): AccessGroup { function collabRecord (over: Partial): Collaborator { return { - _id: (over._id ?? generateId()) as Ref, + _id: (over._id ?? generateId()), _class: core.class.Collaborator, space: ISSUE_SPACE, attachedTo: ISSUE_ID, @@ -323,7 +323,12 @@ describe('CollaboratorGuardMiddleware', () => { nextCalled = true return {} }) - const tx = makeCreateTx(actor, { collaborator: uuid(), grantedVia: 'manual', grantedBy: actor.uuid, level: 'write' }) + const tx = makeCreateTx(actor, { + collaborator: uuid(), + grantedVia: 'manual', + grantedBy: actor.uuid, + level: 'write' + }) await mw.tx(makeCtx(actor), [tx]) expect(nextCalled).toBe(true) }) @@ -356,7 +361,12 @@ describe('CollaboratorGuardMiddleware', () => { nextCalled = true return {} }) - const tx = makeCreateTx(actor, { collaborator: uuid(), grantedVia: 'manual', grantedBy: actor.uuid, level: 'admin' }) + const tx = makeCreateTx(actor, { + collaborator: uuid(), + grantedVia: 'manual', + grantedBy: actor.uuid, + level: 'admin' + }) await mw.tx(makeCtx(actor), [tx]) expect(nextCalled).toBe(true) }) @@ -372,7 +382,12 @@ describe('CollaboratorGuardMiddleware', () => { // 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' }) + const tx = makeCreateTx(actor, { + collaborator: uuid(), + grantedVia: 'manual', + grantedBy: actor.uuid, + level: 'admin' + }) await expect(mw.tx(makeCtx(actor), [tx])).rejects.toThrow() }) @@ -387,7 +402,12 @@ describe('CollaboratorGuardMiddleware', () => { 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 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() }) @@ -404,7 +424,7 @@ describe('CollaboratorGuardMiddleware', () => { const tx = makeCreateTx(sys, { collaborator: uuid(), grantedVia: 'group', - grantedByGroup: generateId() as Ref + grantedByGroup: generateId() }) await mw.tx(makeCtx(sys), [tx]) expect(nextCalled).toBe(true) @@ -465,7 +485,7 @@ describe('CollaboratorGuardMiddleware', () => { 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() as Ref)])).rejects.toThrow() + await expect(mw.tx(makeCtx(actor), [makeRemoveTx(actor, generateId())])).rejects.toThrow() }) // ─── update / immutability ──────────────────────────────────────────────────── @@ -485,12 +505,7 @@ describe('CollaboratorGuardMiddleware', () => { nextCalled = true return {} }) - const tx = makeCreateTx( - actor, - { collaborator: uuid() }, - CHANNEL_CLASS, - 'test:doc:Channel1' as Ref - ) + const tx = makeCreateTx(actor, { collaborator: uuid() }, CHANNEL_CLASS, 'test:doc:Channel1' as Ref) await mw.tx(makeCtx(actor), [tx]) expect(nextCalled).toBe(true) }) @@ -572,9 +587,7 @@ describe('CollaboratorGuardMiddleware', () => { 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() + await expect(mw.tx(makeCtx(owner), [makeGroupUpdateTx(owner, rec._id, { level: 'root' })])).rejects.toThrow() }) it('allows GroupGrant remove by workspace Maintainer', async () => { @@ -599,9 +612,7 @@ describe('CollaboratorGuardMiddleware', () => { 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() as Ref)]) - ).rejects.toThrow() + await expect(mw.tx(makeCtx(owner), [makeGroupRemoveTx(owner, generateId())])).rejects.toThrow() }) it('rejects GroupGrant create on a non-secured class (fail-closed)', async () => { diff --git a/foundations/server/packages/postgres/src/__tests__/utils.spec.ts b/foundations/server/packages/postgres/src/__tests__/utils.spec.ts index 25c05a108c9..0ba06731eb4 100644 --- a/foundations/server/packages/postgres/src/__tests__/utils.spec.ts +++ b/foundations/server/packages/postgres/src/__tests__/utils.spec.ts @@ -1,10 +1,4 @@ -import { - DOMAIN_COLLABORATOR, - 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, diff --git a/plugins/setting-resources/src/components/AccessGroups.svelte b/plugins/setting-resources/src/components/AccessGroups.svelte index 97c3f2534fc..54ed58f7424 100644 --- a/plugins/setting-resources/src/components/AccessGroups.svelte +++ b/plugins/setting-resources/src/components/AccessGroups.svelte @@ -13,12 +13,7 @@ // limitations under the License. --> -
+
-
+
(membersExpanded = !membersExpanded)}> @@ -310,7 +313,7 @@
-
+
diff --git a/tests/sanity/tests/model/tracker/issues-details-page.ts b/tests/sanity/tests/model/tracker/issues-details-page.ts index 4e2f1ca7ae2..eefa6721d76 100644 --- a/tests/sanity/tests/model/tracker/issues-details-page.ts +++ b/tests/sanity/tests/model/tracker/issues-details-page.ts @@ -43,8 +43,9 @@ export class IssuesDetailsPage extends CommonTrackerPage { readonly textRelated = (): Locator => this.page.locator('//span[text()="Related"]/following-sibling::div[1]/div//span') - readonly buttonCollaborators = (): Locator => - this.page.locator('//span[text()="Collaborators"]/following-sibling::div[1]/button') + readonly issueAccessPanel = (): Locator => this.page.locator('#issue-access-panel') + readonly issueAccessMembersToggle = (): Locator => this.page.locator('#issue-access-members .subsection-title') + readonly issueAccessAdditional = (): Locator => this.page.locator('#issue-access-additional') readonly buttonIssueOnSearchForIssueModal = (): Locator => this.page.locator('div.popup div.tabs > div.tab:last-child') @@ -186,15 +187,22 @@ export class IssuesDetailsPage extends CommonTrackerPage { } async checkCollaborators (names: Array): Promise { - await this.buttonCollaborators().click() + // The IssueAccessPanel shows project members (collapsed by default) and the + // explicit additional grants (always visible). Expand the members section so a + // project-member collaborator (e.g. the issue author) is rendered, then assert + // each expected name is present somewhere in the panel — members or grants. + await expect(this.issueAccessPanel()).toBeVisible() + await this.issueAccessMembersToggle().click() for (const name of names) { - await expect(this.stateHistoryDropdown(name)).toBeVisible() + await expect(this.issueAccessPanel().getByText(name).first()).toBeVisible() } - await this.inputTitle().click({ force: true }) } - async checkCollaboratorsCount (count: string): Promise { - await expect(this.buttonCollaborators()).toHaveText(count) + async checkNotInAdditionalAccess (name: string): Promise { + // Assert a name is NOT rendered as a managed additional grant. Wait for the panel + // first so the absence is meaningful (not merely "not yet rendered"). + await expect(this.issueAccessPanel()).toBeVisible() + await expect(this.issueAccessAdditional().getByText(name)).toHaveCount(0) } async addToDescription (description: string): Promise { diff --git a/tests/sanity/tests/tracker/mentions.spec.ts b/tests/sanity/tests/tracker/mentions.spec.ts index 915eb49aebb..0ce7908930d 100644 --- a/tests/sanity/tests/tracker/mentions.spec.ts +++ b/tests/sanity/tests/tracker/mentions.spec.ts @@ -40,10 +40,10 @@ test.describe('Mentions issue tests', () => { await issuesDetailsPage.checkCollaborators(['Appleseed John', 'Dirak Kainin']) }) - test('When Change assigner user should be added as Collaborators', async ({ page }) => { + test('Assignee is granted structural access but is not shown as an additional grant', async () => { const mentionIssue: NewIssue = { - title: `When Change assigner user should be added as Collaborators-${generateId()}`, - description: 'When Change assigner user should be added as Collaborators description' + title: `Assignee structural access-${generateId()}`, + description: 'Assignee structural access description' } await issuesPage.clickModelSelectorAll() @@ -57,8 +57,14 @@ test.describe('Mentions issue tests', () => { assignee: 'Dirak Kainin' }) await issuesDetailsPage.checkActivityContentExist('Assignee set to Dirak Kainin') - await issuesDetailsPage.checkCollaboratorsCount('2 members') - await issuesDetailsPage.checkCollaborators(['Appleseed John', 'Dirak Kainin']) + + // The IssueAccessPanel shows project members and explicit additional grants. The + // assignee is a legacy/structural collaborator (grantedVia == null): it stays + // access-relevant (enforced by CollaboratorGuardMiddleware, covered by the server + // guard tests) but is intentionally NOT rendered as a managed additional grant. + // This asserts that design decision and guards against a regression toward showing + // structural collaborators as grants. + await issuesDetailsPage.checkNotInAdditionalAccess('Dirak Kainin') }) test('Check that the backlink shown in the Issue activity', async ({ page }) => { From 9a16196facea6a4cd5672d935b4948be24f160c7 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Fri, 10 Jul 2026 13:06:04 +0000 Subject: [PATCH 63/65] fix(server): cap admin-grant level to prevent self-mint privilege escalation (H-NEW-01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plain space member (role >= User) could self-attribute a manual Collaborator with level 'admin'; hasAdminGrant() then counted it, unlocking canGrantGroup() and foreign-revoke — powers reserved for owners/maintainers/admin-grantees. checkCreate now caps 'admin'-level grants to callers who already hold admin-equivalent authority (canGrantAdminLevel = canGrant minus the member path). Adds guard tests for the self-mint rejection and the owner/admin-grantee paths. Signed-off-by: Michael Uray --- .../middleware/src/collaboratorGuard.ts | 30 +++++++++++++++++ .../src/tests/collaboratorGuard.test.ts | 32 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/foundations/server/packages/middleware/src/collaboratorGuard.ts b/foundations/server/packages/middleware/src/collaboratorGuard.ts index 462546f000f..16350ef5c02 100644 --- a/foundations/server/packages/middleware/src/collaboratorGuard.ts +++ b/foundations/server/packages/middleware/src/collaboratorGuard.ts @@ -192,6 +192,15 @@ export class CollaboratorGuardMiddleware extends BaseMiddleware implements Middl 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 { @@ -355,6 +364,27 @@ export class CollaboratorGuardMiddleware extends BaseMiddleware implements Middl 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, diff --git a/foundations/server/packages/middleware/src/tests/collaboratorGuard.test.ts b/foundations/server/packages/middleware/src/tests/collaboratorGuard.test.ts index 5cc0e976ead..f70784adab2 100644 --- a/foundations/server/packages/middleware/src/tests/collaboratorGuard.test.ts +++ b/foundations/server/packages/middleware/src/tests/collaboratorGuard.test.ts @@ -354,6 +354,38 @@ describe('CollaboratorGuardMiddleware', () => { 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 From f4681d428b72a5b4572a5de231eccf4dac08f97b Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Fri, 10 Jul 2026 15:37:28 +0000 Subject: [PATCH 64/65] fix(server): run collab-only field-update veto for all roles, not just guests (C-02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GuestPermissionsMiddleware.tx() early-returned for role >= User before the level-aware veto (isForbiddenCollabOnlyGuestFieldUpdate) could run — it lived inside processTx on the guest-only path. A regular User who reached an issue in a private project only through a read-level collaborator grant could therefore edit its fields, so the read/write level was unenforced for non-guests. The veto (which already passes space members and Maintainer+ by construction) is now extracted to checkCollabOnlyGrantVeto and run for every account at the top of tx(). Adds pipeline-level tests over tx() (the P2.3 tests only called the private method directly, which is why they missed this). Signed-off-by: Michael Uray --- .../middleware/src/guestPermissions.ts | 43 ++++++++++++++++++- .../src/tests/guestPermissions.test.ts | 21 +++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/foundations/server/packages/middleware/src/guestPermissions.ts b/foundations/server/packages/middleware/src/guestPermissions.ts index c9baa8ff6f8..0dabe5033da 100644 --- a/foundations/server/packages/middleware/src/guestPermissions.ts +++ b/foundations/server/packages/middleware/src/guestPermissions.ts @@ -124,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) @@ -160,7 +169,39 @@ 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, {})) } - if (await this.isForbiddenCollabOnlyGuestFieldUpdate(ctx, cudTx, account)) { + // 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, {})) } } diff --git a/foundations/server/packages/middleware/src/tests/guestPermissions.test.ts b/foundations/server/packages/middleware/src/tests/guestPermissions.test.ts index 16a37e20dc6..7d4b293e14b 100644 --- a/foundations/server/packages/middleware/src/tests/guestPermissions.test.ts +++ b/foundations/server/packages/middleware/src/tests/guestPermissions.test.ts @@ -624,6 +624,27 @@ describe('GuestPermissionsMiddleware', () => { 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')]) From df3c8d48ea6a1b7fa56bcf58de43fa4dc25feb56 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Fri, 10 Jul 2026 16:50:03 +0000 Subject: [PATCH 65/65] style(test): wrap H-NEW-01 admin-grant test args to CI print-width (120) Signed-off-by: Michael Uray --- .../src/tests/collaboratorGuard.test.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/foundations/server/packages/middleware/src/tests/collaboratorGuard.test.ts b/foundations/server/packages/middleware/src/tests/collaboratorGuard.test.ts index f70784adab2..381bd0673fd 100644 --- a/foundations/server/packages/middleware/src/tests/collaboratorGuard.test.ts +++ b/foundations/server/packages/middleware/src/tests/collaboratorGuard.test.ts @@ -357,7 +357,12 @@ describe('CollaboratorGuardMiddleware', () => { 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' }) + const tx = makeCreateTx(actor, { + collaborator: uuid(), + grantedVia: 'manual', + grantedBy: actor.uuid, + level: 'admin' + }) await expect(mw.tx(makeCtx(actor), [tx])).rejects.toThrow() }) @@ -368,7 +373,12 @@ describe('CollaboratorGuardMiddleware', () => { nextCalled = true return {} }) - const tx = makeCreateTx(actor, { collaborator: uuid(), grantedVia: 'manual', grantedBy: actor.uuid, level: 'admin' }) + const tx = makeCreateTx(actor, { + collaborator: uuid(), + grantedVia: 'manual', + grantedBy: actor.uuid, + level: 'admin' + }) await mw.tx(makeCtx(actor), [tx]) expect(nextCalled).toBe(true) }) @@ -381,7 +391,12 @@ describe('CollaboratorGuardMiddleware', () => { nextCalled = true return {} }) - const tx = makeCreateTx(actor, { collaborator: uuid(), grantedVia: 'manual', grantedBy: actor.uuid, level: 'admin' }) + const tx = makeCreateTx(actor, { + collaborator: uuid(), + grantedVia: 'manual', + grantedBy: actor.uuid, + level: 'admin' + }) await mw.tx(makeCtx(actor), [tx]) expect(nextCalled).toBe(true) })