Hide meeting minutes from guests

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artem Savchenko
2026-04-08 13:37:39 +07:00
parent 161a07321b
commit 4cf6a11041
14 changed files with 513 additions and 7 deletions
+24 -2
View File
@@ -331,10 +331,30 @@ export function devTool (
// })
// })
function parseAccountRole (raw: unknown): AccountRole {
const rawRole = typeof raw === 'string' ? raw.trim() : String(raw ?? 'User').trim()
const normalized = rawRole.trim().toLowerCase()
const match = Object.values(AccountRole).find(
(v) => v.toLowerCase() === normalized
)
if (match !== undefined) return match
switch (normalized) {
case 'readonly':
return AccountRole.ReadOnlyGuest
case 'docguest':
return AccountRole.DocGuest
default:
throw new Error(`Unknown role: ${rawRole}`)
}
}
program
.command('assign-workspace <email> <workspace>')
.description('assign workspace')
.action(async (email: string, workspace: string, cmd) => {
.option('--role <role>', 'Workspace role (User, Guest, ReadOnlyGuest, DocGuest, Maintainer, Owner, Admin)', 'User')
.action(async (email: string, workspace: string, cmd: { role: string }) => {
await withAccountDatabase(async (db) => {
console.log(`assigning user ${email} to ${workspace}...`)
try {
@@ -343,10 +363,12 @@ export function devTool (
throw new Error(`Workspace ${workspace} not found`)
}
const role = cmd.role != null ? parseAccountRole(cmd.role) : AccountRole.User
await assignWorkspace(toolCtx, db, null, getToolToken(), {
email,
workspaceUuid: ws.uuid,
role: AccountRole.User
role
})
} catch (err: any) {
console.error(err)
@@ -985,6 +985,11 @@ export interface ClassCollaborators<T extends Doc> extends Doc {
fields: (keyof T)[] // PersonId | Ref<Employee> | PersonId[] | Ref<Employee>[]
provideSecurity?: boolean // If true, will provide security for collaborators
provideAttachedSecurity?: boolean // If true, will provide security for collaborators of attached doc
/**
* When true with provideSecurity, workspace guests may read instances of this class only as collaborators,
* not via space membership alone (see guest collaborator read middleware / Postgres addSecurity).
*/
guestReadCollaboratorOnly?: boolean
}
export interface Collaborator extends AttachedDoc {
@@ -0,0 +1,110 @@
//
// 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 core, {
AccountRole,
type Class,
type Collaborator,
type Doc,
type DocumentQuery,
type FindResult,
getClassCollaborators,
type MeasureContext,
type Ref,
type SessionData,
systemAccountUuid
} from '@hcengineering/core'
import {
BaseMiddleware,
type Middleware,
type PipelineContext,
type ServerFindOptions
} from '@hcengineering/server-core'
/** Intersects a find query with _id ∈ allowed (empty → no matches). Ref<T>[] matches DocumentQuery _id $in. */
function mergeDocIdRestriction<T extends Doc> (query: DocumentQuery<T>, allowed: Ref<T>[]): DocumentQuery<T> {
const allowedIds: DocumentQuery<T>['_id'] = { $in: allowed.length === 0 ? [] : allowed }
const prevId = query._id
if (prevId === undefined) {
return { ...query, _id: allowedIds }
}
type WithAnd = DocumentQuery<T> & { $and?: DocumentQuery<T>[] }
const { _id: _drop, $and, ...rest } = query as WithAnd
const andParts: DocumentQuery<T>[] = [
...($and ?? []),
{ _id: prevId },
{ _id: allowedIds }
]
// Spreading rest + $and is not inferred as DocumentQuery<T> (mapped type + index signature).
const merged: DocumentQuery<T> = { ...rest, $and: andParts }
return merged
}
/**
* Restricts findAll for classes with ClassCollaborators.guestReadCollaboratorOnly so that
* Guest / ReadOnlyGuest only receive documents they are collaborators on (Mongo and any adapter
* without SQL collaborator OR-clauses).
*/
export class GuestCollaboratorClassReadMiddleware extends BaseMiddleware implements Middleware {
static async create (
ctx: MeasureContext,
context: PipelineContext,
next: Middleware | undefined
): Promise<GuestCollaboratorClassReadMiddleware> {
return new GuestCollaboratorClassReadMiddleware(context, next)
}
override async findAll<T extends Doc>(
ctx: MeasureContext<SessionData>,
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: ServerFindOptions<T>
): Promise<FindResult<T>> {
const session = ctx.contextData
if (session?.isTriggerCtx === true) {
return await this.provideFindAll(ctx, _class, query, options)
}
const account = session?.account
if (account === undefined || account.uuid === systemAccountUuid) {
return await this.provideFindAll(ctx, _class, query, options)
}
if (![AccountRole.Guest, AccountRole.ReadOnlyGuest].includes(account.role)) {
return await this.provideFindAll(ctx, _class, query, options)
}
const collabSec = getClassCollaborators(this.context.modelDb, this.context.hierarchy, _class)
if (collabSec?.provideSecurity !== true || collabSec?.guestReadCollaboratorOnly !== true) {
return await this.provideFindAll(ctx, _class, query, options)
}
const rootClass = collabSec.attachedTo
const docClasses = [...this.context.hierarchy.getDescendants(rootClass), rootClass]
const collabQuery: DocumentQuery<Collaborator> = {
collaborator: account.uuid,
attachedToClass: { $in: docClasses }
}
const collabs = await this.provideFindAll(ctx, core.class.Collaborator, collabQuery, {
projection: { attachedTo: 1 },
limit: 10_000
})
const allowed = collabs.map((c) => c.attachedTo) as Ref<T>[]
const newQuery = mergeDocIdRestriction(query, allowed)
if (collabs.length >= 10_000) {
ctx.warn('Guest collaborator id list truncated at 10000; find may miss rows', {
account: account.uuid,
_class
})
}
return await this.provideFindAll(ctx, _class, newQuery, options)
}
}
@@ -31,6 +31,7 @@ export * from './modified'
export * from './private'
export * from './queryJoin'
export * from './guestPermissions'
export * from './guestCollaboratorClassRead'
export * from './identifier'
export * from './spacePermissions'
export * from './spaceSecurity'
@@ -0,0 +1,262 @@
//
// 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 core, {
AccountRole,
type Account,
type Class,
type Collaborator,
type Doc,
type DomainResult,
generateId,
type Hierarchy,
MeasureMetricsContext,
type MeasureContext,
type PersonId,
type Ref,
type SearchResult,
type SessionData,
systemAccountUuid,
Timestamp,
toFindResult
} from '@hcengineering/core'
import type { Middleware, PipelineContext } from '@hcengineering/server-core'
import { GuestCollaboratorClassReadMiddleware } from '../guestCollaboratorClassRead'
const MEETING_MINUTES_CLASS = 'test:love:class:MeetingMinutes' as Ref<Class<Doc>>
const DOC_CLASS = core.class.Doc
function makeAccount (role: AccountRole, uuid?: ReturnType<typeof generateId>): Account {
return {
uuid: (uuid ?? generateId()) as Account['uuid'],
role,
primarySocialId: 'test-social' as PersonId,
socialIds: ['test-social' as PersonId],
fullSocialIds: []
}
}
function makeCtx (account: Account, extra?: Partial<SessionData>): MeasureContext<SessionData> {
const ctx = new MeasureMetricsContext('test', {}) as MeasureContext<SessionData>
ctx.contextData = {
account,
broadcast: { txes: [], queue: [], sessions: {} },
...extra
} as SessionData
return ctx
}
function makeCollaboratorDoc (attachedTo: Ref<Doc>, collaborator: Account['uuid']): Collaborator {
return {
_id: generateId(),
_class: core.class.Collaborator,
space: core.space.Workspace,
attachedTo,
attachedToClass: MEETING_MINUTES_CLASS,
collection: 'collaborators',
collaborator,
modifiedOn: Date.now(),
modifiedBy: core.account.System
}
}
function makePipelineContext (): PipelineContext {
const collaboratorsMixin = {
_id: generateId(),
_class: core.class.ClassCollaborators,
space: core.space.Model,
attachedTo: MEETING_MINUTES_CLASS,
fields: ['createdBy'],
provideSecurity: true,
guestReadCollaboratorOnly: true,
modifiedOn: Date.now(),
modifiedBy: core.account.System
} as Doc
const hierarchy = {
getAncestors: (c: Ref<Class<Doc>>) => [c, DOC_CLASS],
getDescendants: (_c: Ref<Class<Doc>>) => [] as Ref<Class<Doc>>[]
} as unknown as Hierarchy
const modelDb = {
findAllSync: (cl: Ref<Class<Doc>>, query: { attachedTo?: { $in?: Ref<Class<Doc>>[] } }) => {
if (cl !== core.class.ClassCollaborators) return []
const ids = query.attachedTo?.$in ?? []
return ids.includes(MEETING_MINUTES_CLASS) ? [collaboratorsMixin] : []
}
} as PipelineContext['modelDb']
return {
workspace: { uuid: generateId() as any, url: 'test', dataId: 'test' as any },
hierarchy,
modelDb,
branding: null as any,
adapterManager: {} as any,
storageAdapter: {} as any,
contextVars: {},
lastTx: '' as Timestamp,
lastHash: '',
broadcastEvent: async () => {}
} as PipelineContext
}
function stubMiddleware (): Middleware {
return {
findAll: async () => toFindResult([]),
tx: async () => ({}),
groupBy: async () => new Map(),
searchFulltext: async () => ({ docs: [] }) as SearchResult,
handleBroadcast: async () => {},
loadModel: async () => [],
domainRequest: async () => ({ value: undefined }) as DomainResult,
closeSession: async () => {},
close: async () => {}
} satisfies Middleware
}
describe('GuestCollaboratorClassReadMiddleware', () => {
const MM_ID = generateId() as Ref<Doc>
it('User: passes original query in a single findAll', async () => {
const captured: Array<{ cls: string; query: unknown }> = []
const next: Middleware = {
...stubMiddleware(),
findAll: async (ctx, _class, query, options) => {
captured.push({ cls: _class as string, query: { ...query } })
return toFindResult([])
}
}
const mw = new GuestCollaboratorClassReadMiddleware(makePipelineContext(), next)
const ctx = makeCtx(makeAccount(AccountRole.User))
await mw.findAll(ctx, MEETING_MINUTES_CLASS, { attachedTo: MM_ID })
expect(captured).toHaveLength(1)
expect(captured[0].cls).toBe(MEETING_MINUTES_CLASS)
expect(captured[0].query).toEqual({ attachedTo: MM_ID })
})
it('Guest: loads collaborators then restricts MeetingMinutes to collaborator attachedTo ids', async () => {
const guest = makeAccount(AccountRole.Guest)
const collabDoc = makeCollaboratorDoc(MM_ID, guest.uuid)
const captured: Array<{ cls: string; query: unknown }> = []
const next: Middleware = {
...stubMiddleware(),
findAll: async (c, _class, query) => {
captured.push({ cls: _class as string, query: JSON.parse(JSON.stringify(query)) })
if (_class === core.class.Collaborator) {
return toFindResult([collabDoc])
}
return toFindResult([])
}
}
const mw = new GuestCollaboratorClassReadMiddleware(makePipelineContext(), next)
const ctx = makeCtx(guest)
await mw.findAll(ctx, MEETING_MINUTES_CLASS, { space: core.space.Workspace })
expect(captured).toHaveLength(2)
expect(captured[0].cls).toBe(core.class.Collaborator)
expect((captured[0].query as any).collaborator).toBe(guest.uuid)
expect(captured[1].cls).toBe(MEETING_MINUTES_CLASS)
expect((captured[1].query as any)._id).toEqual({ $in: [MM_ID] })
})
it('Guest: empty collaborator list yields _id $in []', async () => {
const guest = makeAccount(AccountRole.Guest)
const captured: Array<{ cls: string; query: unknown }> = []
const next: Middleware = {
...stubMiddleware(),
findAll: async (c, _class, query) => {
captured.push({ cls: _class as string, query: JSON.parse(JSON.stringify(query)) })
if (_class === core.class.Collaborator) {
return toFindResult([])
}
return toFindResult([])
}
}
const mw = new GuestCollaboratorClassReadMiddleware(makePipelineContext(), next)
const ctx = makeCtx(guest)
await mw.findAll(ctx, MEETING_MINUTES_CLASS, {})
expect(captured).toHaveLength(2)
const mmQuery = captured[1].query as { _id: { $in: unknown[] } }
expect(mmQuery._id).toEqual({ $in: [] })
})
it('Guest: without guestReadCollaboratorOnly on class config, query is unchanged', async () => {
const bareContext = {
...makePipelineContext(),
modelDb: {
findAllSync: () => []
} as PipelineContext['modelDb']
} as PipelineContext
const captured: unknown[] = []
const next: Middleware = {
...stubMiddleware(),
findAll: async (c, _class, query) => {
captured.push(query)
return toFindResult([])
}
}
const mw = new GuestCollaboratorClassReadMiddleware(bareContext, next)
const ctx = makeCtx(makeAccount(AccountRole.Guest))
await mw.findAll(ctx, MEETING_MINUTES_CLASS, { space: core.space.Workspace })
expect(captured).toHaveLength(1)
expect(captured[0]).toEqual({ space: core.space.Workspace })
})
it('system account: no collaborator prefetch', async () => {
let calls = 0
const next: Middleware = {
...stubMiddleware(),
findAll: async () => {
calls++
return toFindResult([])
}
}
const mw = new GuestCollaboratorClassReadMiddleware(makePipelineContext(), next)
const ctx = makeCtx({
uuid: systemAccountUuid,
role: AccountRole.Owner,
primarySocialId: core.account.System,
socialIds: [core.account.System],
fullSocialIds: []
})
await mw.findAll(ctx, MEETING_MINUTES_CLASS, {})
expect(calls).toBe(1)
})
it('Guest: merges existing _id constraint with $and', async () => {
const guest = makeAccount(AccountRole.Guest)
const otherId = generateId() as Ref<Doc>
const collabDoc = makeCollaboratorDoc(MM_ID, guest.uuid)
const captured: unknown[] = []
const next: Middleware = {
...stubMiddleware(),
findAll: async (c, _class, query) => {
captured.push(JSON.parse(JSON.stringify(query)))
if (_class === core.class.Collaborator) {
return toFindResult([collabDoc])
}
return toFindResult([])
}
}
const mw = new GuestCollaboratorClassReadMiddleware(makePipelineContext(), next)
const ctx = makeCtx(guest)
await mw.findAll(ctx, MEETING_MINUTES_CLASS, { _id: otherId })
const mmQuery = captured[1] as any
expect(mmQuery.$and).toBeDefined()
expect(mmQuery.$and).toEqual(
expect.arrayContaining([{ _id: otherId }, { _id: { $in: [MM_ID] } }])
)
})
})
@@ -635,9 +635,15 @@ abstract class PostgresAdapterBase implements DbAdapter {
const privateCheck = domain === DOMAIN_SPACE ? ' OR sec.private = false' : ''
const archivedCheck = showArchived ? '' : ' AND sec.archived = false'
const q = `(sec._id = '${core.space.Space}' OR sec."_class" = '${core.class.SystemSpace}' OR sec.members @> '{"${acc.uuid}"}'${privateCheck})${archivedCheck}`
const res = `EXISTS (SELECT 1 FROM ${translateDomain(DOMAIN_SPACE)} sec WHERE sec._id = ${domain}.${key} AND sec."workspaceId" = ${vars.add(this.workspaceId, '::uuid')} AND ${q})`
const collabSec = getClassCollaborators(this.modelDb, this.hierarchy, _class)
const guestCollaboratorOnly =
[AccountRole.Guest, AccountRole.ReadOnlyGuest].includes(acc.role) &&
collabSec?.provideSecurity === true &&
collabSec?.guestReadCollaboratorOnly === true
const res = guestCollaboratorOnly
? 'false'
: `EXISTS (SELECT 1 FROM ${translateDomain(DOMAIN_SPACE)} sec WHERE sec._id = ${domain}.${key} AND sec."workspaceId" = ${vars.add(this.workspaceId, '::uuid')} AND ${q})`
let collabRes = ''
if ([AccountRole.Guest, AccountRole.ReadOnlyGuest].includes(acc.role)) {
if (collabSec?.provideSecurity === true) {
+1
View File
@@ -436,6 +436,7 @@ export class TClassCollaborators extends TDoc implements ClassCollaborators<Doc>
fields!: (keyof Doc)[]
provideSecurity?: boolean
provideAttachedSecurity?: boolean
guestReadCollaboratorOnly?: boolean
}
@Model(core.class.Collaborator, core.class.Doc, DOMAIN_COLLABORATOR)
+2 -1
View File
@@ -644,7 +644,8 @@ export function createModel (builder: Builder): void {
builder.createDoc<ClassCollaborators<MeetingMinutes>>(core.class.ClassCollaborators, core.space.Model, {
attachedTo: love.class.MeetingMinutes,
fields: ['createdBy'],
provideSecurity: true
provideSecurity: true,
guestReadCollaboratorOnly: true
})
builder.mixin(love.class.Room, core.class.Class, core.mixin.IndexConfiguration, {
+15 -1
View File
@@ -14,7 +14,7 @@
//
import contact from '@hcengineering/contact'
import { TxOperations, type Ref, type Space } from '@hcengineering/core'
import { DOMAIN_MODEL, TxOperations, type Ref, type Space } from '@hcengineering/core'
import drive from '@hcengineering/drive'
import {
MeetingStatus,
@@ -179,6 +179,20 @@ export const loveOperation: MigrateOperation = {
func: async (client) => {
await client.reindex(DOMAIN_MEETING_MINUTES, [love.class.MeetingMinutes])
}
},
{
state: 'meeting-minutes-guest-collaborator-read',
mode: 'upgrade',
func: async (client) => {
await client.update(
DOMAIN_MODEL,
{
_class: core.class.ClassCollaborators,
attachedTo: love.class.MeetingMinutes
},
{ guestReadCollaboratorOnly: true }
)
}
}
])
},
@@ -33,8 +33,10 @@
let preference: ViewletPreference | undefined
let loading = true
/** Full members see all minutes in context; guests see only rows the server allows (collaborator-only reads). */
let canViewMinutes: boolean = false
$: canViewMinutes = hasAccountRole(me, AccountRole.User)
$: canViewMinutes =
hasAccountRole(me, AccountRole.User) || me.role === AccountRole.Guest || me.role === AccountRole.ReadOnlyGuest
</script>
{#if canViewMinutes}
+2
View File
@@ -28,6 +28,7 @@ import {
DomainTxMiddleware,
FindSecurityMiddleware,
FullTextMiddleware,
GuestCollaboratorClassReadMiddleware,
GuestPermissionsMiddleware,
IdentityMiddleware,
LiveQueryMiddleware,
@@ -150,6 +151,7 @@ export function createServerPipeline (
PrivateMiddleware.create,
(ctx: MeasureContext, context: PipelineContext, next?: Middleware) =>
SpaceSecurityMiddleware.create(opt.adapterSecurity ?? false, ctx, context, next),
GuestCollaboratorClassReadMiddleware.create,
SpacePermissionsMiddleware.create,
GuestPermissionsMiddleware.create,
ConfigurationMiddleware.create,
@@ -23,6 +23,7 @@ import {
type WorkspaceToken
} from '@hcengineering/api-client'
import core, {
AccountRole,
buildSocialIdString,
concatLink,
generateId,
@@ -45,6 +46,8 @@ import { type AccountClient, getClient as getAccountClient } from '@hcengineerin
import chunter from '@hcengineering/chunter'
import contact, { ensureEmployee, type SocialIdentityRef, type Person } from '@hcengineering/contact'
import { generateToken } from '@hcengineering/server-token'
import { loveClass, LoveMeetingStatus } from '../loveApiRefs'
import WebSocket from 'ws'
describe('rest-api-server', () => {
@@ -338,6 +341,61 @@ describe('rest-api-server', () => {
})
}, 20000)
})
/**
* Requires a workspace account with AccountRole.Guest (invite / guest link) in the same workspace as `user1`.
* Set API_TESTS_GUEST_EMAIL and API_TESTS_GUEST_PASSWORD to enable.
*/
describe('guest meeting-minutes read', () => {
const guestEmail = 'guest1'
const guestPassword = '1234'
it(
'guest findAll does not return meeting minutes without collaborator',
async () => {
const userConn = connect()
const rooms = await userConn.findAll(loveClass.Room, {}, { limit: 1 })
if (rooms.length === 0) {
throw new Error('No love Room in workspace — cannot seed MeetingMinutes')
}
const room = rooms[0]
const tx = await connectTx()
const title = `api-test-mm-${generateId()}`
const mmId = await tx.createDoc(loveClass.MeetingMinutes, core.space.Workspace, {
title,
description: null,
attachedTo: room._id,
attachedToClass: loveClass.Room,
collection: 'meetings',
status: LoveMeetingStatus.Finished
})
try {
const userFound = await userConn.findAll(loveClass.MeetingMinutes, { _id: mmId })
expect(userFound.length).toBe(1)
const guestWs = await getWorkspaceToken(
'http://huly.local:8083',
{
email: guestEmail,
password: guestPassword,
workspace: wsName
},
serverConfig
)
expect(guestWs.info.role).toBe(AccountRole.Guest)
const guestConn = createRestClient(guestWs.endpoint, guestWs.workspaceId, guestWs.token)
const guestFound = await guestConn.findAll(loveClass.MeetingMinutes, { _id: mmId })
expect(guestFound.length).toBe(0)
} finally {
await tx.removeDoc(loveClass.MeetingMinutes, core.space.Workspace, mmId)
}
},
60000
)
})
})
async function checkFindPerformance (conn: RestClient): Promise<void> {
+17
View File
@@ -0,0 +1,17 @@
//
// Class refs for the love plugin — same id shape the model builder emits (`${pluginId}:class:${name}`).
// Use this in API tests without a dependency on `@hcengineering/love`.
//
import type { Class, Doc, Ref } from '@hcengineering/core'
export const loveClass = {
Room: 'love:class:Room' as Ref<Class<Doc>>,
MeetingMinutes: 'love:class:MeetingMinutes' as Ref<Class<Doc>>
}
/** @see `@hcengineering/love` MeetingStatus */
export enum LoveMeetingStatus {
Active = 0,
Finished = 1
}
+5
View File
@@ -32,6 +32,7 @@ echo "Creating user accounts..."
./tool.sh create-account admin -f Super -l Admin -p 1234
./tool.sh create-account user1 -f John -l Appleseed -p 1234
./tool.sh create-account user2 -f Kainin -l Dirak -p 1234
./tool.sh create-account guest1 -f Guest -l One -p 1234
echo "Creating workspace api-tests..."
./tool.sh create-workspace api-tests email:user1
@@ -43,4 +44,8 @@ echo "Assigning user1 to workspaces..."
./tool.sh assign-workspace user1 api-tests
./tool.sh assign-workspace user1 api-tests-cr
echo "Assigning guest1 to api-tests as Guest..."
./tool.sh assign-workspace guest1 api-tests --role Guest
./tool.sh assign-workspace guest1 api-tests-cr --role Guest
rm -rf ./sanity/.auth