mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-17 18:05:42 +02:00
Hide meeting minutes from guests
Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
+24
-2
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user