mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-17 18:05:42 +02:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user