feat: Add ability to configure guest permissions (#10708)

* Configure guest permissions

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Fix permission domain

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Fix permission declaration

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Fix lock file

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Guest permissions

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Allow guests to update their own documents

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Add modules order

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Fix translations, icons

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Fix disabled apps and update translations

Signed-off-by: Artem Savchenko <armisav@gmail.com>

---------

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2026-04-05 14:36:59 +07:00
committed by GitHub
parent 8b5b57563e
commit ea254970c0
81 changed files with 1390 additions and 33 deletions
@@ -582,6 +582,19 @@ export interface ClassPermission extends Permission {
targetClass: Ref<Class<Doc>>
}
/**
* @public
*/
export interface ModulePermissionGroup extends Doc {
application: Ref<Doc>
role: AccountRole
permissions: Ref<Permission>[]
disabledPermissions?: Ref<Permission>[]
spaceClass: Ref<Class<Space>>
enabled: boolean
order?: number
}
/**
* @public
*/
@@ -43,6 +43,7 @@ import type {
MarkupBlobRef,
MigrationState,
Mixin,
ModulePermissionGroup,
Obj,
Permission,
PersonId,
@@ -180,7 +181,8 @@ export default plugin(coreId, {
Sequence: '' as Ref<Class<Sequence>>,
CustomSequence: '' as Ref<Class<CustomSequence>>,
ClassCollaborators: '' as Ref<Class<ClassCollaborators<Doc>>>,
Collaborator: '' as Ref<Class<Collaborator>>
Collaborator: '' as Ref<Class<Collaborator>>,
ModulePermissionGroup: '' as Ref<Class<ModulePermissionGroup>>
},
icon: {
TypeString: '' as Asset,
@@ -279,6 +281,7 @@ export default plugin(coreId, {
Account: '' as IntlString,
StatusCategory: '' as IntlString,
Rank: '' as IntlString,
Order: '' as IntlString,
Members: '' as IntlString,
Owners: '' as IntlString,
Permission: '' as IntlString,
@@ -7,10 +7,14 @@ import {
import core, {
type Account,
AccountRole,
type Class,
type Doc,
type ClassPermission,
type Permission,
hasAccountRole,
type MeasureContext,
type PersonId,
type Ref,
type SessionData,
type Space,
type Tx,
@@ -22,7 +26,15 @@ import core, {
import platform, { PlatformError, Severity, Status } from '@hcengineering/platform'
import contact, { type Person } from '@hcengineering/contact'
/** Cached state loaded from GuestPermissionsSettings configuration document. */
interface GuestPermissionsCache {
roleAllowedClasses: Map<AccountRole, Set<Ref<Class<Doc>>>>
}
export class GuestPermissionsMiddleware extends BaseMiddleware implements Middleware {
private permissionsCache: GuestPermissionsCache | undefined = undefined
private initPromise: Promise<void> | undefined = undefined
static async create (
ctx: MeasureContext,
context: PipelineContext,
@@ -31,9 +43,86 @@ export class GuestPermissionsMiddleware extends BaseMiddleware implements Middle
return new GuestPermissionsMiddleware(context, next)
}
private async getPermissionsCache (ctx: MeasureContext): Promise<GuestPermissionsCache> {
if (this.permissionsCache !== undefined) return this.permissionsCache
if (this.initPromise === undefined) {
this.initPromise = this.loadPermissionsCache(ctx)
}
await this.initPromise
this.initPromise = undefined
return this.permissionsCache ?? { roleAllowedClasses: new Map() }
}
private async loadPermissionsCache (ctx: MeasureContext): Promise<void> {
try {
const docs = await this.findAll(ctx, core.class.ModulePermissionGroup, {}, {})
if (docs.length > 0) {
const rolePermissions = new Map<AccountRole, Set<Ref<Permission>>>()
const allPermissionIds = new Set<Ref<Permission>>()
for (const group of docs as any[]) {
if (group.enabled === false) continue
const role = ((group.role as AccountRole | undefined) ??
(Array.isArray(group.roles) && group.roles.length > 0 ? (group.roles[0] as AccountRole) : undefined) ??
AccountRole.Guest) as AccountRole
const permissions = (group.permissions ?? []) as Ref<Permission>[]
const disabled = new Set<Ref<Permission>>((group.disabledPermissions ?? []) as Ref<Permission>[])
const current = rolePermissions.get(role) ?? new Set<Ref<Permission>>()
for (const permissionId of permissions) {
if (disabled.has(permissionId)) continue
current.add(permissionId)
allPermissionIds.add(permissionId)
}
rolePermissions.set(role, current)
}
const classPermissions =
allPermissionIds.size > 0
? await this.findAll(
ctx,
core.class.ClassPermission as Ref<Class<Doc>>,
{ _id: { $in: Array.from(allPermissionIds) } } as any
)
: []
const permissionToClass = new Map<Ref<Permission>, Ref<Class<Doc>>>(
classPermissions
.map(
(permission) => [permission._id as Ref<Permission>, (permission as ClassPermission).targetClass] as const
)
.filter((entry): entry is readonly [Ref<Permission>, Ref<Class<Doc>>] => entry[1] !== undefined)
)
const roleAllowedClasses = new Map<AccountRole, Set<Ref<Class<Doc>>>>()
for (const [role, permissions] of rolePermissions.entries()) {
const allowedClasses = new Set<Ref<Class<Doc>>>()
for (const permissionId of permissions) {
const targetClass = permissionToClass.get(permissionId)
if (targetClass !== undefined) allowedClasses.add(targetClass)
}
roleAllowedClasses.set(role, allowedClasses)
}
this.permissionsCache = { roleAllowedClasses }
} else {
this.permissionsCache = { roleAllowedClasses: new Map() }
}
} catch {
this.permissionsCache = { roleAllowedClasses: new Map() }
}
}
private invalidateCacheIfNeeded (txes: Tx[]): void {
for (const tx of txes) {
if (TxProcessor.isExtendsCUD(tx._class)) {
const cudTx = tx as TxCUD<Doc>
if (cudTx.objectClass === core.class.ModulePermissionGroup) {
this.permissionsCache = undefined
return
}
}
}
}
async tx (ctx: MeasureContext<SessionData>, txes: Tx[]): Promise<TxMiddlewareResult> {
const account = ctx.contextData.account
if (hasAccountRole(account, AccountRole.User)) {
this.invalidateCacheIfNeeded(txes)
return await this.provideTx(ctx, txes)
}
@@ -71,9 +160,64 @@ export class GuestPermissionsMiddleware extends BaseMiddleware implements Middle
}
}
/**
* Returns the covered-class ancestor of the objectClass if one exists in the new permissions model,
* or undefined if the class is not covered.
*/
private getCoveredClass (
objectClass: Ref<Class<Doc>>,
allowedClasses: Set<Ref<Class<Doc>>>
): Ref<Class<Doc>> | undefined {
if (allowedClasses.size === 0) return undefined
const h = this.context.hierarchy
for (const coveredClass of allowedClasses) {
if (h.isDerived(objectClass, coveredClass)) {
return coveredClass
}
}
return undefined
}
private isCreatedByAccount (doc: Doc, account: Account): boolean {
const creator = doc.createdBy
if (creator === undefined) return false
if (creator === account.primarySocialId) return true
return account.socialIds.includes(creator)
}
private async isGuestMutationOnOwnDoc (ctx: MeasureContext, tx: TxCUD<Doc>, account: Account): Promise<boolean> {
if (tx._class !== core.class.TxUpdateDoc && tx._class !== core.class.TxRemoveDoc) return false
const docs = await this.findAll(ctx, tx.objectClass, { _id: tx.objectId }, { limit: 1 })
const doc = docs[0] as Doc | undefined
if (doc === undefined) return false
return this.isCreatedByAccount(doc, account)
}
private async isForbiddenTx (ctx: MeasureContext, tx: TxCUD<Doc>, account: Account): Promise<boolean> {
if (tx._class === core.class.TxMixin) return false
return !(await this.hasMixinAccessLevel(ctx, tx, account))
// For TxCreateDoc, check the new permission model first for covered types.
if (tx._class === core.class.TxCreateDoc) {
const cache = await this.getPermissionsCache(ctx)
const roleAllowedClasses = cache.roleAllowedClasses.get(account.role) ?? new Set<Ref<Class<Doc>>>()
const coveredClass = this.getCoveredClass(tx.objectClass, roleAllowedClasses)
if (coveredClass !== undefined) {
return false
}
// Uncovered class: fall through to TxAccessLevel check.
}
if (await this.hasMixinAccessLevel(ctx, tx, account)) {
return false
}
if (tx._class === core.class.TxUpdateDoc || tx._class === core.class.TxRemoveDoc) {
if (await this.isGuestMutationOnOwnDoc(ctx, tx, account)) {
return false
}
}
return true
}
private async isForbiddenSpaceTx (ctx: MeasureContext, tx: TxCUD<Space>, account: Account): Promise<boolean> {
@@ -0,0 +1,491 @@
//
// 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.
//
/**
* Tests for GuestPermissionsMiddleware
*
* Verifies that:
* - Non-guest users pass through without restriction.
* - DocGuest / ReadOnlyGuest users are always forbidden.
* - For covered classes (resolved from module allowedPermissions):
* new permission model is authoritative; TxAccessLevel is ignored.
* Create in any space → permitted.
* - For uncovered classes: TxAccessLevel fallback is used.
*/
import core, {
AccountRole,
generateId,
Hierarchy,
MeasureMetricsContext,
type Account,
type Class,
type Doc,
type MeasureContext,
type PersonId,
type Ref,
type SessionData,
type Space,
type Tx,
TxFactory
} from '@hcengineering/core'
import type { PipelineContext, TxMiddlewareResult } from '@hcengineering/server-core'
import { GuestPermissionsMiddleware } from '../guestPermissions'
const COVERED_CLASS = 'test:class:CoveredClass' as Ref<Class<Doc>>
const UNCOVERED_CLASS = 'test:class:UncoveredClass' as Ref<Class<Doc>>
const COVERED_CLASS_PERMISSION = 'test:permission:CoveredClassPermission' as Ref<Doc>
const MODULE_PERMISSION_GROUP_CLASS = core.class.ModulePermissionGroup
const ALLOWED_SPACE = 'test:space:Allowed' as Ref<Space>
const FORBIDDEN_SPACE = 'test:space:Forbidden' as Ref<Space>
function makeAccount (role: AccountRole): Account {
return {
uuid: generateId() as any,
role,
primarySocialId: 'test' as PersonId,
socialIds: ['test' as PersonId],
fullSocialIds: []
}
}
function makeCtx (account: Account): MeasureContext<SessionData> {
const ctx = new MeasureMetricsContext('test', {}) as MeasureContext<SessionData>
ctx.contextData = {
account,
broadcast: { txes: [], queue: [], sessions: {} }
} as any
return ctx
}
type FindAllFn = (ctx: MeasureContext, _class: Ref<Class<Doc>>, query: object, options?: object) => Promise<Doc[]>
function makePipelineContext (findAll?: FindAllFn): PipelineContext {
const hierarchy = new Hierarchy()
const model = { findAllSync: (_class: any, _query: any) => [] } as any
return {
workspace: { uuid: 'test-workspace' as any, url: 'test', dataId: 'test' as any },
hierarchy,
modelDb: model,
branding: null as any,
adapterManager: {} as any,
storageAdapter: {} as any,
contextVars: {},
lastTx: '',
lastHash: '',
broadcastEvent: async () => {}
} as any
}
function makeMiddleware (
findAll: FindAllFn,
nextFn?: (ctx: MeasureContext, txes: Tx[]) => Promise<TxMiddlewareResult>
): GuestPermissionsMiddleware {
const context = makePipelineContext(findAll)
const next = nextFn !== undefined ? { tx: nextFn } : { tx: async (_ctx: MeasureContext, _txes: Tx[]) => ({}) }
const mw = new (GuestPermissionsMiddleware as any)(context, next)
// Override findAll to inject our test data
mw.findAll = findAll
return mw
}
function makeCreateTx (objectClass: Ref<Class<Doc>>, objectSpace: Ref<Space>): Tx {
const factory = new TxFactory('test:account:System' as PersonId)
return factory.createTxCreateDoc(objectClass, objectSpace, {})
}
// Helper: buildGuestSettings - simulate the document that loadPermissionsCache would find
function makeGuestSettingsDoc (allowedPermissions: Ref<Doc>[], disabledPermissions?: Ref<Doc>[]): Doc {
return {
_id: generateId(),
_class: MODULE_PERMISSION_GROUP_CLASS,
space: 'core:space:Workspace' as Ref<Space>,
modifiedOn: Date.now(),
modifiedBy: 'test' as PersonId,
application: 'test:app:tracker' as Ref<Doc>,
role: AccountRole.Guest,
permissions: allowedPermissions,
...(disabledPermissions !== undefined && disabledPermissions.length > 0 ? { disabledPermissions } : {}),
spaceClass: 'core:class:Space' as Ref<Class<Doc>>,
enabled: true
} as any
}
describe('GuestPermissionsMiddleware', () => {
// ─── Non-guest users pass through ───────────────────────────────────────────
describe('non-guest users', () => {
it('User role: passes through without restriction', async () => {
let nextCalled = false
const mw = makeMiddleware(
async () => [],
async (ctx, txes) => {
nextCalled = true
return {}
}
)
const tx = makeCreateTx(COVERED_CLASS, FORBIDDEN_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.User))
await mw.tx(ctx, [tx])
expect(nextCalled).toBe(true)
})
it('Owner role: passes through without restriction', async () => {
let nextCalled = false
const mw = makeMiddleware(
async () => [],
async () => {
nextCalled = true
return {}
}
)
const tx = makeCreateTx(COVERED_CLASS, FORBIDDEN_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.Owner))
await mw.tx(ctx, [tx])
expect(nextCalled).toBe(true)
})
})
// ─── DocGuest / ReadOnlyGuest are always forbidden ──────────────────────────
describe('DocGuest and ReadOnlyGuest', () => {
it('DocGuest: throws Forbidden for any tx', async () => {
const mw = makeMiddleware(async () => [])
const tx = makeCreateTx(COVERED_CLASS, ALLOWED_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.DocGuest))
await expect(mw.tx(ctx, [tx])).rejects.toThrow()
})
it('ReadOnlyGuest: throws Forbidden for any tx', async () => {
const mw = makeMiddleware(async () => [])
const tx = makeCreateTx(COVERED_CLASS, ALLOWED_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.ReadOnlyGuest))
await expect(mw.tx(ctx, [tx])).rejects.toThrow()
})
})
// ─── New permission model (covered class) ───────────────────────────────────
describe('covered class new permission model', () => {
const settingsDoc = makeGuestSettingsDoc([COVERED_CLASS_PERMISSION])
const findAllWithSettings: FindAllFn = async (_ctx, _class) => {
if (_class === MODULE_PERMISSION_GROUP_CLASS) return [settingsDoc]
if (_class === core.class.ClassPermission) {
return [{ _id: COVERED_CLASS_PERMISSION, targetClass: COVERED_CLASS } as any]
}
return []
}
function patchHierarchy (mw: GuestPermissionsMiddleware): void {
;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => {
if (b === core.class.Space) return false
return a === b
}
;(mw as any).context.hierarchy.classHierarchyMixin = () => undefined
}
it('allows create for covered class in any space (TxAccessLevel is irrelevant)', async () => {
let nextCalled = false
const mw = makeMiddleware(findAllWithSettings, async () => {
nextCalled = true
return {}
})
patchHierarchy(mw)
const tx = makeCreateTx(COVERED_CLASS, ALLOWED_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.Guest))
await mw.tx(ctx, [tx])
expect(nextCalled).toBe(true)
})
it('also allows create in another space when class is covered', async () => {
let nextCalled = false
const mw = makeMiddleware(findAllWithSettings, async () => {
nextCalled = true
return {}
})
patchHierarchy(mw)
const tx = makeCreateTx(COVERED_CLASS, FORBIDDEN_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.Guest))
await mw.tx(ctx, [tx])
expect(nextCalled).toBe(true)
})
it('ignores permissions listed in disabledPermissions (falls back to TxAccessLevel)', async () => {
const docWithDisabled = makeGuestSettingsDoc([COVERED_CLASS_PERMISSION], [COVERED_CLASS_PERMISSION])
const findAll: FindAllFn = async (_ctx, _class) => {
if (_class === MODULE_PERMISSION_GROUP_CLASS) return [docWithDisabled]
if (_class === core.class.ClassPermission) {
return [{ _id: COVERED_CLASS_PERMISSION, targetClass: COVERED_CLASS } as any]
}
return []
}
const mw = makeMiddleware(findAll)
patchHierarchy(mw)
const tx = makeCreateTx(COVERED_CLASS, ALLOWED_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.Guest))
await expect(mw.tx(ctx, [tx])).rejects.toThrow()
})
})
// ─── Uncovered class falls back to TxAccessLevel ────────────────────────────
describe('uncovered class TxAccessLevel fallback', () => {
it('forbids create when class has no TxAccessLevel mixin and no GuestPermissionsSettings', async () => {
const mw = makeMiddleware(async () => [])
const tx = makeCreateTx(UNCOVERED_CLASS, ALLOWED_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.Guest))
await expect(mw.tx(ctx, [tx])).rejects.toThrow()
})
it('allows create when TxAccessLevel.createAccessLevel === Guest (uncovered type)', async () => {
// Settings exist but UNCOVERED_CLASS is NOT in allowedPermissions-derived classes
const settingsDoc = makeGuestSettingsDoc([COVERED_CLASS_PERMISSION])
let nextCalled = false
const mw = makeMiddleware(
async (_ctx, _class) => {
if (_class === MODULE_PERMISSION_GROUP_CLASS) return [settingsDoc]
if (_class === core.class.ClassPermission) {
return [{ _id: COVERED_CLASS_PERMISSION, targetClass: COVERED_CLASS } as any]
}
return []
},
async () => {
nextCalled = true
return {}
}
)
// Simulate TxAccessLevel mixin via hierarchy mock on the middleware context
;(mw as any).context.hierarchy.classHierarchyMixin = (_class: any, _mixin: any) => {
if (_class === UNCOVERED_CLASS) {
return { createAccessLevel: AccountRole.Guest }
}
return undefined
}
;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => {
if (b === core.class.Space) return false
return a === b
}
const tx = makeCreateTx(UNCOVERED_CLASS, ALLOWED_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.Guest))
await mw.tx(ctx, [tx])
expect(nextCalled).toBe(true)
})
})
// ─── Precedence: covered class ignores TxAccessLevel even if it would deny ──
describe('precedence new model overrides TxAccessLevel for covered types', () => {
it('allows covered class create in allowed space regardless of missing TxAccessLevel', async () => {
const settingsDoc = makeGuestSettingsDoc([COVERED_CLASS_PERMISSION])
let nextCalled = false
const mw = makeMiddleware(
async (_ctx, _class) => {
if (_class === MODULE_PERMISSION_GROUP_CLASS) return [settingsDoc]
if (_class === core.class.ClassPermission) {
return [{ _id: COVERED_CLASS_PERMISSION, targetClass: COVERED_CLASS } as any]
}
return []
},
async () => {
nextCalled = true
return {}
}
)
// Ensure hierarchy says TxAccessLevel is absent for the covered class
;(mw as any).context.hierarchy.classHierarchyMixin = (_class: any, _mixin: any) => undefined
;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => {
if (b === core.class.Space) return false
return a === b
}
const tx = makeCreateTx(COVERED_CLASS, ALLOWED_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.Guest))
await mw.tx(ctx, [tx])
expect(nextCalled).toBe(true)
})
it('allows covered class create in any space even if TxAccessLevel would deny', async () => {
const settingsDoc = makeGuestSettingsDoc([COVERED_CLASS_PERMISSION])
const mw = makeMiddleware(async (_ctx, _class) => {
if (_class === MODULE_PERMISSION_GROUP_CLASS) return [settingsDoc]
if (_class === core.class.ClassPermission) {
return [{ _id: COVERED_CLASS_PERMISSION, targetClass: COVERED_CLASS } as any]
}
return []
})
// TxAccessLevel would allow (createAccessLevel === Guest) should be ignored
;(mw as any).context.hierarchy.classHierarchyMixin = (_class: any, _mixin: any) => {
if (_class === COVERED_CLASS) return { createAccessLevel: AccountRole.Guest }
return undefined
}
;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => {
if (b === core.class.Space) return false
return a === b
}
const tx = makeCreateTx(COVERED_CLASS, FORBIDDEN_SPACE)
const ctx = makeCtx(makeAccount(AccountRole.Guest))
await mw.tx(ctx, [tx])
})
})
// ─── Own-document mutations for guests ───────────────────────────────────────
describe('guest update/remove own documents', () => {
const GUEST_SOCIAL = 'test:guest-social' as PersonId
function makeGuestAccountWithSocial (): Account {
return {
uuid: generateId() as any,
role: AccountRole.Guest,
primarySocialId: GUEST_SOCIAL,
socialIds: [GUEST_SOCIAL],
fullSocialIds: []
}
}
function patchHierarchyNoTxAccessLevel (mw: GuestPermissionsMiddleware): void {
;(mw as any).context.hierarchy.classHierarchyMixin = () => undefined
;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => {
if (b === core.class.Space) return false
return a === b
}
}
it('allows guest to update document created by same account', async () => {
const objectId = generateId()
const findAll: FindAllFn = async (_ctx, _class, query: any) => {
if (_class === UNCOVERED_CLASS && query?._id === objectId) {
return [
{
_id: objectId,
_class: UNCOVERED_CLASS,
space: ALLOWED_SPACE,
modifiedOn: Date.now(),
modifiedBy: GUEST_SOCIAL,
createdBy: GUEST_SOCIAL
} as any
]
}
return []
}
let nextCalled = false
const mw = makeMiddleware(findAll, async () => {
nextCalled = true
return {}
})
patchHierarchyNoTxAccessLevel(mw)
const factory = new TxFactory(GUEST_SOCIAL)
const tx = factory.createTxUpdateDoc(UNCOVERED_CLASS, ALLOWED_SPACE, objectId, { name: 'x' } as any)
await mw.tx(makeCtx(makeGuestAccountWithSocial()), [tx])
expect(nextCalled).toBe(true)
})
it('allows guest to remove document created by same account', async () => {
const objectId = generateId()
const findAll: FindAllFn = async (_ctx, _class, query: any) => {
if (_class === UNCOVERED_CLASS && query?._id === objectId) {
return [
{
_id: objectId,
_class: UNCOVERED_CLASS,
space: ALLOWED_SPACE,
modifiedOn: Date.now(),
modifiedBy: GUEST_SOCIAL,
createdBy: GUEST_SOCIAL
} as any
]
}
return []
}
let nextCalled = false
const mw = makeMiddleware(findAll, async () => {
nextCalled = true
return {}
})
patchHierarchyNoTxAccessLevel(mw)
const factory = new TxFactory(GUEST_SOCIAL)
const tx = factory.createTxRemoveDoc(UNCOVERED_CLASS, ALLOWED_SPACE, objectId)
await mw.tx(makeCtx(makeGuestAccountWithSocial()), [tx])
expect(nextCalled).toBe(true)
})
it('forbids guest to update document created by another account', async () => {
const objectId = generateId()
const otherSocial = 'test:other-social' as PersonId
const findAll: FindAllFn = async (_ctx, _class, query: any) => {
if (_class === UNCOVERED_CLASS && query?._id === objectId) {
return [
{
_id: objectId,
_class: UNCOVERED_CLASS,
space: ALLOWED_SPACE,
modifiedOn: Date.now(),
modifiedBy: otherSocial,
createdBy: otherSocial
} as any
]
}
return []
}
const mw = makeMiddleware(findAll)
patchHierarchyNoTxAccessLevel(mw)
const factory = new TxFactory(GUEST_SOCIAL)
const tx = factory.createTxUpdateDoc(UNCOVERED_CLASS, ALLOWED_SPACE, objectId, { name: 'x' } as any)
await expect(mw.tx(makeCtx(makeGuestAccountWithSocial()), [tx])).rejects.toThrow()
})
})
// ─── Cache invalidation ──────────────────────────────────────────────────────
describe('cache invalidation', () => {
it('invalidates cache when GuestPermissionsSettings is updated', async () => {
const findAll: FindAllFn = async (_ctx, _class) => {
if (_class === MODULE_PERMISSION_GROUP_CLASS) {
return [makeGuestSettingsDoc([COVERED_CLASS_PERMISSION])]
}
if (_class === core.class.ClassPermission) {
return [{ _id: COVERED_CLASS_PERMISSION, targetClass: COVERED_CLASS } as any]
}
return []
}
const mw = makeMiddleware(findAll)
;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => {
if (b === core.class.Space) return false
return a === b
}
;(mw as any).context.hierarchy.classHierarchyMixin = () => undefined
// First tx as guest should load cache
const userCtx = makeCtx(makeAccount(AccountRole.User))
const settingsTx: Tx = {
_id: generateId(),
_class: core.class.TxCreateDoc,
space: core.space.Tx,
modifiedOn: Date.now(),
modifiedBy: 'test' as PersonId,
objectId: generateId(),
objectClass: MODULE_PERMISSION_GROUP_CLASS,
objectSpace: 'core:space:Workspace' as Ref<Space>
} as any
// Owner updates settings should invalidate cache
await mw.tx(userCtx, [settingsTx])
// Cache should be cleared after settings update
expect((mw as any).permissionsCache).toBeUndefined()
})
})
})