Skip to content
Open
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
eb35ef9
feat(core): add ClassCollaborators.mentionsGrantAccess opt-in flag
MichaelUray May 25, 2026
1d4017e
feat(model/core): wire mentionsGrantAccess into TClassCollaborators m…
MichaelUray May 25, 2026
0b9fa6a
feat(tracker): opt tracker.class.Issue into collaborator-grants-read …
MichaelUray May 25, 2026
2d28f4b
feat(core/collaborators): add isomorphic resolveMentionGrantTarget he…
MichaelUray May 25, 2026
b67689c
feat(middleware/guest-permissions): veto field updates from collab-on…
MichaelUray May 25, 2026
be38db3
feat(tracker/utils): split canEditIssue into canEditIssueFields + can…
MichaelUray Jul 9, 2026
f07d6e0
feat(tracker/EditIssue): show comment composer separately from field …
MichaelUray May 25, 2026
863faf7
fix(chunter-resources): add @hcengineering/text-core dependency
MichaelUray May 25, 2026
961e33c
feat(chunter-trigger): mention-grants-access uses shared helper + gra…
MichaelUray May 25, 2026
d886ee0
feat(chunter/ChatMessageInput): warn before mention auto-grants Issue…
MichaelUray May 25, 2026
f5157a0
feat(middleware/spaceSecurity): let Guests read collab-secured docs a…
MichaelUray May 25, 2026
e6f6fcf
feat(security): allow non-System callers to read their own Collaborat…
MichaelUray May 25, 2026
bc25a70
feat(security+nav): list collab-only spaces in workbench navigator fo…
MichaelUray May 25, 2026
30ad68d
fix(nav): surface collab-only projects in tracker nav-tree
MichaelUray May 26, 2026
2c916a2
fix(tracker/nav): hide non-Issues sub-nodes on non-member projects
MichaelUray May 26, 2026
28a25d1
feat(tracker/nav): V1 - badge + tooltip for collab-only projects
MichaelUray May 27, 2026
8069e28
test(tracker): V2 - mention-grant persists after issue moves to Done
MichaelUray May 27, 2026
dc3e380
feat(text-core): V3a - grantsAccess attr on mention references
MichaelUray May 27, 2026
ddb5018
feat(chunter): V3b - per-grantee grant choices in send disclosure
MichaelUray May 27, 2026
b72c062
feat(chunter-trigger): V3c - honour grantsAccess='false' on mentions
MichaelUray May 27, 2026
1a8f324
feat(chunter-trigger): V3d - add-only re-grant on message edit
MichaelUray May 27, 2026
765da62
fix(chunter-trigger): V3d update-grant uses the edit actor + add tests
MichaelUray May 27, 2026
f9fe39f
fix(chunter): preserve unsent comment when grant dialog is cancelled
MichaelUray May 27, 2026
2f66577
test(middleware): stub hierarchy.getAncestors for guest-permission un…
MichaelUray May 30, 2026
d388c5a
fix(workbench-resources/Navigator): correct activeClasses cast to Ref…
MichaelUray May 30, 2026
4a73a17
fix: self-review BLOCKERs + IMPORTANT findings
MichaelUray May 28, 2026
34c8338
fix(tracker/V1): guard space.members against null
MichaelUray May 28, 2026
caf9ebc
docs(tracker/V1): note scope limit on sub-node hide
MichaelUray May 30, 2026
a8765c9
chore: apply repo formatting and lint fixes after develop merge
MichaelUray Jul 2, 2026
a1bb75b
fix(middleware): backend-guard collab-read space-filter bypass (H5)
MichaelUray Jul 8, 2026
97d5447
fix(middleware): collab-only Write-Veto deckt auch TxRemoveDoc (L-RM)
MichaelUray Jul 8, 2026
ec05335
fix(middleware): guestPermissions fail-closed bei fehlendem Space (L-GP)
MichaelUray Jul 8, 2026
93c13b4
fix(chunter-resources): align text-core to workspace:^0.7.19
MichaelUray Jul 9, 2026
017942d
fix(core): pass limit:1 in resolveMentionGrantTarget lookups
MichaelUray Jul 11, 2026
f8aae31
fix(server-chunter): dedup mention grants against in-flight collabora…
MichaelUray Jul 11, 2026
fc9c20b
fix(tracker-assets): add ru translation for SharedWithYouTooltip
MichaelUray Jul 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion common/config/rush/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion foundations/core/packages/core/src/classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1010,8 +1010,15 @@ export interface ClassCollaborators<T extends Doc> extends Doc {
attachedTo: Ref<Class<T>>
allFields?: boolean // for all (PersonId | Ref<Employee> | PersonId[] | Ref<Employee>[]) attributes
fields: (keyof T)[] // PersonId | Ref<Employee> | PersonId[] | Ref<Employee>[]
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 {
Expand Down
50 changes: 49 additions & 1 deletion foundations/core/packages/core/src/collaborators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// limitations under the License.
//

import core, { Class, ClassCollaborators, Doc, Hierarchy, ModelDb, Ref } from '.'
import core, { AttachedDoc, Class, ClassCollaborators, Doc, DocumentQuery, Hierarchy, ModelDb, Ref } from '.'

export function getClassCollaborators<T extends Doc> (
model: ModelDb,
Expand All @@ -35,3 +35,51 @@ export function getClassCollaborators<T extends Doc> (
}
}
}

/**
* 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: <T extends Doc>(cls: Ref<Class<T>>, q: DocumentQuery<T>) => Promise<T[]>
): Promise<Doc | null> {
let cur: Doc | undefined = start
for (let i = 0; i < 8 && cur != null; i++) {
const ccQuery: DocumentQuery<ClassCollaborators<Doc>> = {
attachedTo: cur._class
}
const cc = (await findAll(core.class.ClassCollaborators, ccQuery))[0]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is better to add limit: 1 if only one item is required.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied. Both [0] lookups in resolveMentionGrantTarget now use limit: 1, with FindOptions forwarded through all three callers. I also bounded the equivalent targetClassCollab lookup in the trigger.

if (cc?.provideSecurity === true && cc.mentionsGrantAccess === true) {
return cur
}
const attached = cur as AttachedDoc
if (attached.attachedTo == null || attached.attachedToClass == null) {
return null
}
const parentQuery: DocumentQuery<Doc> = {
_id: attached.attachedTo
}
const parent = (await findAll(attached.attachedToClass, parentQuery))[0]
cur = parent
}
return null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { extractReferences } from '../reference'
import { MarkupNodeType, type MarkupNode } from '../model'

function refNode (id: string, label: string, objectclass: string, grantsAccess?: 'true' | 'false'): MarkupNode {
return {
type: MarkupNodeType.reference,
attrs: { id, label, objectclass, ...(grantsAccess !== undefined ? { grantsAccess } : {}) }
}
}

function doc (...children: MarkupNode[]): MarkupNode {
return { type: MarkupNodeType.doc, content: children }
}

describe('extractReferences grantsAccess', () => {
test('surfaces grantsAccess from a reference node', () => {
const refs = extractReferences(doc(refNode('p1', 'Alice', 'contact:class:Person', 'true')))
expect(refs).toHaveLength(1)
expect(refs[0].objectId).toBe('p1')
expect(refs[0].grantsAccess).toBe('true')
})

test('grantsAccess undefined when attr absent (default = grant)', () => {
const refs = extractReferences(doc(refNode('p1', 'Alice', 'contact:class:Person')))
expect(refs[0].grantsAccess).toBeUndefined()
})

test('any-wins: false then true => true', () => {
const refs = extractReferences(
doc(
refNode('p1', 'Alice', 'contact:class:Person', 'false'),
refNode('p1', 'Alice', 'contact:class:Person', 'true')
)
)
expect(refs).toHaveLength(1)
expect(refs[0].grantsAccess).toBe('true')
})

test('any-wins: true then false => true', () => {
const refs = extractReferences(
doc(
refNode('p1', 'Alice', 'contact:class:Person', 'true'),
refNode('p1', 'Alice', 'contact:class:Person', 'false')
)
)
expect(refs[0].grantsAccess).toBe('true')
})

test('any-wins: false then undefined => not false (undefined grants)', () => {
const refs = extractReferences(
doc(refNode('p1', 'Alice', 'contact:class:Person', 'false'), refNode('p1', 'Alice', 'contact:class:Person'))
)
expect(refs[0].grantsAccess).not.toBe('false')
})

test('all explicitly false => false', () => {
const refs = extractReferences(
doc(
refNode('p1', 'Alice', 'contact:class:Person', 'false'),
refNode('p1', 'Alice', 'contact:class:Person', 'false')
)
)
expect(refs[0].grantsAccess).toBe('false')
})

test('different persons kept separate', () => {
const refs = extractReferences(
doc(refNode('p1', 'Alice', 'contact:class:Person', 'true'), refNode('p2', 'Bob', 'contact:class:Person', 'false'))
)
expect(refs).toHaveLength(2)
expect(refs.find((r) => r.objectId === 'p1')?.grantsAccess).toBe('true')
expect(refs.find((r) => r.objectId === 'p2')?.grantsAccess).toBe('false')
})
})
8 changes: 7 additions & 1 deletion foundations/core/packages/text-core/src/markup/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
}
15 changes: 14 additions & 1 deletion foundations/core/packages/text-core/src/markup/reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export interface Reference {
objectId: Ref<Doc>
objectClass: Ref<Class<Doc>>
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'
}

/**
Expand All @@ -37,9 +40,19 @@ export function extractReferences (content: MarkupNode): Array<Reference> {
const reference = node as ReferenceMarkupNode
const objectId = reference.attrs.id as Ref<Doc>
const objectClass = reference.attrs.objectclass as Ref<Class<Doc>>
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
Expand Down
20 changes: 20 additions & 0 deletions foundations/core/packages/text/src/nodes/reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface ReferenceNodeProps {
id: Ref<Doc>
objectclass: Ref<Class<Doc>>
label: string
grantsAccess?: 'true' | 'false'
fixed?: boolean
}

Expand All @@ -44,6 +45,17 @@ export const ReferenceNode = Node.create<ReferenceOptions>({
id: getDataAttribute('id'),
objectclass: getDataAttribute('objectclass'),
label: getDataAttribute('label'),
grantsAccess: {
// V3: keep the deny flag across edit round-trips. parseHTML normalises
// a missing attribute to `undefined` rather than `null` so the runtime
// value matches the public type `'true' | 'false' | undefined` declared
// on ReferenceMarkupNode.attrs — a `null` would break strict
// `=== undefined` checks downstream.
default: undefined,
parseHTML: (element) => element.getAttribute('data-grants-access') ?? undefined,
renderHTML: (attributes) =>
attributes.grantsAccess == null ? null : { 'data-grants-access': attributes.grantsAccess }
},
fixed: {
default: null,
parseHTML: (element) => element.getAttribute('data-fixed') === 'true',
Expand Down Expand Up @@ -90,6 +102,11 @@ export const ReferenceNode = Node.create<ReferenceOptions>({
'data-id': node.attrs.id,
'data-objectclass': node.attrs.objectclass,
'data-label': node.attrs.label,
// V3: belt-and-suspenders — explicitly emit data-grants-access here
// (in addition to the per-attribute renderHTML in addAttributes) so
// the deny flag is rendered even if the attribute-renderer merge
// path is bypassed by a downstream override.
...(node.attrs.grantsAccess != null ? { 'data-grants-access': node.attrs.grantsAccess } : {}),
class: 'antiMention'
},
this.options.HTMLAttributes,
Expand All @@ -110,10 +127,13 @@ function getAttrs (el: HTMLSpanElement): Attrs | false {
return false
}

const grantsAccess = el.dataset.grantsAccess

return {
id,
label,
objectclass,
...(grantsAccess !== undefined ? { grantsAccess } : {}),
fixed: fixed || null
}
}
12 changes: 12 additions & 0 deletions foundations/server/packages/core/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@ export interface RawFindIterator {
* @public
*/
export interface DbAdapter extends LowLevelStorage {
// Backend capability flag. When true, this adapter enforces collaborator-grant
// read security at the storage layer (e.g. the Postgres addSecurity collab
// OR-branch that joins the Collaborator domain into the visibility check).
//
// The space-security middleware only drops the space filter for collab-read
// bypasses (Guests reading docs they collaborate on, Collaborator self-reads,
// collab-only space listing) when the adapter serving the domain sets this to
// true. Absent/false is treated as fail-closed: the middleware keeps the normal
// space filter, so a backend without an equivalent clause (e.g. Mongo, which has
// no adapter-side space security) cannot leak docs across spaces.
supportsCollaboratorSecurity?: boolean

init?: (
ctx: MeasureContext,
contextVars: Record<string, any>,
Expand Down
49 changes: 49 additions & 0 deletions foundations/server/packages/middleware/src/guestPermissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import core, {
type Class,
type Doc,
type ClassPermission,
getClassCollaborators,
type Permission,
hasAccountRole,
type MeasureContext,
Expand Down Expand Up @@ -157,9 +158,57 @@ 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 NOR delete it via
* TxRemoveDoc (L-RM). 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<SessionData>,
cudTx: TxCUD<Doc>,
account: Account
): Promise<boolean> {
// L-RM: veto covers both field-updates AND removes by collab-only guests.
if (cudTx._class !== core.class.TxUpdateDoc && cudTx._class !== core.class.TxRemoveDoc) 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<Space>(ctx, core.class.Space, { _id: cudTx.objectSpace }))[0]
// L-GP: fail-closed — if the doc's space cannot be resolved we cannot prove
// the caller is a space member, so the collab-only-guest veto must FORBID
// (previously returned false = fail-open = mutation allowed).
if (space === undefined) return true
if (space.members?.includes(account.uuid)) 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.
Expand Down
Loading