Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
ed889ed
feat(core): add ClassCollaborators.mentionsGrantAccess opt-in flag
MichaelUray May 25, 2026
ede2852
feat(model/core): wire mentionsGrantAccess into TClassCollaborators m…
MichaelUray May 25, 2026
d9f7fca
feat(tracker): opt tracker.class.Issue into collaborator-grants-read …
MichaelUray May 25, 2026
b195cb3
feat(core/collaborators): add isomorphic resolveMentionGrantTarget he…
MichaelUray May 25, 2026
b2ddad6
feat(middleware/guest-permissions): veto field updates from collab-on…
MichaelUray May 25, 2026
05a23bf
feat(tracker/utils): split canEditIssue into canEditIssueFields + can…
MichaelUray May 25, 2026
74ed380
feat(tracker/EditIssue): show comment composer separately from field …
MichaelUray May 25, 2026
ce41644
fix(chunter-resources): add @hcengineering/text-core dependency
MichaelUray May 25, 2026
96a3b58
feat(chunter-trigger): mention-grants-access uses shared helper + gra…
MichaelUray May 25, 2026
2fd8e7f
feat(chunter/ChatMessageInput): warn before mention auto-grants Issue…
MichaelUray May 25, 2026
51008bd
feat(middleware/spaceSecurity): let Guests read collab-secured docs a…
MichaelUray May 25, 2026
592e78b
feat(security): allow non-System callers to read their own Collaborat…
MichaelUray May 25, 2026
ae40806
feat(security+nav): list collab-only spaces in workbench navigator fo…
MichaelUray May 25, 2026
7028bbe
fix(nav): surface collab-only projects in tracker nav-tree
MichaelUray May 26, 2026
186c11e
fix(tracker/nav): hide non-Issues sub-nodes on non-member projects
MichaelUray May 26, 2026
6a3ab49
feat(tracker/nav): V1 - badge + tooltip for collab-only projects
MichaelUray May 27, 2026
765b2eb
test(tracker): V2 - mention-grant persists after issue moves to Done
MichaelUray May 27, 2026
e8878ac
feat(text-core): V3a - grantsAccess attr on mention references
MichaelUray May 27, 2026
5405d2b
feat(chunter): V3b - per-grantee grant choices in send disclosure
MichaelUray May 27, 2026
4b26a76
feat(chunter-trigger): V3c - honour grantsAccess='false' on mentions
MichaelUray May 27, 2026
206d6f2
feat(chunter-trigger): V3d - add-only re-grant on message edit
MichaelUray May 27, 2026
a3a7efb
fix(chunter-trigger): V3d update-grant uses the edit actor + add tests
MichaelUray May 27, 2026
3f3902e
fix(chunter): preserve unsent comment when grant dialog is cancelled
MichaelUray May 27, 2026
74d2b01
test(middleware): stub hierarchy.getAncestors for guest-permission un…
MichaelUray May 30, 2026
08db50f
fix(workbench-resources/Navigator): correct activeClasses cast to Ref…
MichaelUray May 30, 2026
3f617d0
fix: self-review BLOCKERs + IMPORTANT findings
MichaelUray May 28, 2026
4fc65bd
fix(tracker/V1): guard space.members against null
MichaelUray May 28, 2026
fff3873
docs(tracker/V1): note scope limit on sub-node hide
MichaelUray May 30, 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
3 changes: 3 additions & 0 deletions 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 @@ -1008,8 +1008,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
57 changes: 56 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,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<T extends Doc> (
model: ModelDb,
Expand All @@ -35,3 +44,49 @@ 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 cc = (await findAll(core.class.ClassCollaborators, {
attachedTo: cur._class
} as DocumentQuery<ClassCollaborators<Doc>>))[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<Doc>))[0]
cur = parent
}
return null
}
Original file line number Diff line number Diff line change
@@ -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')
})
})
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
24 changes: 22 additions & 2 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'
}

export interface ReferenceOptions {
Expand All @@ -42,7 +43,18 @@ export const ReferenceNode = Node.create<ReferenceOptions>({
return {
id: getDataAttribute('id'),
objectclass: getDataAttribute('objectclass'),
label: getDataAttribute('label')
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 }
}
}
},

Expand Down Expand Up @@ -77,6 +89,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 @@ -96,9 +113,12 @@ function getAttrs (el: HTMLSpanElement): Attrs | false {
return false
}

const grantsAccess = el.dataset.grantsAccess

return {
id,
label,
objectclass
objectclass,
...(grantsAccess !== undefined ? { grantsAccess } : {})
}
}
48 changes: 48 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,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<SessionData>,
cudTx: TxCUD<Doc>,
account: Account
): Promise<boolean> {
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<Space>(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.
Expand Down
35 changes: 34 additions & 1 deletion foundations/server/packages/middleware/src/spaceSecurity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,7 +630,40 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar

let clientFilterSpaces: Set<Ref<Space>> | 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)
// 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)
// 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 &&
!spaceCollabBypass
) {
if (!isOwner(account, ctx) || !isSpace || !showArchived) {
if (newQuery[field] !== undefined) {
const res = await this.mergeQuery(ctx, account, newQuery[field], domain, isSpace, showArchived)
Expand Down
Loading