diff --git a/ARCHITECTURE_OVERVIEW.md b/ARCHITECTURE_OVERVIEW.md index dc51049b9a..10ee67ad5c 100644 --- a/ARCHITECTURE_OVERVIEW.md +++ b/ARCHITECTURE_OVERVIEW.md @@ -457,6 +457,7 @@ sequenceDiagram - `DESKTOP_UPDATES_URL`: `https://dist.huly.io` ### Authentication & Security +- Restricted-role (guest) row-level and class-level access model: see [`docs/security-model.md`](docs/security-model.md). - `HULY_TOKEN_SECRET`: `secret` - Token signing for Huly services - `SERVER_SECRET`: `secret` - Service-to-service auth - `ADMIN_EMAILS`: Admin user emails diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 1b7f28e3e0..36b7766ea9 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -4518,6 +4518,9 @@ importers: specifier: ^5.2.2 version: 5.3.2 devDependencies: + '@hcengineering/model-all': + specifier: workspace:^0.7.0 + version: link:../../../../models/all '@hcengineering/platform-rig': specifier: workspace:^0.7.21 version: link:../../../utils/packages/platform-rig diff --git a/docs/security-model.md b/docs/security-model.md new file mode 100644 index 0000000000..1f5320ff50 --- /dev/null +++ b/docs/security-model.md @@ -0,0 +1,129 @@ +# Restricted-role security model + +This model protects data accessed by accounts with a role below `AccountRole.User`: +`ReadOnlyGuest`, `DocGuest`, `Guest`, and any future role ordered below `User`. +It combines a permission to perform an action with a rule that limits the records available to +that account. Accounts with the `User` role or higher are outside this model. + +## Restricted-role threshold + +`roleOrder` in `@hcengineering/core` defines role privilege. A restricted role is any role below +`AccountRole.User`: + +```ts +export const roleOrder: Record = { + [AccountRole.ReadOnlyGuest]: 5, + [AccountRole.DocGuest]: 10, + [AccountRole.Guest]: 20, + [AccountRole.User]: 30, + [AccountRole.Maintainer]: 40, + [AccountRole.Owner]: 50, + [AccountRole.Admin]: 100 +} + +export function isRowLevelRestricted (role: AccountRole): boolean { + return roleOrder[role] < roleOrder[AccountRole.User] +} +``` + +A new role automatically uses this model when its `roleOrder` value is below `User`. + +## Layer 1 — class/action access + +Layer 1 answers: *may this role perform this action on this class?* It combines two sources: + +1. **Admin-configurable**: `ModulePermissionGroup` docs, each listing `Ref`s enabled + for a role; `ClassPermission` resolves a permission to the target class and `Tx` kind it + covers. These are edited in Settings → Guest permissions. +2. **Code-declared, not admin-configurable**: `core.mixin.TxAccessLevel`, giving a class a static + minimum role for create/update/remove: + + ```ts + export interface TxAccessLevel extends Class { + createAccessLevel?: AccountRole + removeAccessLevel?: AccountRole + updateAccessLevel?: AccountRole + isIdentity?: boolean + } + ``` + + `isIdentity: true` additionally lets an account update/mixin a document that *is* its own + identity (a `Person`/`SocialIdentity` matching the caller), independent of `updateAccessLevel`. + +An action is allowed when either source permits it. This layer never decides whether the caller may +access a particular document; that is Layer 2's responsibility. + +## Layer 2 — row visibility + +Layer 2 answers: *which records may this account see or change?* It applies to reads, full-text +search, and mutations after Layer 1 has allowed the class and action. + +The plugin author declares `core.mixin.RowVisibility` next to each protected class. It is a +structural rule, not an administrator setting: + +```ts +export interface RowVisibility extends Class { + policy: RowVisibilityPolicy + writePolicy?: RowVisibilityPolicy // stricter policy for create/update/remove; defaults to policy + allowKnownIdBypass: boolean + knownIdBypassFields?: string[] + scopeActivityToOwner?: boolean +} +``` + +### Policy kinds (`RowVisibilityPolicy`) + +| Kind | Meaning | +| --- | --- | +| `ownerField` | `doc[field]` must equal the caller's resolved identity (`IdentityKind`: `accountUuid` \| `personId` \| `socialId` \| `linkId`). | +| `linkedViaRecord` | Ownership via a separate link record (e.g. `core.class.Collaborator`); optionally chained `through` another class before narrowing the protected document. | +| `spaceMember` | Ordinary membership in a real space provides the restriction. | +| `denyAll` | Access is denied because ownership cannot be verified. | +| `publicReadable` | No additional row restriction applies after Layer 1 and ordinary space checks. A `reason` records why this is safe. | + +### `allowKnownIdBypass` + +When enabled, a `findAll` query narrowed to `_id` or a field from `knownIdBypassFields` may skip the +row policy. Enable it only when that reference can originate from a document the caller is already +allowed to read. It must be disabled when the value is a secret, such as a public-link ID, or when +knowing the identifier does not demonstrate authorization. + +Full-text search and mutations never use this bypass: a caller-provided identifier is not proof of +authorization. + +### `scopeActivityToOwner` + +This opt-in lets `GuestActivitySettings.activityScope` limit activity attached to a personal +document: to the caller's own activity, to activity on documents where they collaborate, or to any +activity they can otherwise read. Do not enable it for shared documents such as channels. + +## Declared policies + +Classes in shared or system spaces need an explicit policy or exemption. Without either, +`SpaceSecurityMiddleware` denies restricted roles by default. + +| Class | Policy | Notes | +| --- | --- | --- | +| `core.class.Collaborator` | `ownerField(collaborator, accountUuid)` | `knownIdBypassFields: ['attachedTo']`; Layer 1 create/remove open from `ReadOnlyGuest`, still gated by `card.ids.GuestCollaboratorClassPermission` (see `canEditDocCollaborator`). | +| `contact.class.SocialIdentity` | `ownerField(attachedTo, personId)` | `allowKnownIdBypass: true`. | +| `love.class.MeetingMinutes` | `linkedViaRecord` via room `Collaborator` | | +| `love.class.RoomInfo`, `love.class.ParticipantInfo` | `linkedViaRecord` via room collaborators, chained `through` `MeetingMinutes` | Same shared policy object (`roomActivityVisibility`). | +| `love.class.PendingRecording` | `linkedViaRecord` via room collaborators | `allowKnownIdBypass: false`. | +| `love.class.Room`, `love.class.Floor` | `publicReadable` | Office layout must render for every guest; the meeting content itself stays collaborator-restricted. | +| `love.class.DevicesPreference` | `ownerField(createdBy, socialId)` | | +| `hr.class.Request` | `ownerField(attachedTo, personId)` | `allowKnownIdBypass: true`, but `attachedTo` itself is excluded from known-id bypass fields. | +| `notification.class.PushSubscription` | `ownerField(user, accountUuid)` | `allowKnownIdBypass: false`. | +| `guest.class.PublicLink` | `ownerField(_id, linkId)` | `allowKnownIdBypass: false` - the id is the session's bearer secret. | +| `process.class.ApproveRequest` | `ownerField(user, personId)` | Layer 1 update open from `ReadOnlyGuest`. | +| `pulse.class.DocumentPresence`, `pulse.class.TypingIndicator` | `publicReadable` | Ephemeral, no business data, expires by TTL. | +| `chunter.class.ChatMessage`, `chunter.class.ThreadMessage` | read `publicReadable` (channel space governs it), write `ownerField(createdBy, socialId)` | | +| `attachment.class.Attachment` | read `publicReadable`, write `ownerField(createdBy, socialId)` | | +| `activity.class.SavedMessage` | `ownerField(createdBy, socialId)` | | +| `card.class.Card` | read `publicReadable` (ordinary space membership), write `ownerField(createdBy, socialId)` | `scopeActivityToOwner: true`. | + +## Extending the model + +- **Restricted role:** add it to `roleOrder` below `AccountRole.User`. +- **Class in a shared or system space:** declare the narrowest suitable `RowVisibilityPolicy` and + add a class/action permission when the role must write it. +- **Identity kind:** extend `IdentityKind` and `AccountIdentityResolver` together. diff --git a/foundations/core/packages/core/src/classes.ts b/foundations/core/packages/core/src/classes.ts index f0feceb209..d59efc255c 100644 --- a/foundations/core/packages/core/src/classes.ts +++ b/foundations/core/packages/core/src/classes.ts @@ -628,26 +628,6 @@ export enum AccountRole { Admin = 'ADMIN' } -/** - * @public - */ -export const roleOrder: Record = { - [AccountRole.ReadOnlyGuest]: 5, - [AccountRole.DocGuest]: 10, - [AccountRole.Guest]: 20, - [AccountRole.User]: 30, - [AccountRole.Maintainer]: 40, - [AccountRole.Owner]: 50, - [AccountRole.Admin]: 100 -} - -export interface TxAccessLevel extends Class { - createAccessLevel?: AccountRole - removeAccessLevel?: AccountRole - updateAccessLevel?: AccountRole - isIdentity?: boolean -} - /** * @public */ diff --git a/foundations/core/packages/core/src/component.ts b/foundations/core/packages/core/src/component.ts index 1b0ec6ef0e..a631acee3d 100644 --- a/foundations/core/packages/core/src/component.ts +++ b/foundations/core/packages/core/src/component.ts @@ -69,7 +69,8 @@ import type { UserStatus, Version } from './classes' -import { AccountRole, TxAccessLevel } from './classes' +import { AccountRole } from './classes' +import { type GuestActivitySettings, type RowVisibility, type TxAccessLevel } from './security' import { type Status, type StatusCategory } from './status' import type { Tx, @@ -183,7 +184,8 @@ export default plugin(coreId, { CustomSequence: '' as Ref>, ClassCollaborators: '' as Ref>>, Collaborator: '' as Ref>, - ModulePermissionGroup: '' as Ref> + ModulePermissionGroup: '' as Ref>, + GuestActivitySettings: '' as Ref> }, icon: { TypeString: '' as Asset, @@ -203,6 +205,7 @@ export default plugin(coreId, { mixin: { ConfigurationElement: '' as Ref>, IndexConfiguration: '' as Ref>>, + RowVisibility: '' as Ref>, SpacesTypeData: '' as Ref>, TransientConfiguration: '' as Ref>, TxAccessLevel: '' as Ref>, diff --git a/foundations/core/packages/core/src/index.ts b/foundations/core/packages/core/src/index.ts index ddafa5c49d..2280ea13fa 100644 --- a/foundations/core/packages/core/src/index.ts +++ b/foundations/core/packages/core/src/index.ts @@ -15,6 +15,7 @@ import core from './component' export * from './classes' +export * from './security' export * from './autoJoinRoles' export * from './client' export * from './collaboration' diff --git a/foundations/core/packages/core/src/security.ts b/foundations/core/packages/core/src/security.ts new file mode 100644 index 0000000000..e3b0035cab --- /dev/null +++ b/foundations/core/packages/core/src/security.ts @@ -0,0 +1,128 @@ +// +// Copyright © 2026 TraceX SAS. +// +// 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 { AccountRole, type Class, type Doc, type Ref } from './classes' + +/** + * Relative ordering of `AccountRole` values, low to high. + * @public + */ +export const roleOrder: Record = { + [AccountRole.ReadOnlyGuest]: 5, + [AccountRole.DocGuest]: 10, + [AccountRole.Guest]: 20, + [AccountRole.User]: 30, + [AccountRole.Maintainer]: 40, + [AccountRole.Owner]: 50, + [AccountRole.Admin]: 100 +} + +/** + * True for any role below `AccountRole.User`. + * @public + */ +export function isRowLevelRestricted (role: AccountRole): boolean { + return roleOrder[role] < roleOrder[AccountRole.User] +} + +/** + * Code-declared minimum role for create, update, and remove operations. + * @public + */ +export interface TxAccessLevel extends Class { + createAccessLevel?: AccountRole + removeAccessLevel?: AccountRole + updateAccessLevel?: AccountRole + isIdentity?: boolean +} + +/** + * Identity used by a row-visibility policy. + * @public + */ +export type IdentityKind = 'accountUuid' | 'personId' | 'socialId' | 'linkId' + +/** + * How a class derives row-level access for restricted roles. + * @public + */ +export type RowVisibilityPolicy = + | { kind: 'ownerField', field: string, identity: IdentityKind } + | { + kind: 'linkedViaRecord' + linkClass: Ref> + linkTargetField: string + linkIdentityField: string + identity: IdentityKind + /** Field on the protected document containing the linked target. Defaults to `_id`. */ + targetField?: string + /** Optionally maps linked ids through another class before narrowing the protected document. */ + through?: { + documentClass: Ref> + sourceField: string + targetField: string + includeDirect?: boolean + } + } + | { kind: 'spaceMember' } + | { kind: 'denyAll' } + | { kind: 'publicReadable', reason: string } + +/** + * Row-level access policy declared on a class. + * @public + */ +export interface RowVisibility extends Class { + policy: RowVisibilityPolicy + /** Optional stricter policy for create/update/remove. Defaults to `policy`. */ + writePolicy?: RowVisibilityPolicy + /** + * Whether a query already narrowing one of `knownIdBypassFields` may skip the policy check, + * trusting the caller obtained that reference from a document it can already see. Must be + * `false` when the referenced value doubles as a secret (e.g. `guest.class.PublicLink._id`). + */ + allowKnownIdBypass: boolean + /** Additional fields that count as known references. */ + knownIdBypassFields?: string[] + /** + * Opts this class into `GuestActivitySettings.activityScope`: restricted-role reads of an + * `AttachedDoc` (chat message, etc.) whose `attachedToClass` is this class get narrowed to the + * caller's own activity or activity on documents it collaborates on, per that setting - instead + * of the attached class's own (often `publicReadable`) policy. Set only on classes meant to be + * "personal" documents (e.g. card.class.Card); leave unset for shared spaces like channels, + * where every member should keep seeing all activity regardless of this setting. + */ + scopeActivityToOwner?: boolean +} + +/** + * Activity visible to a restricted-role account on a personal document. + * @public + */ +export enum GuestActivityScope { + Own = 'own', + Collaborator = 'collaborator', + Any = 'any' +} + +/** + * Per-role setting for activity visibility. Defaults to `GuestActivityScope.Any`. + * @public + */ +export interface GuestActivitySettings extends Doc { + role: AccountRole + /** Activity visibility on classes that opt into this setting. */ + activityScope: GuestActivityScope +} diff --git a/foundations/core/packages/core/src/server.ts b/foundations/core/packages/core/src/server.ts index a1515008ed..33e835cbf4 100644 --- a/foundations/core/packages/core/src/server.ts +++ b/foundations/core/packages/core/src/server.ts @@ -73,6 +73,8 @@ export interface SessionData { } > grant?: PermissionsGrant + /** Raw `extra` claims from the session's token (e.g. `linkId` for public-link guests). */ + extra?: Record asyncRequests?: ((ctx: MeasureContext, id?: string) => Promise)[] } diff --git a/foundations/core/packages/core/src/utils.ts b/foundations/core/packages/core/src/utils.ts index 69d8ac6b37..8a3a681236 100644 --- a/foundations/core/packages/core/src/utils.ts +++ b/foundations/core/packages/core/src/utils.ts @@ -39,7 +39,6 @@ import { type Rank, type Ref, type Role, - roleOrder, type SocialId, SocialIdType, type SocialKey, @@ -51,6 +50,7 @@ import core from './component' import { type Hierarchy } from './hierarchy' import { type TxOperations } from './operations' import { isPredicate } from './predicate' +import { roleOrder } from './security' import { type Branding, type BrandingMap } from './server' import { type DocumentQuery, type FindResult } from './storage' import { DOMAIN_TX, type Tx, type TxCreateDoc, type TxCUD, TxProcessor, type TxUpdateDoc } from './tx' @@ -825,15 +825,6 @@ export function hasAccountRole (acc: Account, targerRole: AccountRole): boolean return roleOrder[acc.role] >= roleOrder[targerRole] } -/** - * Any kind of guest account. Intended to be used by permission resolution code only, - * UI should ask a permission store what the user can do instead of checking roles. - * @public - */ -export function isGuestRole (role: AccountRole): boolean { - return role === AccountRole.Guest || role === AccountRole.DocGuest || role === AccountRole.ReadOnlyGuest -} - /** * Accounts which are not allowed to modify anything at all. Note that DocGuest is not one of * them, a public link guest is restricted by the link itself, not by the role. diff --git a/foundations/server/packages/core/src/utils.ts b/foundations/server/packages/core/src/utils.ts index 6f78ced48a..4de21f367a 100644 --- a/foundations/server/packages/core/src/utils.ts +++ b/foundations/server/packages/core/src/utils.ts @@ -201,7 +201,8 @@ export class SessionDataImpl implements SessionData { } >, readonly service: string, - readonly grant?: PermissionsGrant + readonly grant?: PermissionsGrant, + readonly extra?: Record ) { this._removedMap = _removedMap this._contextCache = _contextCache diff --git a/foundations/server/packages/middleware/package.json b/foundations/server/packages/middleware/package.json index ff1e1f352b..5c3cda43ea 100644 --- a/foundations/server/packages/middleware/package.json +++ b/foundations/server/packages/middleware/package.json @@ -18,6 +18,7 @@ "_phase:validate": "compile validate" }, "devDependencies": { + "@hcengineering/model-all": "workspace:^0.7.0", "@hcengineering/platform-rig": "workspace:^0.7.21", "@typescript-eslint/eslint-plugin": "^6.21.0", "eslint-plugin-import": "^2.26.0", diff --git a/foundations/server/packages/middleware/src/accessGate.ts b/foundations/server/packages/middleware/src/accessGate.ts new file mode 100644 index 0000000000..3eee981c55 --- /dev/null +++ b/foundations/server/packages/middleware/src/accessGate.ts @@ -0,0 +1,202 @@ +// +// Copyright © 2026 TraceX SAS. +// +// Licensed under the PolyForm Shield License 1.0.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://polyformproject.org/licenses/shield/1.0.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. +// + +/** + * Layer 1 write-side gate (see `docs/security-model.md`): which classes/tx kinds a role may reach + * at all. Consolidates the two existing sources - admin-configured + * `ModulePermissionGroup`/`ClassPermission` docs, and the code-declared per-class minimum role + * (`core.mixin.TxAccessLevel`) - behind one resolver, used by `GuestPermissionsMiddleware`. + * + * Role-parameterized throughout (not hardcoded to `AccountRole.Guest`): restricted roles are + * checked against the minimum role declared by each class, while `User` and higher roles bypass + * this gate. + */ +import core, { + type Account, + AccountRole, + type Class, + type ClassPermission, + type Doc, + type Hierarchy, + hasAccountRole, + type MeasureContext, + type Permission, + type PersonId, + type Ref, + type Tx, + type TxCUD +} from '@hcengineering/core' +import type { Middleware } from '@hcengineering/server-core' +import contact, { type Person } from '@hcengineering/contact' + +/** + * Admin-configured (`ModulePermissionGroup`/`ClassPermission`) per-role allowed-class cache, keyed + * by the `Tx` kind the permission covers (`ClassPermission.txClass`, defaulting to `TxCreateDoc` + * for the common "may create this class" case - preserving every existing untyped registration). + */ +export class ClassAccessResolver { + private cache: Map>, Set>>>> | undefined + private loading: Promise | undefined + + constructor (private readonly next: Middleware | undefined) {} + + invalidate (): void { + this.cache = undefined + } + + async allowedClasses (ctx: MeasureContext, role: AccountRole, txClass: Ref>): Promise>>> { + const cache = await this.ensureLoaded(ctx) + return cache.get(role)?.get(txClass) ?? new Set() + } + + async allowedCreateClasses (ctx: MeasureContext, role: AccountRole): Promise>>> { + return await this.allowedClasses(ctx, role, core.class.TxCreateDoc) + } + + private async ensureLoaded ( + ctx: MeasureContext + ): Promise>, Set>>>>> { + if (this.cache !== undefined) return this.cache + if (this.loading === undefined) { + this.loading = this.load(ctx) + } + await this.loading + this.loading = undefined + return this.cache ?? new Map() + } + + private async load (ctx: MeasureContext): Promise { + try { + const docs = ((await this.next?.findAll(ctx, core.class.ModulePermissionGroup, {}, {})) ?? []) as any[] + const rolePermissions = new Map>>() + const allPermissionIds = new Set>() + for (const group of docs) { + if (group.enabled === false) continue + // `roles` (plural) is a legacy shape from before GuestPermissionsSettings had a single `role`. + 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[] + const disabled = new Set>((group.disabledPermissions ?? []) as Ref[]) + const current = rolePermissions.get(role) ?? new Set>() + 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.next?.findAll( + ctx, + core.class.ClassPermission as Ref>, + { + _id: { $in: Array.from(allPermissionIds) } + } as any + ) + : [] + const permissionInfo = new Map, { targetClass: Ref>, txClass: Ref> }>( + ((classPermissions ?? []) as ClassPermission[]) + .filter( + (permission): permission is ClassPermission & { targetClass: Ref> } => + permission.targetClass !== undefined + ) + .map((permission) => [ + permission._id, + { targetClass: permission.targetClass, txClass: permission.txClass ?? core.class.TxCreateDoc } + ]) + ) + const roleAllowedClasses = new Map>, Set>>>>() + for (const [role, permissions] of rolePermissions.entries()) { + const byTxClass = new Map>, Set>>>() + for (const permissionId of permissions) { + const info = permissionInfo.get(permissionId) + if (info === undefined) continue + const targetClasses = byTxClass.get(info.txClass) ?? new Set>>() + targetClasses.add(info.targetClass) + byTxClass.set(info.txClass, targetClasses) + } + roleAllowedClasses.set(role, byTxClass) + } + this.cache = roleAllowedClasses + } catch { + this.cache = new Map() + } + } +} + +function coveredClass ( + hierarchy: Hierarchy, + objectClass: Ref>, + allowedClasses: Set>> +): Ref> | undefined { + for (const candidate of allowedClasses) { + if (hierarchy.isDerived(objectClass, candidate)) return candidate + } + return undefined +} + +/** Whether `tx`'s class grants `account` at least the role required by `core.mixin.TxAccessLevel`. */ +export async function hasClassAccessLevel ( + hierarchy: Hierarchy, + next: Middleware | undefined, + ctx: MeasureContext, + tx: TxCUD, + account: Account +): Promise { + const mixin = hierarchy.classHierarchyMixin(tx.objectClass, core.mixin.TxAccessLevel) + if (mixin === undefined) return false + if (tx._class === core.class.TxCreateDoc) { + return mixin.createAccessLevel !== undefined && hasAccountRole(account, mixin.createAccessLevel) + } + if (tx._class === core.class.TxRemoveDoc) { + return mixin.removeAccessLevel !== undefined && hasAccountRole(account, mixin.removeAccessLevel) + } + if (tx._class === core.class.TxUpdateDoc || tx._class === core.class.TxMixin) { + if (mixin.isIdentity === true && account.socialIds.includes(tx.objectId as unknown as PersonId)) { + return true + } + if (mixin.isIdentity === true && hierarchy.isDerived(tx.objectClass, contact.class.Person)) { + const person = ((await next?.findAll(ctx, tx.objectClass, { _id: tx.objectId }, { limit: 1 })) ?? [])[0] as + | Person + | undefined + return person?.personUuid === account.uuid + } + return mixin.updateAccessLevel !== undefined && hasAccountRole(account, mixin.updateAccessLevel) + } + return false +} + +/** + * Whether `account`'s role may apply `tx` to a non-`Space` class at all (Layer 1), combining the + * admin-configured allow-list with the code-declared `TxAccessLevel` fallback. Does not consider + * ownership of the target document - callers layer declared `RowVisibility` policies on top. + */ +export async function isClassAccessAllowed ( + hierarchy: Hierarchy, + next: Middleware | undefined, + classAccess: ClassAccessResolver, + ctx: MeasureContext, + tx: TxCUD, + account: Account +): Promise { + if (hasAccountRole(account, AccountRole.User)) return true + + const allowed = await classAccess.allowedClasses(ctx, account.role, tx._class) + if (coveredClass(hierarchy, tx.objectClass, allowed) !== undefined) return true + + return await hasClassAccessLevel(hierarchy, next, ctx, tx, account) +} diff --git a/foundations/server/packages/middleware/src/guestPermissions.ts b/foundations/server/packages/middleware/src/guestPermissions.ts index 077d546a84..3fe787ee77 100644 --- a/foundations/server/packages/middleware/src/guestPermissions.ts +++ b/foundations/server/packages/middleware/src/guestPermissions.ts @@ -9,8 +9,7 @@ import core, { AccountRole, type Class, type Doc, - type ClassPermission, - type Permission, + type DocumentQuery, hasAccountRole, type MeasureContext, type PersonId, @@ -20,20 +19,23 @@ import core, { type Tx, type TxApplyIf, type TxCUD, + type TxCreateDoc, TxProcessor, + type TxMixin, type TxUpdateDoc } from '@hcengineering/core' +import contact from '@hcengineering/contact' import platform, { PlatformError, Severity, Status } from '@hcengineering/platform' -import contact, { type Person } from '@hcengineering/contact' +import { ClassAccessResolver, hasClassAccessLevel, isClassAccessAllowed } from './accessGate' +import { AccountIdentityResolver, RowVisibilityResolver } from './rowVisibility' -/** Cached state loaded from GuestPermissionsSettings configuration document. */ -interface GuestPermissionsCache { - roleAllowedClasses: Map>>> -} +// Importing `@hcengineering/process` would pull client-only dependencies into this package. +const APPROVE_REQUEST_CLASS = 'process:class:ApproveRequest' as unknown as Ref> export class GuestPermissionsMiddleware extends BaseMiddleware implements Middleware { - private permissionsCache: GuestPermissionsCache | undefined = undefined - private initPromise: Promise | undefined = undefined + // Use this middleware so overridden `findAll` methods are honored. + private readonly classAccess = new ClassAccessResolver(this) + private readonly rowVisibility = new RowVisibilityResolver(this.next) static async create ( ctx: MeasureContext, @@ -43,80 +45,23 @@ export class GuestPermissionsMiddleware extends BaseMiddleware implements Middle return new GuestPermissionsMiddleware(context, next) } - private async getPermissionsCache (ctx: MeasureContext): Promise { - 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 { - try { - const docs = await this.findAll(ctx, core.class.ModulePermissionGroup, {}, {}) - if (docs.length > 0) { - const rolePermissions = new Map>>() - const allPermissionIds = new Set>() - 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[] - const disabled = new Set>((group.disabledPermissions ?? []) as Ref[]) - const current = rolePermissions.get(role) ?? new Set>() - 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>, - { _id: { $in: Array.from(allPermissionIds) } } as any - ) - : [] - const permissionToClass = new Map, Ref>>( - classPermissions - .map( - (permission) => [permission._id as Ref, (permission as ClassPermission).targetClass] as const - ) - .filter((entry): entry is readonly [Ref, Ref>] => entry[1] !== undefined) - ) - const roleAllowedClasses = new Map>>>() - for (const [role, permissions] of rolePermissions.entries()) { - const allowedClasses = new Set>>() - 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 { + private invalidateCacheIfNeeded (txes: Tx[]): boolean { for (const tx of txes) { + if (tx._class === core.class.TxApplyIf && this.invalidateCacheIfNeeded((tx as TxApplyIf).txes)) { + return true + } if (TxProcessor.isExtendsCUD(tx._class)) { const cudTx = tx as TxCUD - if (cudTx.objectClass === core.class.ModulePermissionGroup) { - this.permissionsCache = undefined - return + if ( + cudTx.objectClass === core.class.ModulePermissionGroup || + cudTx.objectClass === core.class.ClassPermission + ) { + this.classAccess.invalidate() + return true } } } + return false } async tx (ctx: MeasureContext, txes: Tx[]): Promise { @@ -126,10 +71,6 @@ export class GuestPermissionsMiddleware extends BaseMiddleware implements Middle return await this.provideTx(ctx, txes) } - if (account.role === AccountRole.DocGuest || account.role === AccountRole.ReadOnlyGuest) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) - } - for (const tx of txes) { await this.processTx(ctx, tx) } @@ -161,69 +102,88 @@ 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. + * Bypasses `core.class.Collaborator`'s own ownerField policy (which would require the named + * collaborator to be the caller) - gated on `card.ids.GuestCollaboratorClassPermission` plus the + * caller having created the document the collaborator record attaches to. */ - private getCoveredClass ( - objectClass: Ref>, - allowedClasses: Set>> - ): Ref> | 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, account: Account): Promise { - 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 canEditDocCollaborator ( + ctx: MeasureContext, + tx: TxCUD, + account: Account + ): Promise { + const allowed = await this.classAccess.allowedClasses(ctx, account.role, core.class.TxCreateDoc) + if (!allowed.has(core.class.Collaborator)) return false + if (tx.attachedTo === undefined || tx.attachedToClass === undefined) return false + const parents = await this.findAll(ctx, tx.attachedToClass, { _id: tx.attachedTo }, { limit: 1 }) + const parent = parents[0] as (Doc & { createdBy?: PersonId }) | undefined + return parent?.createdBy !== undefined && account.socialIds.includes(parent.createdBy) } - private async isForbiddenTx (ctx: MeasureContext, tx: TxCUD, account: Account): Promise { - if (tx._class === core.class.TxMixin) return false - - // 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>>() - const coveredClass = this.getCoveredClass(tx.objectClass, roleAllowedClasses) - if (coveredClass !== undefined) { - return false - } - // Uncovered class: fall through to TxAccessLevel check. + /** Checks whether a mutation targets a row visible to the caller. */ + private async canMutateVisibleRow ( + ctx: MeasureContext, + tx: TxCUD, + account: Account + ): Promise { + const identity = new AccountIdentityResolver(this.next, ctx, account) + if ( + this.context.hierarchy.isDerived(tx.objectClass, core.class.Collaborator) && + (tx._class === core.class.TxCreateDoc || tx._class === core.class.TxRemoveDoc) + ) { + return await this.canEditDocCollaborator(ctx, tx, account) } - - 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)) { + if (tx._class === core.class.TxCreateDoc) { + if ( + this.context.hierarchy.isDerived(tx.objectClass, contact.class.SocialIdentity) && + !account.socialIds.includes(tx.objectId as unknown as PersonId) + ) { return false } + const doc = TxProcessor.createDoc2Doc(tx as TxCreateDoc) + return await this.rowVisibility.canCreate(ctx, this.context.hierarchy, tx.objectClass, doc, identity) + } + if ( + (tx._class === core.class.TxUpdateDoc || tx._class === core.class.TxMixin) && + this.context.hierarchy.isDerived(tx.objectClass, APPROVE_REQUEST_CLASS) + ) { + const allowed = await this.classAccess.allowedClasses(ctx, account.role, core.class.TxCreateDoc) + if (!allowed.has(APPROVE_REQUEST_CLASS)) return false + } + const query: DocumentQuery = { _id: tx.objectId } + const decision = await this.rowVisibility.resolveMutation( + ctx, + this.context.hierarchy, + tx.objectClass, + query, + identity + ) + if (decision.kind === 'deny') return false + if (decision.kind === 'unrestricted') return true + const docs = await this.findAll(ctx, tx.objectClass, decision.query, { limit: 1 }) + const doc = docs[0] + if (doc === undefined) return false + if (tx._class === core.class.TxUpdateDoc || tx._class === core.class.TxMixin) { + return await this.rowVisibility.canUpdate( + ctx, + this.context.hierarchy, + tx.objectClass, + doc, + tx as TxUpdateDoc | TxMixin, + identity + ) } - return true } + private async isForbiddenTx (ctx: MeasureContext, tx: TxCUD, account: Account): Promise { + if (!(await isClassAccessAllowed(this.context.hierarchy, this, this.classAccess, ctx, tx, account))) return true + return !(await this.canMutateVisibleRow(ctx, tx, account)) + } + private async isForbiddenSpaceTx (ctx: MeasureContext, tx: TxCUD, account: Account): Promise { if (tx._class === core.class.TxRemoveDoc) return true if (tx._class === core.class.TxCreateDoc) { - return !(await this.hasMixinAccessLevel(ctx, tx, account)) + return !(await hasClassAccessLevel(this.context.hierarchy, this, ctx, tx, account)) } if (tx._class === core.class.TxUpdateDoc) { const updateTx = tx as TxUpdateDoc @@ -238,29 +198,4 @@ export class GuestPermissionsMiddleware extends BaseMiddleware implements Middle } return false } - - private async hasMixinAccessLevel (ctx: MeasureContext, tx: TxCUD, account: Account): Promise { - const h = this.context.hierarchy - const accessLevelMixin = h.classHierarchyMixin(tx.objectClass, core.mixin.TxAccessLevel) - if (accessLevelMixin === undefined) return false - if (tx._class === core.class.TxCreateDoc) { - return accessLevelMixin.createAccessLevel === AccountRole.Guest - } - if (tx._class === core.class.TxRemoveDoc) { - return accessLevelMixin.removeAccessLevel === AccountRole.Guest - } - if (tx._class === core.class.TxUpdateDoc) { - if (accessLevelMixin.isIdentity === true && account.socialIds.includes(tx.objectId as unknown as PersonId)) { - return true - } - if (accessLevelMixin.isIdentity === true && h.isDerived(tx.objectClass, contact.class.Person)) { - const person = (await this.findAll(ctx, tx.objectClass, { _id: tx.objectId }, { limit: 1 }))[0] as - | Person - | undefined - return person?.personUuid === account.uuid - } - return accessLevelMixin.updateAccessLevel === AccountRole.Guest - } - return false - } } diff --git a/foundations/server/packages/middleware/src/guestVisibility.ts b/foundations/server/packages/middleware/src/guestVisibility.ts new file mode 100644 index 0000000000..21c6da8fda --- /dev/null +++ b/foundations/server/packages/middleware/src/guestVisibility.ts @@ -0,0 +1,195 @@ +// +// Copyright © 2026 TraceX SAS. +// +// 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. +// + +/** + * Restricted-role-specific visibility helpers used by `SpaceSecurityMiddleware`: hiding the People + * directory down to accounts sharing a real space, and honoring per-role module disablement + * (Settings → Guest permissions). Genuinely role-specific business rules, unlike row-level + * ownership - see `./rowVisibility` for that. + */ +import core, { + type Account, + type AccountUuid, + type Class, + type DocumentQuery, + GuestActivityScope, + type GuestActivitySettings, + type Hierarchy, + type MeasureContext, + type ModulePermissionGroup, + type Ref, + type SessionData, + type Space +} from '@hcengineering/core' +import type { Middleware } from '@hcengineering/server-core' +import contact, { type Person } from '@hcengineering/contact' + +export type SpaceWithMembers = Pick + +/** + * True when `query[field]` already narrows the result to a specific, known set of refs + * (a bare value, or `{ $in: [...] }` with nothing else combined on the same key). Any other + * shape (`$ne`, `$nin`, `$gt`, the field missing entirely, etc.) is treated as an open + * browse/search query for the purposes of guest visibility restrictions. + */ +export function hasNarrowFieldQuery (query: Record | null | undefined, field: string): boolean { + const val = query?.[field] + if (val === undefined) return false + if (typeof val === 'string') return true + if (Array.isArray(val)) return false + if (typeof val === 'object' && val !== null) { + const keys = Object.keys(val) + return keys.length === 1 && keys[0] === '$in' && Array.isArray(val.$in) + } + return false +} + +export function hasNarrowIdQuery (query: Record): boolean { + return hasNarrowFieldQuery(query, '_id') +} + +/** + * Accounts the given (restricted-role) account is allowed to know people from: itself, plus + * every member of every real space it belongs to. Deliberately excludes `mainSpaces` and + * `systemSpaces` (e.g. the shared `contact.space.Contacts`) since those would defeat the + * restriction for every role that reaches this helper - callers pass in only the real-space + * membership state (`SpaceSecurityMiddleware`'s `allowedSpaces`/`spacesMap`). + */ +export function getGuestVisibleAccounts ( + account: Account, + allowedSpaces: Record[]>, + spacesMap: Map, SpaceWithMembers> +): Set { + const accounts = new Set([account.uuid]) + const userSpaces = allowedSpaces[account.uuid] ?? [] + for (const spaceId of userSpaces) { + const space = spacesMap.get(spaceId) + if (space === undefined) continue + for (const member of space.members) { + accounts.add(member) + } + } + return accounts +} + +/** + * Resolves `getGuestVisibleAccounts` into the set of `Person` refs a restricted-role account + * may discover via open browse/search queries. + */ +export async function getGuestVisiblePersonIds ( + next: Middleware | undefined, + ctx: MeasureContext, + account: Account, + allowedSpaces: Record[]>, + spacesMap: Map, SpaceWithMembers> +): Promise>> { + const accounts = getGuestVisibleAccounts(account, allowedSpaces, spacesMap) + if (accounts.size === 0) return new Set() + const personQuery: DocumentQuery = { personUuid: { $in: Array.from(accounts) } } + const persons = ((await next?.findAll(ctx, contact.class.Person, personQuery, { + projection: { _id: 1 } + })) ?? []) as Array> + return new Set(persons.map((p) => p._id)) +} + +/** + * Settings → Guest permissions lets an admin disable a whole module/application for a role via + * `ModulePermissionGroup.enabled`. Today that only hides the app icon in the sidebar and gates + * write transactions (`GuestPermissionsMiddleware`) — search/mention never consulted it, so a + * disabled module's objects (e.g. Products cards) still turned up in the @-mention picker. + * Returns the `Space` classes (`ModulePermissionGroup.spaceClass`) disabled for the caller's role. + */ +export async function getDisabledModuleSpaceClasses ( + next: Middleware | undefined, + ctx: MeasureContext, + account: Account +): Promise>>> { + const groupQuery: DocumentQuery = { role: account.role, enabled: false } + const groups = ((await next?.findAll(ctx, core.class.ModulePermissionGroup, groupQuery, { + projection: { spaceClass: 1 } + })) ?? []) as Array> + const classes = new Set>>() + for (const group of groups) { + if (group.spaceClass !== undefined) { + classes.add(group.spaceClass) + } + } + return classes +} + +/** Resolves `getDisabledModuleSpaceClasses`'s class refs into concrete space ids, using the + * space-membership tracking state (`spacesMap`) `SpaceSecurityMiddleware` already maintains. */ +export function resolveDisabledModuleSpaceIds ( + hierarchy: Hierarchy, + disabledClasses: Set>>, + spacesMap: Map, SpaceWithMembers> +): Set> { + const ids = new Set>() + if (disabledClasses.size === 0) return ids + for (const space of spacesMap.values()) { + for (const spaceClass of disabledClasses) { + if (hierarchy.isDerived(space._class, spaceClass)) { + ids.add(space._id) + break + } + } + } + return ids +} + +/** + * Excludes `excluded` space ids from a space-field query condition (the `space` / `objectSpace` / + * `_id` field `SpaceSecurityMiddleware.findAll` narrows queries to, depending on domain), whatever + * shape it is already in (`undefined`, a bare ref, or an object with `$in`/`$nin`/other + * operators). Returns `{ deny: true }` when the exclusion would leave no space eligible at all. + */ +export function excludeSpacesFromQuery ( + current: Record | Ref | undefined, + excluded: Set> +): { query: Record | Ref | undefined } | { deny: true } { + if (excluded.size === 0) return { query: current } + if (current === undefined) { + return { query: { $nin: Array.from(excluded) } } + } + if (typeof current !== 'object' || current === null) { + return excluded.has(current) ? { deny: true } : { query: current } + } + if (Array.isArray(current.$in)) { + const filtered = (current.$in as Ref[]).filter((id) => !excluded.has(id)) + if (filtered.length === 0) return { deny: true } + return { query: { ...current, $in: filtered } } + } + const existingNin = new Set>((current.$nin as Ref[] | undefined) ?? []) + for (const id of excluded) existingNin.add(id) + return { query: { ...current, $nin: Array.from(existingNin) } } +} + +/** + * Resolves the caller's `core.class.GuestActivitySettings` doc (one per role). Defaults to + * `GuestActivityScope.Any` - today's unrestricted-activity behavior - when no doc exists yet for + * the role. + */ +export async function resolveGuestActivityScope ( + next: Middleware | undefined, + ctx: MeasureContext, + account: Account +): Promise { + const query: DocumentQuery = { role: account.role } + const docs = ((await next?.findAll(ctx, core.class.GuestActivitySettings, query, { limit: 1 })) ?? + []) as GuestActivitySettings[] + return docs[0]?.activityScope ?? GuestActivityScope.Any +} + +// Row-level ownership restriction (Layer 2) now lives in `./rowVisibility`. diff --git a/foundations/server/packages/middleware/src/rowVisibility.ts b/foundations/server/packages/middleware/src/rowVisibility.ts new file mode 100644 index 0000000000..d61ec26070 --- /dev/null +++ b/foundations/server/packages/middleware/src/rowVisibility.ts @@ -0,0 +1,318 @@ +// +// Copyright © 2026 TraceX SAS. +// +// Licensed under the PolyForm Shield License 1.0.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://polyformproject.org/licenses/shield/1.0.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. +// + +/** + * Row-level ownership resolution (Layer 2, see `docs/security-model.md` and + * `core.mixin.RowVisibility` / `RowVisibilityPolicy`) for classes not scoped by ordinary + * space-based filtering. + * + * Deliberately role-agnostic: nothing here compares `account.role`. Which accounts this runs for + * is entirely up to the call site (`isRowLevelRestricted(account.role)` in `spaceSecurity.ts`). + */ +import core, { + type Account, + type Class, + type Doc, + type DocumentQuery, + type Hierarchy, + type IdentityKind, + type MeasureContext, + type Ref, + type RowVisibilityPolicy, + type SessionData, + type TxMixin, + type TxUpdateDoc, + TxProcessor +} from '@hcengineering/core' +import type { Middleware } from '@hcengineering/server-core' +import contact, { type Person } from '@hcengineering/contact' +import { hasNarrowFieldQuery } from './guestVisibility' + +/** Lazily resolves the identities used by row-visibility policies. */ +export class AccountIdentityResolver { + private personIdPromise: Promise | undefined> | undefined + + constructor ( + private readonly next: Middleware | undefined, + private readonly ctx: MeasureContext, + private readonly account: Account + ) {} + + /** The caller's own `contact.class.Person` ref, if any. */ + async personId (): Promise | undefined> { + if (this.personIdPromise === undefined) { + this.personIdPromise = (async () => { + const personQuery: DocumentQuery = { personUuid: this.account.uuid } + const persons = ((await this.next?.findAll(this.ctx, contact.class.Person, personQuery, { + projection: { _id: 1 }, + limit: 1 + })) ?? []) as Array> + return persons[0]?._id + })() + } + return await this.personIdPromise + } + + /** Public-link identity from the session token. */ + linkId (): Ref | undefined { + return this.ctx.contextData.extra?.linkId as Ref | undefined + } + + /** All social identities linked to the account. */ + async resolve (kind: IdentityKind): Promise { + switch (kind) { + case 'accountUuid': + return this.account.uuid + case 'personId': + return await this.personId() + case 'socialId': + return this.account.socialIds.length === 1 ? this.account.socialIds[0] : this.account.socialIds + case 'linkId': + return this.linkId() + } + } +} + +/** Matches a field against either a single identity or a set of social identities. */ +function identityMatches (fieldValue: unknown, resolved: string | string[] | undefined): boolean { + if (Array.isArray(resolved)) return resolved.length > 0 && resolved.includes(fieldValue as string) + return resolved !== undefined && fieldValue === resolved +} + +export type RowVisibilityDecision = + | { kind: 'unrestricted' } + | { kind: 'narrow', query: DocumentQuery } + | { kind: 'deny' } + +interface MutationAwareVisibility { + policy: RowVisibilityPolicy + writePolicy?: RowVisibilityPolicy +} + +function getWritePolicy (mixin: MutationAwareVisibility): RowVisibilityPolicy { + return mixin.writePolicy ?? mixin.policy +} + +/** Intersects a query field constraint with a required value. */ +function mergeEquals (query: DocumentQuery, field: string, value: any): DocumentQuery | undefined { + const current = (query as Record)[field] + if (current === undefined) { + return { ...query, [field]: value } + } + if (typeof current === 'object' && current !== null && Array.isArray(current.$in)) { + return (current.$in as any[]).includes(value) ? { ...query, [field]: value } : undefined + } + if (typeof current !== 'object') { + return current === value ? query : undefined + } + return { ...query, [field]: value } +} + +/** Narrows a query field to a set of allowed values. */ +function mergeIn ( + query: DocumentQuery, + field: string, + values: Set +): DocumentQuery | undefined { + const current = (query as Record)[field] + if (current === undefined) { + return { ...query, [field]: { $in: Array.from(values) } } + } + if (typeof current === 'object' && current !== null && Array.isArray(current.$in)) { + const filtered = (current.$in as any[]).filter((v) => values.has(v)) + return filtered.length === 0 ? undefined : { ...query, [field]: { $in: filtered } } + } + if (typeof current !== 'object') { + return values.has(current) ? query : undefined + } + return { ...query, [field]: { $in: Array.from(values) } } +} + +/** Applies `core.mixin.RowVisibility` (if declared) to a `findAll` query. */ +export class RowVisibilityResolver { + constructor (private readonly next: Middleware | undefined) {} + + hasPolicy (hierarchy: Hierarchy, _class: Ref>): boolean { + // Some focused middleware tests use a minimal hierarchy double that only implements the + // methods exercised by the scenario. A real Hierarchy always provides this method. + if (typeof hierarchy.classHierarchyMixin !== 'function') return false + return hierarchy.classHierarchyMixin(_class, core.mixin.RowVisibility) !== undefined + } + + async resolve( + ctx: MeasureContext, + hierarchy: Hierarchy, + _class: Ref>, + query: DocumentQuery, + identity: AccountIdentityResolver, + allowKnownIdBypass = true + ): Promise> { + if (typeof hierarchy.classHierarchyMixin !== 'function') { + return { kind: 'unrestricted' } + } + const mixin = hierarchy.classHierarchyMixin(_class as Ref>, core.mixin.RowVisibility) + if (mixin === undefined) { + return { kind: 'unrestricted' } + } + + if (allowKnownIdBypass && mixin.allowKnownIdBypass) { + const bypassFields = ['_id', ...(mixin.knownIdBypassFields ?? [])] + if (bypassFields.some((field) => hasNarrowFieldQuery(query, field))) { + return { kind: 'unrestricted' } + } + } + + return await this.applyPolicy(ctx, mixin.policy, query, identity) + } + + /** Resolves the policy used for update/remove without allowing known-reference bypasses. */ + async resolveMutation( + ctx: MeasureContext, + hierarchy: Hierarchy, + _class: Ref>, + query: DocumentQuery, + identity: AccountIdentityResolver + ): Promise> { + if (typeof hierarchy.classHierarchyMixin !== 'function') return { kind: 'unrestricted' } + const mixin = hierarchy.classHierarchyMixin(_class as Ref>, core.mixin.RowVisibility) + if (mixin === undefined) return { kind: 'unrestricted' } + return await this.applyPolicy(ctx, getWritePolicy(mixin), query, identity) + } + + /** Validates ownership fields on a document before it exists in storage. */ + async canCreate ( + ctx: MeasureContext, + hierarchy: Hierarchy, + _class: Ref>, + doc: Doc, + identity: AccountIdentityResolver + ): Promise { + if (typeof hierarchy.classHierarchyMixin !== 'function') return true + const mixin = hierarchy.classHierarchyMixin(_class, core.mixin.RowVisibility) + if (mixin?.policy === undefined) return true + const policy = getWritePolicy(mixin) + + switch (policy.kind) { + case 'ownerField': { + const value = await identity.resolve(policy.identity) + return identityMatches((doc as unknown as Record)[policy.field], value) + } + case 'spaceMember': + case 'publicReadable': + return true + case 'linkedViaRecord': + case 'denyAll': + return false + } + } + + /** Ensures an update or mixin extension cannot transfer a row to another owner. */ + async canUpdate ( + ctx: MeasureContext, + hierarchy: Hierarchy, + _class: Ref>, + doc: Doc, + tx: TxUpdateDoc | TxMixin, + identity: AccountIdentityResolver + ): Promise { + if (typeof hierarchy.classHierarchyMixin !== 'function') return true + const mixin = hierarchy.classHierarchyMixin(_class, core.mixin.RowVisibility) + if (mixin === undefined) return true + const policy = getWritePolicy(mixin) + if (policy.kind !== 'ownerField' && policy.kind !== 'linkedViaRecord') return true + + const updated = + tx._class === core.class.TxMixin + ? TxProcessor.updateMixin4Doc({ ...doc }, tx as TxMixin) + : TxProcessor.updateDoc2Doc({ ...doc }, tx as TxUpdateDoc) + const updatedFields = updated as unknown as Record + + if (policy.kind === 'ownerField') { + const value = await identity.resolve(policy.identity) + return identityMatches(updatedFields[policy.field], value) + } + + const allowed = await this.resolveLinkedTargets(ctx, policy, identity) + if (allowed === undefined) return false + return allowed.has(updatedFields[policy.targetField ?? '_id'] as Ref) + } + + /** Resolves the targets accessible through a `linkedViaRecord` policy. */ + private async resolveLinkedTargets ( + ctx: MeasureContext, + policy: Extract, + identity: AccountIdentityResolver + ): Promise> | undefined> { + const value = await identity.resolve(policy.identity) + if (value === undefined || (Array.isArray(value) && value.length === 0)) return undefined + const linkQuery: DocumentQuery = { + [policy.linkIdentityField]: Array.isArray(value) ? { $in: value } : value + } + const projection: Record = { [policy.linkTargetField]: 1 } + const links = ((await this.next?.findAll(ctx, policy.linkClass, linkQuery, { projection })) ?? []) as Array< + Record> + > + const linkedTargets = new Set>(links.map((l) => l[policy.linkTargetField])) + if (linkedTargets.size === 0) return undefined + let allowed = linkedTargets + const through = policy.through + if (through !== undefined) { + const throughQuery: DocumentQuery = { + [through.sourceField]: { $in: Array.from(linkedTargets) } + } + const throughProjection: Record = { [through.targetField]: 1 } + const throughDocs = ((await this.next?.findAll(ctx, through.documentClass, throughQuery, { + projection: throughProjection + })) ?? []) as Array>> + allowed = new Set>(throughDocs.map((doc) => doc[through.targetField])) + if (through.includeDirect === true) { + for (const target of linkedTargets) allowed.add(target) + } + } + return allowed + } + + private async applyPolicy( + ctx: MeasureContext, + policy: RowVisibilityPolicy, + query: DocumentQuery, + identity: AccountIdentityResolver + ): Promise> { + switch (policy.kind) { + case 'spaceMember': + case 'publicReadable': + return { kind: 'unrestricted' } + + case 'denyAll': + return { kind: 'deny' } + + case 'ownerField': { + const value = await identity.resolve(policy.identity) + if (value === undefined || (Array.isArray(value) && value.length === 0)) return { kind: 'deny' } + const merged = Array.isArray(value) + ? mergeIn(query, policy.field, new Set(value)) + : mergeEquals(query, policy.field, value) + return merged === undefined ? { kind: 'deny' } : { kind: 'narrow', query: merged } + } + + case 'linkedViaRecord': { + const allowed = await this.resolveLinkedTargets(ctx, policy, identity) + if (allowed === undefined) return { kind: 'deny' } + const merged = mergeIn(query, policy.targetField ?? '_id', allowed) + return merged === undefined ? { kind: 'deny' } : { kind: 'narrow', query: merged } + } + } + } +} diff --git a/foundations/server/packages/middleware/src/spaceSecurity.ts b/foundations/server/packages/middleware/src/spaceSecurity.ts index 4c8bd4223e..c156d894c1 100644 --- a/foundations/server/packages/middleware/src/spaceSecurity.ts +++ b/foundations/server/packages/middleware/src/spaceSecurity.ts @@ -27,18 +27,22 @@ import core, { type FindResult, generateId, getClassCollaborators, + GuestActivityScope, type LookupData, type MeasureContext, type ObjQueryType, + type PersonId, type Position, type PullArray, type Ref, type SearchOptions, type SearchQuery, + type SearchResultDoc, type SearchResult, type SessionData, shouldShowArchived, type Space, + isRowLevelRestricted, systemAccountUuid, toFindResult, type Tx, @@ -57,10 +61,19 @@ import { type ServerFindOptions, type TxMiddlewareResult } from '@hcengineering/server-core' +import contact, { type Person } from '@hcengineering/contact' +import { + excludeSpacesFromQuery, + getDisabledModuleSpaceClasses, + getGuestVisiblePersonIds, + hasNarrowIdQuery, + resolveDisabledModuleSpaceIds, + resolveGuestActivityScope, + type SpaceWithMembers +} from './guestVisibility' +import { AccountIdentityResolver, RowVisibilityResolver } from './rowVisibility' import { isOwner, isSystem } from './utils' -type SpaceWithMembers = Pick - /** * @public */ @@ -71,6 +84,7 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar private readonly _domainSpaces = new Map> | Promise>>>() private readonly publicSpaces = new Set>() private readonly systemSpaces = new Set>() + private readonly rowVisibility = new RowVisibilityResolver(this.next) wasInit: Promise | boolean = false @@ -533,7 +547,7 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar ): Ref[] { const userSpaces = this.allowedSpaces[account.uuid] ?? [] let res = [...Array.from(userSpaces), account.uuid as unknown as Ref, ...this.mainSpaces] - if (!forSearch || ![AccountRole.Guest, AccountRole.ReadOnlyGuest].includes(account.role)) { + if (!forSearch || ![AccountRole.Guest, AccountRole.ReadOnlyGuest, AccountRole.DocGuest].includes(account.role)) { res = [...res, ...this.systemSpaces] } const ignorePublicSpaces = isData || account.role === AccountRole.ReadOnlyGuest @@ -624,6 +638,7 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar const domain = this.context.hierarchy.getDomain(_class) const newQuery = clone(query) const account = ctx.contextData.account + const isRestricted = isRowLevelRestricted(account.role) const isSpace = this.context.hierarchy.isDerived(_class, core.class.Space) const field = this.getKey(domain) const showArchived: boolean = shouldShowArchived(newQuery, options) @@ -679,7 +694,103 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar } } - let findResult = await this.provideFindAll(ctx, _class, !this.skipFindCheck ? newQuery : query, options) + // Person/Employee visibility for Guest / ReadOnlyGuest / DocGuest: restrict open browse/search + // queries to accounts sharing a real space with the caller. Applied on top of whichever query + // object the backend actually executes (`skipFindCheck` deployments rely on the DB adapter for + // space-level security and pass the original `query` through untouched, so the restriction is + // layered on there too, not only on `newQuery`). + let baseQuery: DocumentQuery = !this.skipFindCheck ? newQuery : query + if ( + !isSystem(account, ctx) && + isRestricted && + this.context.hierarchy.isDerived(_class, contact.class.Person) && + !hasNarrowIdQuery(baseQuery) + ) { + const allowedPersonIds = await getGuestVisiblePersonIds( + this.next, + ctx, + account, + this.allowedSpaces, + this.spacesMap + ) + if (allowedPersonIds.size === 0) { + return toFindResult([], 0) + } + const restrictedQuery: DocumentQuery = { ...baseQuery, _id: { $in: Array.from(allowedPersonIds) } } + baseQuery = restrictedQuery + } + + // A class without an explicit policy is allowed to use ordinary real-space membership only. + // Shared and system spaces are visible to many accounts by construction, so treating them as + // ordinary membership would turn a forgotten policy into a data leak. Keep Person on its + // dedicated visibility path above until its relationship-based policy is modelled explicitly. + if ( + !isSystem(account, ctx) && + isRestricted && + domain !== DOMAIN_MODEL && + !this.context.hierarchy.isDerived(_class, contact.class.Person) && + !this.rowVisibility.hasPolicy(this.context.hierarchy, _class as Ref>) + ) { + const nonOrdinarySpaces = new Set>([...this.mainSpaces, ...this.systemSpaces]) + const excluded = excludeSpacesFromQuery((baseQuery as Record)[field], nonOrdinarySpaces) + if ('deny' in excluded) { + return toFindResult([], 0) + } + baseQuery = { ...baseQuery } + if (excluded.query === undefined) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete (baseQuery as Record)[field] + } else { + ;(baseQuery as Record)[field] = excluded.query + } + } + + // Row-level ownership (Layer 2) for classes living in mainSpaces - see `./rowVisibility`. + if (!isSystem(account, ctx) && isRestricted) { + const identity = new AccountIdentityResolver(this.next, ctx, account) + const decision = await this.rowVisibility.resolve(ctx, this.context.hierarchy, _class, baseQuery, identity) + if (decision.kind === 'deny') { + return toFindResult([], 0) + } + if (decision.kind === 'narrow') { + baseQuery = decision.query + } + } + + // A whole application/module can be turned off per role in Settings → Guest permissions + // (`ModulePermissionGroup.enabled`). That used to only hide the sidebar icon, gate writes, and + // (searchFulltext below) exclude the module from @-mention/search results — plain `findAll` + // reads (e.g. opening a card by direct navigation) were untouched, so a guest who still + // happened to be a member of the module's space (e.g. via auto-join) could still read its + // documents. Excluding the module's spaces from the space field here closes that gap for both + // `core.class.Space`-derived classes (field `_id`) and ordinary content classes (field + // `space`/`objectSpace`) — unlike the Person/sensitive-class restrictions above, this is a + // blanket exclusion with no known-ref bypass: a disabled module means no read access to it. + if (!isSystem(account, ctx) && isRestricted && domain !== DOMAIN_MODEL) { + const disabledSpaceClasses = await getDisabledModuleSpaceClasses(this.next, ctx, account) + const disabledSpaceIds = resolveDisabledModuleSpaceIds( + this.context.hierarchy, + disabledSpaceClasses, + this.spacesMap + ) + if (disabledSpaceIds.size > 0) { + const current = (baseQuery as Record)[field] + const excluded = excludeSpacesFromQuery(current, disabledSpaceIds) + if ('deny' in excluded) { + return toFindResult([], 0) + } + const updatedQuery: DocumentQuery = { ...baseQuery } + if (excluded.query === undefined) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete (updatedQuery as Record)[field] + } else { + ;(updatedQuery as Record)[field] = excluded.query + } + baseQuery = updatedQuery + } + } + + let findResult = await this.provideFindAll(ctx, _class, baseQuery, options) if (clientFilterSpaces !== undefined) { const cfs = clientFilterSpaces findResult = toFindResult( @@ -688,6 +799,13 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar findResult.lookupMap ) } + if (!isSystem(account, ctx) && isRestricted && this.context.hierarchy.isDerived(_class, core.class.AttachedDoc)) { + const activityScope = await resolveGuestActivityScope(this.next, ctx, account) + if (activityScope !== GuestActivityScope.Any) { + const filtered = await this.filterActivityByScope(ctx, findResult, account, activityScope) + findResult = toFindResult(filtered, filtered.length, findResult.lookupMap) + } + } if (account.role !== AccountRole.DocGuest) { if (options?.lookup !== undefined) { for (const object of findResult) { @@ -708,6 +826,8 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar await this.init(ctx) const newQuery = { ...query } const account = ctx.contextData.account + const personRestricted = isRowLevelRestricted(account.role) + let personClassesSearched = false if (!isSystem(account, ctx)) { const allSpaces = this.getAllAllowedSpaces(account, true, false, true) if (query.classes !== undefined) { @@ -715,6 +835,19 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar const passedDomains = new Set() for (const _class of query.classes) { const domain = this.context.hierarchy.getDomain(_class) + const isPersonClass = this.context.hierarchy.isDerived(_class, contact.class.Person) + if (isPersonClass) { + personClassesSearched = true + } + if (personRestricted && isPersonClass) { + // contact.space.Contacts is a SystemSpace, normally excluded from `allSpaces` here + // (see getAllAllowedSpaces's forSearch branch) specifically for these roles — which is + // what makes the @-mention/People search come back empty for guests today. Let it + // through for Person/Employee classes only; the actual visibility restriction is + // enforced below, on the results, via getGuestVisiblePersonIds. + res.add(contact.space.Contacts) + continue + } if (passedDomains.has(domain)) { continue } @@ -726,13 +859,124 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar } newQuery.spaces = [...res] } else { + // Unscoped search: we don't know ahead of time whether the index will return Person/ + // Employee docs, so treat it as if it did - the result-level filter below is the actual + // backstop and must not depend on the space-exclusion happening to already cover it. + if (personRestricted) { + personClassesSearched = true + } newQuery.spaces = allSpaces } + + // Drop spaces belonging to a module/application the caller's role has disabled in + // Settings → Guest permissions, so a disabled module's cards stop turning up in + // search/mention results (previously only the sidebar icon and writes were gated). + const disabledSpaceClasses = await getDisabledModuleSpaceClasses(this.next, ctx, account) + const disabledSpaceIds = resolveDisabledModuleSpaceIds( + this.context.hierarchy, + disabledSpaceClasses, + this.spacesMap + ) + if (disabledSpaceIds.size > 0 && newQuery.spaces !== undefined) { + newQuery.spaces = newQuery.spaces.filter((s) => !disabledSpaceIds.has(s)) + } } const result = await this.provideSearchFulltext(ctx, newQuery, options) + if (personRestricted && personClassesSearched && !isSystem(account, ctx)) { + const allowedPersonIds = await getGuestVisiblePersonIds( + this.next, + ctx, + account, + this.allowedSpaces, + this.spacesMap + ) + result.docs = result.docs.filter( + (d) => + !this.context.hierarchy.isDerived(d.doc._class, contact.class.Person) || + allowedPersonIds.has(d.doc._id as Ref) + ) + } + if (personRestricted && !isSystem(account, ctx)) { + const identity = new AccountIdentityResolver(this.next, ctx, account) + result.docs = await this.filterSearchResultsByRowVisibility(ctx, result.docs, identity) + result.total = result.docs.length + } return result } + /** + * Full-text results contain only a small document projection, so an owner/link policy cannot + * be evaluated from the result itself. Re-query each candidate through the resolved policy. + * Known-id bypasses are intentionally disabled: a search result is not proof that the caller + * obtained its id from an already-authorized document. + */ + private async filterSearchResultsByRowVisibility ( + ctx: MeasureContext, + docs: SearchResultDoc[], + identity: AccountIdentityResolver + ): Promise { + const visible = await Promise.all( + docs.map(async (result) => { + const _class = result.doc._class + if (!this.rowVisibility.hasPolicy(this.context.hierarchy, _class)) { + const query: DocumentQuery = { _id: result.doc._id } + const matching = await this.findAll(ctx, _class, query, { + limit: 1 + }) + return matching.length > 0 + } + const query: DocumentQuery = { _id: result.doc._id } + const decision = await this.rowVisibility.resolve(ctx, this.context.hierarchy, _class, query, identity, false) + if (decision.kind === 'deny') return false + if (decision.kind === 'unrestricted') return true + const matching = await this.provideFindAll(ctx, _class, decision.query, { limit: 1 }) + return matching.length > 0 + }) + ) + return docs.filter((_doc, index) => visible[index]) + } + + /** Narrows activity results per `GuestActivitySettings.activityScope`, only for classes that + * opted in via `RowVisibility.scopeActivityToOwner` (e.g. card.class.Card). */ + private async filterActivityByScope( + ctx: MeasureContext, + docs: T[], + account: Account, + scope: GuestActivityScope + ): Promise { + if (docs.length === 0) return docs + const passthrough: T[] = [] + const scoped: T[] = [] + for (const doc of docs) { + const attachedToClass = (doc as unknown as AttachedDoc).attachedToClass + const opted = + attachedToClass !== undefined && + this.context.hierarchy.classHierarchyMixin(attachedToClass, core.mixin.RowVisibility)?.scopeActivityToOwner === + true + ;(opted ? scoped : passthrough).push(doc) + } + if (scoped.length === 0) return passthrough + + if (scope === GuestActivityScope.Own) { + const own = scoped.filter((doc) => + account.socialIds.includes((doc as unknown as { createdBy?: PersonId }).createdBy as PersonId) + ) + return [...passthrough, ...own] + } + + const attachedToIds = Array.from(new Set(scoped.map((doc) => (doc as unknown as AttachedDoc).attachedTo))) + const collabQuery: DocumentQuery = { + attachedTo: { $in: attachedToIds }, + collaborator: account.uuid + } + const collaborators = ((await this.next?.findAll(ctx, core.class.Collaborator, collabQuery, { + projection: { attachedTo: 1 } + })) ?? []) as Array> + const allowedAttachedTo = new Set(collaborators.map((c) => c.attachedTo)) + const collaboratorDocs = scoped.filter((doc) => allowedAttachedTo.has((doc as unknown as AttachedDoc).attachedTo)) + return [...passthrough, ...collaboratorDocs] + } + filterLookup(ctx: MeasureContext, lookup: LookupData, showArchived: boolean): void { if (Object.keys(lookup).length === 0) return const account = ctx.contextData.account diff --git a/foundations/server/packages/middleware/src/tests/accessGate.test.ts b/foundations/server/packages/middleware/src/tests/accessGate.test.ts new file mode 100644 index 0000000000..9aa58bab35 --- /dev/null +++ b/foundations/server/packages/middleware/src/tests/accessGate.test.ts @@ -0,0 +1,209 @@ +// +// Copyright © 2026 TraceX SAS. +// +// Licensed under the PolyForm Shield License 1.0.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://polyformproject.org/licenses/shield/1.0.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. +// + +/** + * Unit tests for `accessGate.ts` in isolation from `GuestPermissionsMiddleware` - in particular, + * that `hasClassAccessLevel`/`isClassAccessAllowed` compare the calling account against the + * minimum role declared by the class rather than a hardcoded `AccountRole.Guest`. + */ + +import core, { + AccountRole, + generateId, + Hierarchy, + MeasureMetricsContext, + TxFactory, + type Account, + type Class, + type Doc, + type MeasureContext, + type PersonId, + type Ref, + type TxCUD +} from '@hcengineering/core' +import type { Middleware } from '@hcengineering/server-core' +import { ClassAccessResolver, hasClassAccessLevel, isClassAccessAllowed } from '../accessGate' + +const SOME_CLASS = 'test:class:Some' as Ref> + +function makeAccount (role: AccountRole): Account { + return { + uuid: generateId() as any, + role, + primarySocialId: 'test' as PersonId, + socialIds: ['test' as PersonId], + fullSocialIds: [] + } +} + +function makeCtx (): MeasureContext { + return new MeasureMetricsContext('test', {}) +} + +function makeCreateTx (): TxCUD { + const factory = new TxFactory('test:account:System' as PersonId) + return factory.createTxCreateDoc(SOME_CLASS, 'test:space:Some' as any, {}) as TxCUD +} + +function makeUpdateTx (): TxCUD { + const factory = new TxFactory('test:account:System' as PersonId) + return factory.createTxUpdateDoc(SOME_CLASS, 'test:space:Some' as any, generateId(), {} as any) as TxCUD +} + +describe('hasClassAccessLevel', () => { + it('grants the declared minimum role and roles above it', async () => { + const hierarchy = new Hierarchy() + hierarchy.classHierarchyMixin = ((_class: any) => + _class === SOME_CLASS ? { createAccessLevel: AccountRole.Maintainer } : undefined) as any + + const allowed = await hasClassAccessLevel( + hierarchy, + undefined, + makeCtx(), + makeCreateTx(), + makeAccount(AccountRole.Maintainer) + ) + expect(allowed).toBe(true) + + const ownerAllowed = await hasClassAccessLevel( + hierarchy, + undefined, + makeCtx(), + makeCreateTx(), + makeAccount(AccountRole.Owner) + ) + expect(ownerAllowed).toBe(true) + + const denied = await hasClassAccessLevel( + hierarchy, + undefined, + makeCtx(), + makeCreateTx(), + makeAccount(AccountRole.Guest) + ) + expect(denied).toBe(false) + }) + + it.each([AccountRole.ReadOnlyGuest, AccountRole.DocGuest, AccountRole.Guest])( + 'allows restricted role %s when ReadOnlyGuest is the declared minimum', + async (role) => { + const hierarchy = new Hierarchy() + hierarchy.classHierarchyMixin = ((_class: any) => + _class === SOME_CLASS ? { createAccessLevel: AccountRole.ReadOnlyGuest } : undefined) as any + + const allowed = await hasClassAccessLevel(hierarchy, undefined, makeCtx(), makeCreateTx(), makeAccount(role)) + expect(allowed).toBe(true) + } + ) + + it('denies when no TxAccessLevel mixin is declared for the class', async () => { + const hierarchy = new Hierarchy() + hierarchy.classHierarchyMixin = (() => undefined) as any + const allowed = await hasClassAccessLevel( + hierarchy, + undefined, + makeCtx(), + makeCreateTx(), + makeAccount(AccountRole.Guest) + ) + expect(allowed).toBe(false) + }) +}) + +describe('isClassAccessAllowed', () => { + it('always allows User and above, regardless of policy', async () => { + const hierarchy = new Hierarchy() + hierarchy.classHierarchyMixin = (() => undefined) as any + const classAccess = new ClassAccessResolver(undefined) + const allowed = await isClassAccessAllowed( + hierarchy, + undefined, + classAccess, + makeCtx(), + makeCreateTx(), + makeAccount(AccountRole.User) + ) + expect(allowed).toBe(true) + }) + + it('allows a create covered by ModulePermissionGroup for the caller role', async () => { + const hierarchy = new Hierarchy() + hierarchy.isDerived = ((a: any, b: any) => a === b) as any + hierarchy.classHierarchyMixin = (() => undefined) as any + + const next: Middleware = { + findAll: (async (_ctx: any, _class: any) => { + if (_class === core.class.ModulePermissionGroup) { + return [{ role: AccountRole.Guest, permissions: ['p1'], enabled: true }] as any + } + if (_class === core.class.ClassPermission) { + return [{ _id: 'p1', targetClass: SOME_CLASS }] as any + } + return [] + }) as any + } as any + + const classAccess = new ClassAccessResolver(next) + const allowed = await isClassAccessAllowed( + hierarchy, + next, + classAccess, + makeCtx(), + makeCreateTx(), + makeAccount(AccountRole.Guest) + ) + expect(allowed).toBe(true) + }) + + it('a ClassPermission with an explicit txClass only covers that tx kind (regression test for the txClass generalization)', async () => { + const hierarchy = new Hierarchy() + hierarchy.isDerived = ((a: any, b: any) => a === b) as any + hierarchy.classHierarchyMixin = (() => undefined) as any + + const next: Middleware = { + findAll: (async (_ctx: any, _class: any) => { + if (_class === core.class.ModulePermissionGroup) { + return [{ role: AccountRole.Guest, permissions: ['p1'], enabled: true }] as any + } + if (_class === core.class.ClassPermission) { + return [{ _id: 'p1', targetClass: SOME_CLASS, txClass: core.class.TxUpdateDoc }] as any + } + return [] + }) as any + } as any + + const classAccess = new ClassAccessResolver(next) + + const updateAllowed = await isClassAccessAllowed( + hierarchy, + next, + classAccess, + makeCtx(), + makeUpdateTx(), + makeAccount(AccountRole.Guest) + ) + expect(updateAllowed).toBe(true) + + const createAllowed = await isClassAccessAllowed( + hierarchy, + next, + classAccess, + makeCtx(), + makeCreateTx(), + makeAccount(AccountRole.Guest) + ) + expect(createAllowed).toBe(false) + }) +}) diff --git a/foundations/server/packages/middleware/src/tests/guestActivityScope.test.ts b/foundations/server/packages/middleware/src/tests/guestActivityScope.test.ts new file mode 100644 index 0000000000..ad431a069b --- /dev/null +++ b/foundations/server/packages/middleware/src/tests/guestActivityScope.test.ts @@ -0,0 +1,244 @@ +// +// Copyright © 2026 TraceX SAS. +// +// Licensed under the PolyForm Shield License 1.0.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://polyformproject.org/licenses/shield/1.0.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. +// + +/** + * `GuestActivitySettings.activityScope` narrows a restricted role's view of chat/activity messages + * attached to a class that opts in via `RowVisibility.scopeActivityToOwner` (modeling + * card.class.Card) - own/collaborator/any - while activity on a non-opted-in class (e.g. a chunter + * channel) stays untouched. + */ + +import core, { + AccountRole, + generateId, + GuestActivityScope, + MeasureMetricsContext, + type Account, + type AccountUuid, + type Class, + type Doc, + type MeasureContext, + type PersonId, + type Ref, + type SessionData, + type Space +} from '@hcengineering/core' +import type { Middleware, PipelineContext } from '@hcengineering/server-core' +import { SpaceSecurityMiddleware } from '../spaceSecurity' + +const CARD_CLASS = 'test:class:Card' as Ref> +const MESSAGE_CLASS = 'test:class:Message' as Ref> +const CHANNEL_MESSAGE_CLASS = 'test:class:ChannelMessage' as Ref> + +interface QueryOperator { + $in?: unknown[] + $nin?: unknown[] +} + +function isQueryOperator (value: unknown): value is QueryOperator { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function matchesQuery (doc: Record, query: Record | undefined): boolean { + for (const key of Object.keys(query ?? {})) { + const cond = query?.[key] + const val = doc[key] + if (isQueryOperator(cond) && cond.$in !== undefined) { + if (!cond.$in.includes(val)) return false + } else if (isQueryOperator(cond) && cond.$nin !== undefined) { + if (cond.$nin.includes(val)) return false + } else if (val !== cond) { + return false + } + } + return true +} + +function makeAccount (role: AccountRole, uuid: AccountUuid, socialId: PersonId = 'test' as PersonId): Account { + return { + uuid, + role, + primarySocialId: socialId, + socialIds: [socialId], + fullSocialIds: [] + } +} + +function makeCtx (account: Account): MeasureContext { + const ctx = new MeasureMetricsContext('test', {}) as MeasureContext + ctx.contextData = { account, broadcast: { txes: [], queue: [], sessions: {} } } as any + return ctx +} + +async function setup (activityScope: GuestActivityScope): Promise<{ + mw: SpaceSecurityMiddleware + ALICE: AccountUuid + ALICE_SOCIAL: PersonId + msgOnCardAlice: Ref + msgOnCardBobByBob: Ref + channelMsg: Ref +}> { + const ALICE = generateId() as unknown as AccountUuid + const BOB = generateId() as unknown as AccountUuid + const ALICE_SOCIAL = 'social:alice' as PersonId + const BOB_SOCIAL = 'social:bob' as PersonId + + // A plain shared space (not one of SpaceSecurityMiddleware's mainSpaces/systemSpaces) both + // accounts are members of - ordinary membership grants read access, isolating this test to the + // new activity-scope narrowing rather than space-membership filtering. + const SHARED_SPACE = 'test:space:shared' as Ref + + const cardAlice = { _id: generateId(), _class: CARD_CLASS, space: SHARED_SPACE, createdBy: ALICE_SOCIAL } + const cardBob = { _id: generateId(), _class: CARD_CLASS, space: SHARED_SPACE, createdBy: BOB_SOCIAL } + + const msgOnCardAlice = { + _id: generateId(), + _class: MESSAGE_CLASS, + space: SHARED_SPACE, + attachedTo: cardAlice._id, + attachedToClass: CARD_CLASS, + createdBy: ALICE_SOCIAL + } + const msgOnCardBobByBob = { + _id: generateId(), + _class: MESSAGE_CLASS, + space: SHARED_SPACE, + attachedTo: cardBob._id, + attachedToClass: CARD_CLASS, + createdBy: BOB_SOCIAL + } + const messages = [msgOnCardAlice, msgOnCardBobByBob] + + // Alice is a listed collaborator on Bob's card, but not (explicitly) on her own. + const collaborators = [ + { _id: generateId(), _class: core.class.Collaborator, collaborator: ALICE, attachedTo: cardBob._id } + ] + + const channelMsg = { + _id: generateId(), + _class: CHANNEL_MESSAGE_CLASS, + space: SHARED_SPACE, + attachedTo: generateId(), + attachedToClass: 'test:class:Channel' as Ref>, + createdBy: BOB_SOCIAL + } + + const sharedSpaceDoc = { + _id: SHARED_SPACE, + _class: 'test:class:Channel' as Ref>, + private: false, + archived: false, + members: [ALICE, BOB] + } + + const permissions = [{ role: AccountRole.Guest, activityScope }] + + const next: Middleware = { + findAll: (async (_ctx: any, _class: any, query: any) => { + if (_class === core.class.Space) return [sharedSpaceDoc] as any + if (_class === core.class.GuestActivitySettings) return permissions.filter((p) => matchesQuery(p, query)) as any + if (_class === MESSAGE_CLASS) return messages.filter((d) => matchesQuery(d, query)) as any + if (_class === CHANNEL_MESSAGE_CLASS) return [channelMsg].filter((d) => matchesQuery(d, query)) as any + if (_class === core.class.Collaborator) return collaborators.filter((d) => matchesQuery(d, query)) as any + return [] + }) as any, + groupBy: (async () => new Map()) as any, + searchFulltext: (async () => ({ docs: [], total: 0 })) as any, + tx: (async () => ({})) as any, + handleBroadcast: (async () => {}) as any, + loadModel: (async () => []) as any, + domainRequest: (async () => ({ domain: 'test', value: null })) as any, + closeSession: (async () => {}) as any + } as any + + const rowVisibilityByClass: Record = { + [CARD_CLASS]: { + policy: { kind: 'publicReadable' }, + allowKnownIdBypass: false, + scopeActivityToOwner: true + } + } + + const hierarchy: any = { + isDerived: (a: Ref>, b: Ref>) => { + if (b === core.class.Space) return a === core.class.Space + if (b === core.class.AttachedDoc) return a === MESSAGE_CLASS || a === CHANNEL_MESSAGE_CLASS + return a === b + }, + getDomain: (_class: Ref>) => (_class === core.class.Space ? 'space' : 'test-domain'), + classHierarchyMixin: (_class: Ref>) => rowVisibilityByClass[_class as unknown as string] + } + + const context: PipelineContext = { + workspace: { uuid: 'test-workspace' as any, url: 'test', dataId: 'test' as any }, + hierarchy, + modelDb: { findAllSync: () => [] } as any, + branding: null as any, + adapterManager: {} as any, + storageAdapter: {} as any, + contextVars: {}, + lastTx: '', + lastHash: '', + broadcastEvent: async () => {} + } as any + + const mw = new (SpaceSecurityMiddleware as any)(false, context, next) as SpaceSecurityMiddleware + + return { + mw, + ALICE, + ALICE_SOCIAL, + msgOnCardAlice: msgOnCardAlice._id, + msgOnCardBobByBob: msgOnCardBobByBob._id, + channelMsg: channelMsg._id + } +} + +describe('GuestActivitySettings.activityScope', () => { + it('Any (default): sees activity on every card, unaffected', async () => { + const s = await setup(GuestActivityScope.Any) + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE, s.ALICE_SOCIAL)) + const res = await s.mw.findAll(ctx, MESSAGE_CLASS, {}) + expect(new Set(res.map((r: any) => r._id))).toEqual(new Set([s.msgOnCardAlice, s.msgOnCardBobByBob])) + }) + + it('Own: sees only activity it authored itself', async () => { + const s = await setup(GuestActivityScope.Own) + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE, s.ALICE_SOCIAL)) + const res = await s.mw.findAll(ctx, MESSAGE_CLASS, {}) + expect(res.map((r: any) => r._id)).toEqual([s.msgOnCardAlice]) + }) + + it('Collaborator: sees activity on cards where it is a listed collaborator, not its own uncollaborated card', async () => { + const s = await setup(GuestActivityScope.Collaborator) + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE, s.ALICE_SOCIAL)) + const res = await s.mw.findAll(ctx, MESSAGE_CLASS, {}) + expect(res.map((r: any) => r._id)).toEqual([s.msgOnCardBobByBob]) + }) + + it('does not touch activity on a class that has not opted in (e.g. a channel), even under Own', async () => { + const s = await setup(GuestActivityScope.Own) + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE, s.ALICE_SOCIAL)) + const res = await s.mw.findAll(ctx, CHANNEL_MESSAGE_CLASS, {}) + expect(res.map((r: any) => r._id)).toEqual([s.channelMsg]) + }) + + it('regular User accounts are never scoped', async () => { + const s = await setup(GuestActivityScope.Own) + const ctx = makeCtx(makeAccount(AccountRole.User, s.ALICE, s.ALICE_SOCIAL)) + const res = await s.mw.findAll(ctx, MESSAGE_CLASS, {}) + expect(new Set(res.map((r: any) => r._id))).toEqual(new Set([s.msgOnCardAlice, s.msgOnCardBobByBob])) + }) +}) diff --git a/foundations/server/packages/middleware/src/tests/guestMentionSearch.test.ts b/foundations/server/packages/middleware/src/tests/guestMentionSearch.test.ts new file mode 100644 index 0000000000..e318ea99ed --- /dev/null +++ b/foundations/server/packages/middleware/src/tests/guestMentionSearch.test.ts @@ -0,0 +1,226 @@ +// +// Copyright © 2026 TraceX SAS. +// +// Licensed under the PolyForm Shield License 1.0.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://polyformproject.org/licenses/shield/1.0.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. +// + +/** + * A Guest sharing a real chunter.class.Channel with another account must be able to find that + * account through `SpaceSecurityMiddleware.searchFulltext` (the @-mention path: `searchFor('mention')` + * -> `searchFulltext({ classes: [contact.mixin.Employee] })`). The mock mirrors real backend + * behavior (`query.spaces` filtering, Mixin ids resolving to their base doc). + */ + +import contact from '@hcengineering/contact' +import core, { + AccountRole, + generateId, + MeasureMetricsContext, + type Account, + type AccountUuid, + type Class, + type Doc, + type MeasureContext, + type PersonId, + type Ref, + type SearchQuery, + type SearchOptions, + type SessionData, + type Space +} from '@hcengineering/core' +import type { Middleware, PipelineContext } from '@hcengineering/server-core' +import { SpaceSecurityMiddleware } from '../spaceSecurity' + +const CHANNEL_CLASS = 'chunter:class:Channel' as Ref> + +function makeAccount (role: AccountRole, uuid: AccountUuid): Account { + return { + uuid, + role, + primarySocialId: 'test' as PersonId, + socialIds: ['test' as PersonId], + fullSocialIds: [] + } +} + +function makeCtx (account: Account): MeasureContext { + const ctx = new MeasureMetricsContext('test', {}) as MeasureContext + ctx.contextData = { + account, + broadcast: { txes: [], queue: [], sessions: {} } + } as any + return ctx +} + +describe('SpaceSecurityMiddleware.searchFulltext - guest @-mention reproduction', () => { + it('a Guest sharing a real channel with another account finds that account in an Employee mention search', async () => { + const GUEST = generateId() as unknown as AccountUuid + const ALICE = generateId() as unknown as AccountUuid + const CHANNEL = generateId() as unknown as Ref + + const personGuest = { + _id: generateId(), + _class: contact.mixin.Employee, + personUuid: GUEST, + space: contact.space.Contacts + } + const personAlice = { + _id: generateId(), + _class: contact.mixin.Employee, + personUuid: ALICE, + space: contact.space.Contacts + } + + const channel = { + _id: CHANNEL, + _class: CHANNEL_CLASS, + space: core.space.Space, + private: false, + archived: false, + members: [GUEST, ALICE] + } + + const next: Middleware = { + findAll: (async (_ctx: any, _class: any, query: any) => { + if (_class === core.class.Space) return [channel] as any + if (_class === contact.class.Person || _class === contact.mixin.Employee) { + const all = [personGuest, personAlice] + if (query?._id !== undefined) { + return all.filter((p) => p._id === query._id) as any + } + const uuids: AccountUuid[] | undefined = query?.personUuid?.$in + return uuids === undefined ? all : (all.filter((p) => uuids.includes(p.personUuid)) as any) + } + return [] + }) as any, + groupBy: (async () => new Map()) as any, + // Behaves like a real fulltext adapter: only returns docs whose `space` is in `query.spaces`. + searchFulltext: (async (_ctx: any, query: SearchQuery, _options: SearchOptions) => { + const candidates = [personGuest, personAlice] + const docs = candidates + .filter((p) => query.spaces === undefined || query.spaces.includes(p.space)) + .map((p) => ({ id: p._id, title: p.personUuid, doc: { _id: p._id, _class: p._class, createdOn: 0 } })) + return { docs, total: docs.length } + }) as any, + tx: (async () => ({})) as any, + handleBroadcast: (async () => {}) as any, + loadModel: (async () => []) as any, + domainRequest: (async () => ({ domain: 'test', value: null })) as any, + closeSession: (async () => {}) as any + } as any + + const context: PipelineContext = { + workspace: { uuid: 'test-workspace' as any, url: 'test', dataId: 'test' as any }, + hierarchy: { + isDerived: (a: Ref>, b: Ref>) => { + if (b === contact.class.Person) return a === contact.class.Person || a === contact.mixin.Employee + if (b === core.class.Space) return a === core.class.Space || a === CHANNEL_CLASS + return a === b + }, + getDomain: () => 'contact', + classHierarchyMixin: () => undefined + } as any, + modelDb: { findAllSync: () => [] } as any, + branding: null as any, + adapterManager: {} as any, + storageAdapter: {} as any, + contextVars: {}, + lastTx: '', + lastHash: '', + broadcastEvent: async () => {} + } as any + + const mw = new (SpaceSecurityMiddleware as any)(false, context, next) as SpaceSecurityMiddleware + const ctx = makeCtx(makeAccount(AccountRole.Guest, GUEST)) + + const result = await mw.searchFulltext(ctx, { query: '*', classes: [contact.mixin.Employee] }, {}) + + expect(result.docs.map((d) => d.title)).toEqual(expect.arrayContaining([GUEST, ALICE])) + }) + + it('an unscoped search (no query.classes) by a DocGuest does not surface a Person from a space it does not share (regression test for the DocGuest/unscoped-search leak)', async () => { + const DOC_GUEST = generateId() as unknown as AccountUuid + const BOB = generateId() as unknown as AccountUuid + + const personDocGuest = { + _id: generateId(), + _class: contact.mixin.Employee, + personUuid: DOC_GUEST, + space: contact.space.Contacts + } + const personBob = { + _id: generateId(), + _class: contact.mixin.Employee, + personUuid: BOB, + space: contact.space.Contacts + } + + const contactsSpace = { _id: contact.space.Contacts, _class: core.class.SystemSpace, members: [] } + + // No shared space between DOC_GUEST and BOB. + const next: Middleware = { + findAll: (async (_ctx: any, _class: any, query: any) => { + if (_class === core.class.Space) return [contactsSpace] as any + if (_class === contact.class.Person || _class === contact.mixin.Employee) { + const all = [personDocGuest, personBob] + if (query?._id !== undefined) { + return all.filter((p) => p._id === query._id) as any + } + const uuids: AccountUuid[] | undefined = query?.personUuid?.$in + return uuids === undefined ? all : (all.filter((p) => uuids.includes(p.personUuid)) as any) + } + return [] + }) as any, + groupBy: (async () => new Map()) as any, + searchFulltext: (async (_ctx: any, query: SearchQuery, _options: SearchOptions) => { + const candidates = [personDocGuest, personBob] + const docs = candidates + .filter((p) => query.spaces === undefined || query.spaces.includes(p.space)) + .map((p) => ({ id: p._id, title: p.personUuid, doc: { _id: p._id, _class: p._class, createdOn: 0 } })) + return { docs, total: docs.length } + }) as any, + tx: (async () => ({})) as any, + handleBroadcast: (async () => {}) as any, + loadModel: (async () => []) as any, + domainRequest: (async () => ({ domain: 'test', value: null })) as any, + closeSession: (async () => {}) as any + } as any + + const context: PipelineContext = { + workspace: { uuid: 'test-workspace' as any, url: 'test', dataId: 'test' as any }, + hierarchy: { + isDerived: (a: Ref>, b: Ref>) => { + if (b === contact.class.Person) return a === contact.class.Person || a === contact.mixin.Employee + if (b === core.class.Space) return a === core.class.Space + return a === b + }, + getDomain: () => 'contact', + classHierarchyMixin: () => undefined + } as any, + modelDb: { findAllSync: () => [] } as any, + branding: null as any, + adapterManager: {} as any, + storageAdapter: {} as any, + contextVars: {}, + lastTx: '', + lastHash: '', + broadcastEvent: async () => {} + } as any + + const mw = new (SpaceSecurityMiddleware as any)(false, context, next) as SpaceSecurityMiddleware + const ctx = makeCtx(makeAccount(AccountRole.DocGuest, DOC_GUEST)) + + const result = await mw.searchFulltext(ctx, { query: '*' }, {}) + + expect(result.docs.map((d) => d.title)).not.toContain(BOB) + }) +}) diff --git a/foundations/server/packages/middleware/src/tests/guestPermissions.test.ts b/foundations/server/packages/middleware/src/tests/guestPermissions.test.ts index e124aee8cb..d4fa390a9f 100644 --- a/foundations/server/packages/middleware/src/tests/guestPermissions.test.ts +++ b/foundations/server/packages/middleware/src/tests/guestPermissions.test.ts @@ -1,5 +1,6 @@ // // Copyright © 2025 Hardcore Engineering Inc. +// Copyright © 2026 TraceX SAS. // // 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 @@ -18,7 +19,7 @@ * * Verifies that: * - Non-guest users pass through without restriction. - * - DocGuest / ReadOnlyGuest users are always forbidden. + * - Restricted users are forbidden unless a class explicitly declares a sufficient access level. * - For covered classes (resolved from module allowedPermissions): * new permission model is authoritative; TxAccessLevel is ignored. * Create in any space → permitted. @@ -41,6 +42,7 @@ import core, { type Tx, TxFactory } from '@hcengineering/core' +import contact from '@hcengineering/contact' import type { PipelineContext, TxMiddlewareResult } from '@hcengineering/server-core' import { GuestPermissionsMiddleware } from '../guestPermissions' @@ -50,6 +52,25 @@ const COVERED_CLASS_PERMISSION = 'test:permission:CoveredClassPermission' as Ref const MODULE_PERMISSION_GROUP_CLASS = core.class.ModulePermissionGroup const ALLOWED_SPACE = 'test:space:Allowed' as Ref const FORBIDDEN_SPACE = 'test:space:Forbidden' as Ref +const DOCUMENT_PRESENCE = 'pulse:class:DocumentPresence' as Ref> +const TYPING_INDICATOR = 'pulse:class:TypingIndicator' as Ref> +const CHAT_MESSAGE = 'chunter:class:ChatMessage' as Ref> +const ATTACHMENT = 'attachment:class:Attachment' as Ref> +const SAVED_MESSAGE = 'activity:class:SavedMessage' as Ref> + +interface TestDocumentPresence extends Doc { + person: Ref + lastActive: number +} + +interface TestTypingIndicator extends Doc { + socialId: PersonId + status?: string +} + +interface TestChatMessage extends Doc { + message: string +} function makeAccount (role: AccountRole): Account { return { @@ -94,7 +115,10 @@ function makeMiddleware ( nextFn?: (ctx: MeasureContext, txes: Tx[]) => Promise ): GuestPermissionsMiddleware { const context = makePipelineContext(findAll) - const next = nextFn !== undefined ? { tx: nextFn } : { tx: async (_ctx: MeasureContext, _txes: Tx[]) => ({}) } + const next = { + findAll, + tx: nextFn ?? (async (_ctx: MeasureContext, _txes: Tx[]) => ({})) + } const mw = new (GuestPermissionsMiddleware as any)(context, next) // Override findAll to inject our test data mw.findAll = findAll @@ -106,7 +130,7 @@ function makeCreateTx (objectClass: Ref>, objectSpace: Ref): T return factory.createTxCreateDoc(objectClass, objectSpace, {}) } -// Helper: buildGuestSettings - simulate the document that loadPermissionsCache would find +// Helper: buildGuestSettings - simulate the document ClassAccessResolver would find function makeGuestSettingsDoc (allowedPermissions: Ref[], disabledPermissions?: Ref[]): Doc { return { _id: generateId(), @@ -157,16 +181,16 @@ describe('GuestPermissionsMiddleware', () => { }) }) - // ─── DocGuest / ReadOnlyGuest are always forbidden ────────────────────────── + // ─── Restricted roles require an explicit access declaration ──────────────── describe('DocGuest and ReadOnlyGuest', () => { - it('DocGuest: throws Forbidden for any tx', async () => { + it('DocGuest: throws Forbidden when the class has no access declaration', 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 () => { + it('ReadOnlyGuest: throws Forbidden when the class has no access declaration', async () => { const mw = makeMiddleware(async () => []) const tx = makeCreateTx(COVERED_CLASS, ALLOWED_SPACE) const ctx = makeCtx(makeAccount(AccountRole.ReadOnlyGuest)) @@ -284,6 +308,727 @@ describe('GuestPermissionsMiddleware', () => { }) }) + describe('ChatMessage ownership', () => { + const OWN_MESSAGE = 'test:message:own' as Ref + const FOREIGN_MESSAGE = 'test:message:foreign' as Ref + const GUEST_SOCIAL_ID = 'test' as PersonId + const OTHER_SOCIAL_ID = 'test:other' as PersonId + + function makeChatMiddleware (nextCalled: () => void): GuestPermissionsMiddleware { + const messages: TestChatMessage[] = [ + { + _id: OWN_MESSAGE, + _class: CHAT_MESSAGE as Ref>, + space: ALLOWED_SPACE, + modifiedOn: Date.now(), + modifiedBy: GUEST_SOCIAL_ID, + createdBy: GUEST_SOCIAL_ID, + message: 'own' + }, + { + _id: FOREIGN_MESSAGE, + _class: CHAT_MESSAGE as Ref>, + space: ALLOWED_SPACE, + modifiedOn: Date.now(), + modifiedBy: OTHER_SOCIAL_ID, + createdBy: OTHER_SOCIAL_ID, + message: 'foreign' + } + ] + const mw = makeMiddleware( + async (_ctx, _class, query: any) => { + if (_class !== CHAT_MESSAGE) return [] + return messages.filter( + (message) => + (query._id === undefined || query._id === message._id) && + (query.createdBy === undefined || query.createdBy === message.createdBy) + ) + }, + async () => { + nextCalled() + return {} + } + ) + ;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => a === b + ;(mw as any).context.hierarchy.classHierarchyMixin = (_class: any, mixin: any) => { + if (_class !== CHAT_MESSAGE) return undefined + if (mixin === core.mixin.TxAccessLevel) { + return { + createAccessLevel: AccountRole.Guest, + updateAccessLevel: AccountRole.Guest, + removeAccessLevel: AccountRole.Guest + } + } + if (mixin === core.mixin.RowVisibility) { + return { + policy: { kind: 'publicReadable', reason: 'Message visibility follows channel access' }, + writePolicy: { kind: 'ownerField', field: 'createdBy', identity: 'socialId' }, + allowKnownIdBypass: false + } + } + return undefined + } + return mw + } + + it('allows Guest to create a message authored by its social identity', async () => { + let nextCalled = false + const mw = makeChatMiddleware(() => { + nextCalled = true + }) + const factory = new TxFactory(GUEST_SOCIAL_ID) + const create = factory.createTxCreateDoc(CHAT_MESSAGE, ALLOWED_SPACE, { message: 'new' } as any) + + await mw.tx(makeCtx(makeAccount(AccountRole.Guest)), [create]) + expect(nextCalled).toBe(true) + }) + + it('forbids Guest to create a message attributed to another social identity', async () => { + const mw = makeChatMiddleware(() => {}) + const factory = new TxFactory(OTHER_SOCIAL_ID) + const create = factory.createTxCreateDoc(CHAT_MESSAGE, ALLOWED_SPACE, { message: 'spoofed' } as any) + + await expect(mw.tx(makeCtx(makeAccount(AccountRole.Guest)), [create])).rejects.toThrow() + }) + + it('allows Guest to update and remove its own message', async () => { + let nextCalls = 0 + const mw = makeChatMiddleware(() => { + nextCalls++ + }) + const factory = new TxFactory(GUEST_SOCIAL_ID) + const update = factory.createTxUpdateDoc( + CHAT_MESSAGE as Ref>, + ALLOWED_SPACE, + OWN_MESSAGE, + { message: 'updated' } + ) + const remove = factory.createTxRemoveDoc(CHAT_MESSAGE as Ref>, ALLOWED_SPACE, OWN_MESSAGE) + const ctx = makeCtx(makeAccount(AccountRole.Guest)) + + await mw.tx(ctx, [update]) + await mw.tx(ctx, [remove]) + expect(nextCalls).toBe(2) + }) + + it('forbids Guest to update or remove another author message', async () => { + const mw = makeChatMiddleware(() => {}) + const factory = new TxFactory(GUEST_SOCIAL_ID) + const update = factory.createTxUpdateDoc( + CHAT_MESSAGE as Ref>, + ALLOWED_SPACE, + FOREIGN_MESSAGE, + { message: 'updated' } + ) + const remove = factory.createTxRemoveDoc( + CHAT_MESSAGE as Ref>, + ALLOWED_SPACE, + FOREIGN_MESSAGE + ) + const ctx = makeCtx(makeAccount(AccountRole.Guest)) + + await expect(mw.tx(ctx, [update])).rejects.toThrow() + await expect(mw.tx(ctx, [remove])).rejects.toThrow() + }) + }) + + describe('Attachment ownership', () => { + const OWN_ATTACHMENT = 'test:attachment:own' as Ref + const FOREIGN_ATTACHMENT = 'test:attachment:foreign' as Ref + const GUEST_SOCIAL_ID = 'test' as PersonId + const OTHER_SOCIAL_ID = 'test:other' as PersonId + + function makeAttachmentMiddleware (nextCalled: () => void): GuestPermissionsMiddleware { + const attachments: Doc[] = [ + { + _id: OWN_ATTACHMENT, + _class: ATTACHMENT, + space: ALLOWED_SPACE, + modifiedOn: Date.now(), + modifiedBy: GUEST_SOCIAL_ID, + createdBy: GUEST_SOCIAL_ID + }, + { + _id: FOREIGN_ATTACHMENT, + _class: ATTACHMENT, + space: ALLOWED_SPACE, + modifiedOn: Date.now(), + modifiedBy: OTHER_SOCIAL_ID, + createdBy: OTHER_SOCIAL_ID + } + ] + const mw = makeMiddleware( + async (_ctx, _class, query: any) => { + if (_class !== ATTACHMENT) return [] + return attachments.filter( + (attachment) => + (query._id === undefined || query._id === attachment._id) && + (query.createdBy === undefined || query.createdBy === attachment.createdBy) + ) + }, + async () => { + nextCalled() + return {} + } + ) + ;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => a === b + ;(mw as any).context.hierarchy.classHierarchyMixin = (_class: any, mixin: any) => { + if (_class !== ATTACHMENT) return undefined + if (mixin === core.mixin.TxAccessLevel) { + return { + createAccessLevel: AccountRole.Guest, + updateAccessLevel: AccountRole.Guest, + removeAccessLevel: AccountRole.Guest + } + } + if (mixin === core.mixin.RowVisibility) { + return { + policy: { kind: 'publicReadable', reason: 'Attachment visibility follows parent access' }, + writePolicy: { kind: 'ownerField', field: 'createdBy', identity: 'socialId' }, + allowKnownIdBypass: false + } + } + return undefined + } + return mw + } + + it('allows Guest to remove its own attachment', async () => { + let nextCalled = false + const mw = makeAttachmentMiddleware(() => { + nextCalled = true + }) + const factory = new TxFactory(GUEST_SOCIAL_ID) + const remove = factory.createTxRemoveDoc(ATTACHMENT, ALLOWED_SPACE, OWN_ATTACHMENT) + + await mw.tx(makeCtx(makeAccount(AccountRole.Guest)), [remove]) + expect(nextCalled).toBe(true) + }) + + it('forbids Guest to remove another author attachment', async () => { + const mw = makeAttachmentMiddleware(() => {}) + const factory = new TxFactory(GUEST_SOCIAL_ID) + const remove = factory.createTxRemoveDoc(ATTACHMENT, ALLOWED_SPACE, FOREIGN_ATTACHMENT) + + await expect(mw.tx(makeCtx(makeAccount(AccountRole.Guest)), [remove])).rejects.toThrow() + }) + }) + + describe('SavedMessage ownership', () => { + const OWN_SAVED_MESSAGE = 'test:saved-message:own' as Ref + const FOREIGN_SAVED_MESSAGE = 'test:saved-message:foreign' as Ref + const GUEST_SOCIAL_ID = 'test' as PersonId + const OTHER_SOCIAL_ID = 'test:other' as PersonId + + function makeSavedMessageMiddleware (nextCalled: () => void): GuestPermissionsMiddleware { + const savedMessages: Doc[] = [ + { + _id: OWN_SAVED_MESSAGE, + _class: SAVED_MESSAGE, + space: core.space.Workspace, + modifiedOn: Date.now(), + modifiedBy: GUEST_SOCIAL_ID, + createdBy: GUEST_SOCIAL_ID + }, + { + _id: FOREIGN_SAVED_MESSAGE, + _class: SAVED_MESSAGE, + space: core.space.Workspace, + modifiedOn: Date.now(), + modifiedBy: OTHER_SOCIAL_ID, + createdBy: OTHER_SOCIAL_ID + } + ] + const mw = makeMiddleware( + async (_ctx, _class, query: any) => { + if (_class !== SAVED_MESSAGE) return [] + return savedMessages.filter( + (savedMessage) => + (query._id === undefined || query._id === savedMessage._id) && + (query.createdBy === undefined || query.createdBy === savedMessage.createdBy) + ) + }, + async () => { + nextCalled() + return {} + } + ) + ;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => a === b + ;(mw as any).context.hierarchy.classHierarchyMixin = (_class: any, mixin: any) => { + if (_class !== SAVED_MESSAGE) return undefined + if (mixin === core.mixin.TxAccessLevel) { + return { + createAccessLevel: AccountRole.Guest, + updateAccessLevel: AccountRole.Guest, + removeAccessLevel: AccountRole.Guest + } + } + if (mixin === core.mixin.RowVisibility) { + return { + policy: { kind: 'ownerField', field: 'createdBy', identity: 'socialId' }, + allowKnownIdBypass: false + } + } + return undefined + } + return mw + } + + it('allows Guest to create and remove its own saved message', async () => { + let nextCalls = 0 + const mw = makeSavedMessageMiddleware(() => { + nextCalls++ + }) + const factory = new TxFactory(GUEST_SOCIAL_ID) + const create = factory.createTxCreateDoc(SAVED_MESSAGE, core.space.Workspace, { + attachedTo: 'test:message' + } as any) + const remove = factory.createTxRemoveDoc(SAVED_MESSAGE, core.space.Workspace, OWN_SAVED_MESSAGE) + const ctx = makeCtx(makeAccount(AccountRole.Guest)) + + await mw.tx(ctx, [create]) + await mw.tx(ctx, [remove]) + expect(nextCalls).toBe(2) + }) + + it('forbids Guest to create or remove another account saved message', async () => { + const mw = makeSavedMessageMiddleware(() => {}) + const foreignFactory = new TxFactory(OTHER_SOCIAL_ID) + const create = foreignFactory.createTxCreateDoc(SAVED_MESSAGE, core.space.Workspace, { + attachedTo: 'test:message' + } as any) + const ownFactory = new TxFactory(GUEST_SOCIAL_ID) + const remove = ownFactory.createTxRemoveDoc(SAVED_MESSAGE, core.space.Workspace, FOREIGN_SAVED_MESSAGE) + const ctx = makeCtx(makeAccount(AccountRole.Guest)) + + await expect(mw.tx(ctx, [create])).rejects.toThrow() + await expect(mw.tx(ctx, [remove])).rejects.toThrow() + }) + }) + + describe('SocialIdentity ownership on create', () => { + const GUEST_SOCIAL = 'test:guest-social' as PersonId + const OTHER_SOCIAL = 'test:other-social' as PersonId + const OWN_PERSON = 'test:person:guest' as Ref + const OTHER_PERSON = 'test:person:other' as Ref + + function makeSocialIdentityCreateTx (socialId: PersonId, attachedTo: Ref): Tx { + const factory = new TxFactory(GUEST_SOCIAL) + const create = factory.createTxCreateDoc( + contact.class.SocialIdentity, + contact.space.Contacts, + { + key: `EMAIL:${socialId}`, + type: 'EMAIL', + value: `${socialId}@example.com`, + isDeleted: false + } as any, + socialId as any + ) + return factory.createTxCollectionCUD( + contact.class.Person, + attachedTo as any, + contact.space.Contacts, + 'socialIds', + create + ) + } + + function makeSocialIdentityMiddleware (nextCalled: () => void): GuestPermissionsMiddleware { + const mw = makeMiddleware( + async (_ctx, _class, query: any) => { + if (_class === contact.class.Person && query?.personUuid !== undefined) { + return [ + { + _id: OWN_PERSON, + _class: contact.class.Person, + space: contact.space.Contacts, + personUuid: query.personUuid + } as any + ] + } + if (_class === contact.class.SocialIdentity) { + if (query?._id !== undefined && query._id !== GUEST_SOCIAL) return [] + if (query?.attachedTo !== undefined && query.attachedTo !== OWN_PERSON) return [] + return [ + { + _id: GUEST_SOCIAL, + _class: contact.class.SocialIdentity, + space: contact.space.Contacts, + attachedTo: OWN_PERSON + } as any + ] + } + return [] + }, + async () => { + nextCalled() + return {} + } + ) + ;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => a === b + ;(mw as any).context.hierarchy.classHierarchyMixin = (_class: any, mixin: any) => { + if (_class !== contact.class.SocialIdentity) return undefined + if (mixin === core.mixin.TxAccessLevel) { + return { createAccessLevel: AccountRole.Guest, isIdentity: true } + } + if (mixin === core.mixin.RowVisibility) { + return { + policy: { kind: 'ownerField', field: 'attachedTo', identity: 'personId' }, + allowKnownIdBypass: false + } + } + return undefined + } + return mw + } + + function makeGuestAccount (): Account { + return { + uuid: 'test:guest-account' as any, + role: AccountRole.Guest, + primarySocialId: GUEST_SOCIAL, + socialIds: [GUEST_SOCIAL], + fullSocialIds: [] + } + } + + it('allows creating the current account social identity for its own Person', async () => { + let nextCalled = false + const mw = makeSocialIdentityMiddleware(() => { + nextCalled = true + }) + await mw.tx(makeCtx(makeGuestAccount()), [makeSocialIdentityCreateTx(GUEST_SOCIAL, OWN_PERSON)]) + expect(nextCalled).toBe(true) + }) + + it('forbids creating a social identity not present in the current account', async () => { + const mw = makeSocialIdentityMiddleware(() => {}) + await expect( + mw.tx(makeCtx(makeGuestAccount()), [makeSocialIdentityCreateTx(OTHER_SOCIAL, OWN_PERSON)]) + ).rejects.toThrow() + }) + + it('forbids attaching the current account social identity to another Person', async () => { + const mw = makeSocialIdentityMiddleware(() => {}) + await expect( + mw.tx(makeCtx(makeGuestAccount()), [makeSocialIdentityCreateTx(GUEST_SOCIAL, OTHER_PERSON)]) + ).rejects.toThrow() + }) + + it('forbids transferring an existing social identity to another Person', async () => { + const mw = makeSocialIdentityMiddleware(() => {}) + const factory = new TxFactory(GUEST_SOCIAL) + const update = factory.createTxUpdateDoc( + contact.class.SocialIdentity, + contact.space.Contacts, + GUEST_SOCIAL as any, + { attachedTo: OTHER_PERSON } as any + ) + + await expect(mw.tx(makeCtx(makeGuestAccount()), [update])).rejects.toThrow() + }) + }) + + describe('DocumentPresence for restricted roles', () => { + const SOCIAL_ID = 'test:presence-social' as PersonId + const PERSON = 'test:person:presence' as Ref + const OTHER_PERSON = 'test:person:other-presence' as Ref + const PRESENCE_ID = `presence:test:document:${PERSON}` as Ref + + function makePresenceAccount (role: AccountRole): Account { + return { + uuid: 'test:presence-account' as any, + role, + primarySocialId: SOCIAL_ID, + socialIds: [SOCIAL_ID], + fullSocialIds: [] + } + } + + function makePresenceMiddleware (nextCalled: () => void): GuestPermissionsMiddleware { + const presence = { + _id: PRESENCE_ID, + _class: DOCUMENT_PRESENCE, + space: core.space.Space, + objectId: 'test:document', + objectClass: 'test:class:Document', + person: PERSON, + lastActive: Date.now() + } as any + const mw = makeMiddleware( + async (_ctx, _class, query: any) => { + if (_class === contact.class.Person && query?.personUuid !== undefined) { + return [ + { + _id: PERSON, + _class: contact.class.Person, + space: contact.space.Contacts, + personUuid: query.personUuid + } as any + ] + } + if (_class === DOCUMENT_PRESENCE) { + if (query?._id !== undefined && query._id !== PRESENCE_ID) return [] + if (query?.person !== undefined && query.person !== PERSON) return [] + return [presence] + } + return [] + }, + async () => { + nextCalled() + return {} + } + ) + ;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => a === b + ;(mw as any).context.hierarchy.classHierarchyMixin = (_class: any, mixin: any) => { + if (_class !== DOCUMENT_PRESENCE) return undefined + if (mixin === core.mixin.TxAccessLevel) { + return { + createAccessLevel: AccountRole.ReadOnlyGuest, + updateAccessLevel: AccountRole.ReadOnlyGuest, + removeAccessLevel: AccountRole.ReadOnlyGuest + } + } + if (mixin === core.mixin.RowVisibility) { + return { + policy: { kind: 'publicReadable', reason: 'Ephemeral test data' }, + allowKnownIdBypass: false + } + } + return undefined + } + return mw + } + + function makePresenceCreateTx (person: Ref): Tx { + const factory = new TxFactory(SOCIAL_ID) + return factory.createTxCreateDoc( + DOCUMENT_PRESENCE, + core.space.Space, + { + objectId: 'test:document', + objectClass: 'test:class:Document', + person, + lastActive: Date.now() + } as any, + PRESENCE_ID as any + ) + } + + it.each([AccountRole.Guest, AccountRole.DocGuest, AccountRole.ReadOnlyGuest])( + 'allows %s to create DocumentPresence', + async (role) => { + let nextCalled = false + const mw = makePresenceMiddleware(() => { + nextCalled = true + }) + await mw.tx(makeCtx(makePresenceAccount(role)), [makePresenceCreateTx(PERSON)]) + expect(nextCalled).toBe(true) + } + ) + + it('allows ReadOnlyGuest to update and remove DocumentPresence', async () => { + let nextCalls = 0 + const mw = makePresenceMiddleware(() => { + nextCalls++ + }) + const factory = new TxFactory(SOCIAL_ID) + const update = factory.createTxUpdateDoc( + DOCUMENT_PRESENCE as Ref>, + core.space.Space, + PRESENCE_ID as Ref, + { lastActive: Date.now() } + ) + const remove = factory.createTxRemoveDoc( + DOCUMENT_PRESENCE as Ref>, + core.space.Space, + PRESENCE_ID as Ref + ) + const ctx = makeCtx(makePresenceAccount(AccountRole.ReadOnlyGuest)) + + await mw.tx(ctx, [update]) + await mw.tx(ctx, [remove]) + expect(nextCalls).toBe(2) + }) + + it('allows ReadOnlyGuest to create DocumentPresence for another Person', async () => { + let nextCalled = false + const mw = makePresenceMiddleware(() => { + nextCalled = true + }) + await mw.tx(makeCtx(makePresenceAccount(AccountRole.ReadOnlyGuest)), [makePresenceCreateTx(OTHER_PERSON)]) + expect(nextCalled).toBe(true) + }) + + it('allows ReadOnlyGuest to update the person in an existing DocumentPresence', async () => { + let nextCalled = false + const mw = makePresenceMiddleware(() => { + nextCalled = true + }) + const factory = new TxFactory(SOCIAL_ID) + const update = factory.createTxUpdateDoc( + DOCUMENT_PRESENCE as Ref>, + core.space.Space, + PRESENCE_ID as Ref, + { person: OTHER_PERSON } + ) + + await mw.tx(makeCtx(makePresenceAccount(AccountRole.ReadOnlyGuest)), [update]) + expect(nextCalled).toBe(true) + }) + }) + + describe('TypingIndicator for restricted roles', () => { + const SOCIAL_ID = 'test:typing-social' as PersonId + const OTHER_SOCIAL_ID = 'test:typing-other-social' as PersonId + const TYPING_ID = `typing:test:document:${SOCIAL_ID}` as Ref + + function makeTypingAccount (role: AccountRole): Account { + return { + uuid: 'test:typing-account' as any, + role, + primarySocialId: SOCIAL_ID, + socialIds: [SOCIAL_ID], + fullSocialIds: [] + } + } + + function makeTypingMiddleware (nextCalled: () => void): GuestPermissionsMiddleware { + const indicator = { + _id: TYPING_ID, + _class: TYPING_INDICATOR, + space: core.space.Space, + objectId: 'test:document', + socialId: SOCIAL_ID + } as any + const mw = makeMiddleware( + async (_ctx, _class, query: any) => { + if (_class !== TYPING_INDICATOR) return [] + if (query?._id !== undefined && query._id !== TYPING_ID) return [] + if (query?.socialId !== undefined && query.socialId !== SOCIAL_ID) return [] + return [indicator] + }, + async () => { + nextCalled() + return {} + } + ) + ;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => a === b + ;(mw as any).context.hierarchy.classHierarchyMixin = (_class: any, mixin: any) => { + if (_class !== TYPING_INDICATOR) return undefined + if (mixin === core.mixin.TxAccessLevel) { + return { + createAccessLevel: AccountRole.ReadOnlyGuest, + updateAccessLevel: AccountRole.ReadOnlyGuest, + removeAccessLevel: AccountRole.ReadOnlyGuest + } + } + if (mixin === core.mixin.RowVisibility) { + return { + policy: { kind: 'publicReadable', reason: 'Ephemeral test data' }, + allowKnownIdBypass: false + } + } + return undefined + } + return mw + } + + function makeTypingCreateTx (socialId: PersonId): Tx { + const factory = new TxFactory(SOCIAL_ID) + return factory.createTxCreateDoc( + TYPING_INDICATOR, + core.space.Space, + { objectId: 'test:document', socialId } as any, + TYPING_ID as any + ) + } + + it.each([AccountRole.Guest, AccountRole.DocGuest, AccountRole.ReadOnlyGuest])( + 'allows %s to create TypingIndicator', + async (role) => { + let nextCalled = false + const mw = makeTypingMiddleware(() => { + nextCalled = true + }) + + await mw.tx(makeCtx(makeTypingAccount(role)), [makeTypingCreateTx(SOCIAL_ID)]) + expect(nextCalled).toBe(true) + } + ) + + it('allows ReadOnlyGuest to update and remove TypingIndicator', async () => { + let nextCalls = 0 + const mw = makeTypingMiddleware(() => { + nextCalls++ + }) + const factory = new TxFactory(SOCIAL_ID) + const update = factory.createTxUpdateDoc( + TYPING_INDICATOR as Ref>, + core.space.Space, + TYPING_ID as Ref, + { status: 'test:status' } + ) + const remove = factory.createTxRemoveDoc( + TYPING_INDICATOR as Ref>, + core.space.Space, + TYPING_ID as Ref + ) + const ctx = makeCtx(makeTypingAccount(AccountRole.ReadOnlyGuest)) + + await mw.tx(ctx, [update]) + await mw.tx(ctx, [remove]) + expect(nextCalls).toBe(2) + }) + + it.each([AccountRole.Guest, AccountRole.DocGuest, AccountRole.ReadOnlyGuest])( + 'allows %s to remove an already expired TypingIndicator', + async (role) => { + let nextCalled = false + const mw = makeTypingMiddleware(() => { + nextCalled = true + }) + const factory = new TxFactory(SOCIAL_ID) + const expiredId = `typing:test:expired:${OTHER_SOCIAL_ID}` as Ref + const remove = factory.createTxRemoveDoc( + TYPING_INDICATOR as Ref>, + core.space.Space, + expiredId + ) + + await mw.tx(makeCtx(makeTypingAccount(role)), [remove]) + expect(nextCalled).toBe(true) + } + ) + + it('allows ReadOnlyGuest to create a TypingIndicator for another social identity', async () => { + let nextCalled = false + const mw = makeTypingMiddleware(() => { + nextCalled = true + }) + await mw.tx(makeCtx(makeTypingAccount(AccountRole.ReadOnlyGuest)), [makeTypingCreateTx(OTHER_SOCIAL_ID)]) + expect(nextCalled).toBe(true) + }) + + it('allows ReadOnlyGuest to update the social identity in an existing TypingIndicator', async () => { + let nextCalled = false + const mw = makeTypingMiddleware(() => { + nextCalled = true + }) + const factory = new TxFactory(SOCIAL_ID) + const update = factory.createTxUpdateDoc( + TYPING_INDICATOR as Ref>, + core.space.Space, + TYPING_ID as Ref, + { socialId: OTHER_SOCIAL_ID } + ) + + await mw.tx(makeCtx(makeTypingAccount(AccountRole.ReadOnlyGuest)), [update]) + 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 () => { @@ -344,8 +1089,8 @@ describe('GuestPermissionsMiddleware', () => { }) }) - // ─── Own-document mutations for guests ─────────────────────────────────────── - describe('guest update/remove own documents', () => { + // ─── Layer 1 cannot be bypassed by document creator ────────────────────────── + describe('guest update/remove documents', () => { const GUEST_SOCIAL = 'test:guest-social' as PersonId function makeGuestAccountWithSocial (): Account { @@ -366,7 +1111,7 @@ describe('GuestPermissionsMiddleware', () => { } } - it('allows guest to update document created by same account', async () => { + it('forbids guest to update its own document when the class does not allow updates', async () => { const objectId = generateId() const findAll: FindAllFn = async (_ctx, _class, query: any) => { if (_class === UNCOVERED_CLASS && query?._id === objectId) { @@ -391,11 +1136,11 @@ describe('GuestPermissionsMiddleware', () => { 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) + await expect(mw.tx(makeCtx(makeGuestAccountWithSocial()), [tx])).rejects.toThrow() + expect(nextCalled).toBe(false) }) - it('allows guest to remove document created by same account', async () => { + it('forbids guest to remove its own document when the class does not allow removal', async () => { const objectId = generateId() const findAll: FindAllFn = async (_ctx, _class, query: any) => { if (_class === UNCOVERED_CLASS && query?._id === objectId) { @@ -420,8 +1165,8 @@ describe('GuestPermissionsMiddleware', () => { 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) + await expect(mw.tx(makeCtx(makeGuestAccountWithSocial()), [tx])).rejects.toThrow() + expect(nextCalled).toBe(false) }) it('forbids guest to update document created by another account', async () => { @@ -450,6 +1195,289 @@ describe('GuestPermissionsMiddleware', () => { }) }) + // ─── card.class.Card ownership on update (regression test for the File-card guest-upload fix) ── + describe('card.class.Card ownership on update', () => { + const CARD_CLASS = 'card:class:Card' as Ref> + const GUEST_SOCIAL = 'test:guest-social' as PersonId + const OTHER_SOCIAL = 'test:other-social' as PersonId + const OWN_CARD = 'test:card:own' as Ref + const OTHER_CARD = 'test:card:other' as Ref + const CARD_SPACE = 'test:space:cards' as Ref + + function makeCardMiddleware (nextCalled: () => void): GuestPermissionsMiddleware { + const mw = makeMiddleware( + async (_ctx, _class, query: any) => { + if (_class === CARD_CLASS) { + const cards = [ + { _id: OWN_CARD, _class: CARD_CLASS, space: CARD_SPACE, createdBy: GUEST_SOCIAL }, + { _id: OTHER_CARD, _class: CARD_CLASS, space: CARD_SPACE, createdBy: OTHER_SOCIAL } + ] + return cards.filter( + (c) => + (query?._id === undefined || query._id === c._id) && + (query?.createdBy === undefined || query.createdBy === c.createdBy) + ) as any + } + return [] + }, + async () => { + nextCalled() + return {} + } + ) + ;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => a === b + ;(mw as any).context.hierarchy.classHierarchyMixin = (_class: any, mixin: any) => { + if (_class !== CARD_CLASS) return undefined + if (mixin === core.mixin.TxAccessLevel) { + return { updateAccessLevel: AccountRole.Guest } + } + if (mixin === core.mixin.RowVisibility) { + return { + policy: { kind: 'publicReadable' }, + writePolicy: { kind: 'ownerField', field: 'createdBy', identity: 'socialId' }, + allowKnownIdBypass: false + } + } + return undefined + } + return mw + } + + function makeGuestAccount (): Account { + return { + uuid: 'test:guest-account' as any, + role: AccountRole.Guest, + primarySocialId: GUEST_SOCIAL, + socialIds: [GUEST_SOCIAL], + fullSocialIds: [] + } + } + + it('allows Guest to update a card it created', async () => { + let nextCalled = false + const mw = makeCardMiddleware(() => { + nextCalled = true + }) + const factory = new TxFactory(GUEST_SOCIAL) + const update = factory.createTxUpdateDoc(CARD_CLASS, CARD_SPACE, OWN_CARD, { blobs: {} } as any) + await mw.tx(makeCtx(makeGuestAccount()), [update]) + expect(nextCalled).toBe(true) + }) + + it('forbids Guest to update a card created by another account', async () => { + const mw = makeCardMiddleware(() => {}) + const factory = new TxFactory(GUEST_SOCIAL) + const update = factory.createTxUpdateDoc(CARD_CLASS, CARD_SPACE, OTHER_CARD, { blobs: {} } as any) + await expect(mw.tx(makeCtx(makeGuestAccount()), [update])).rejects.toThrow() + }) + }) + + // ─── core.class.Collaborator editing on own cards (card.ids.GuestCollaboratorClassPermission) ── + describe('editing collaborators on a card the guest created', () => { + const CARD_CLASS = 'card:class:Card' as Ref> + const GUEST_SOCIAL = 'test:guest-social' as PersonId + const OTHER_SOCIAL = 'test:other-social' as PersonId + const OWN_CARD = 'test:card:own' as Ref + const OTHER_CARD = 'test:card:other' as Ref + const CARD_SPACE = 'test:space:cards' as Ref + const NEW_COLLABORATOR_ACCOUNT = 'test:other-account' as any + const COLLABORATOR_PERMISSION = 'test:permission:collaborator' as Ref + + function makeCollaboratorMiddleware ( + editOwnDocCollaborators: boolean, + nextCalled: () => void + ): GuestPermissionsMiddleware { + const mw = makeMiddleware( + async (_ctx, _class, query: any) => { + if (_class === core.class.ModulePermissionGroup) { + return [ + { + role: AccountRole.Guest, + permissions: [COLLABORATOR_PERMISSION], + disabledPermissions: editOwnDocCollaborators ? [] : [COLLABORATOR_PERMISSION], + enabled: true + } + ] as any + } + if (_class === core.class.ClassPermission) { + return [{ _id: COLLABORATOR_PERMISSION, targetClass: core.class.Collaborator }] as any + } + if (_class === CARD_CLASS) { + const cards = [ + { _id: OWN_CARD, _class: CARD_CLASS, space: CARD_SPACE, createdBy: GUEST_SOCIAL }, + { _id: OTHER_CARD, _class: CARD_CLASS, space: CARD_SPACE, createdBy: OTHER_SOCIAL } + ] + return cards.filter((c) => query?._id === undefined || query._id === c._id) as any + } + return [] + }, + async () => { + nextCalled() + return {} + } + ) + ;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => a === b + ;(mw as any).context.hierarchy.classHierarchyMixin = (_class: any, mixin: any) => { + if (_class !== core.class.Collaborator || mixin !== core.mixin.TxAccessLevel) return undefined + return { createAccessLevel: AccountRole.ReadOnlyGuest, removeAccessLevel: AccountRole.ReadOnlyGuest } + } + return mw + } + + function makeGuestAccount (): Account { + return { + uuid: 'test:guest-account' as any, + role: AccountRole.Guest, + primarySocialId: GUEST_SOCIAL, + socialIds: [GUEST_SOCIAL], + fullSocialIds: [] + } + } + + function makeAddCollaboratorTx (card: Ref): Tx { + const factory = new TxFactory(GUEST_SOCIAL) + const create = factory.createTxCreateDoc(core.class.Collaborator, CARD_SPACE, { + collaborator: NEW_COLLABORATOR_ACCOUNT + } as any) + return factory.createTxCollectionCUD(CARD_CLASS, card, CARD_SPACE, 'collaborators', create) + } + + function makeRemoveCollaboratorTx (card: Ref): Tx { + const factory = new TxFactory(GUEST_SOCIAL) + const remove = factory.createTxRemoveDoc(core.class.Collaborator, CARD_SPACE, generateId()) + return factory.createTxCollectionCUD(CARD_CLASS, card, CARD_SPACE, 'collaborators', remove) + } + + it('forbids by default (editOwnDocCollaborators off)', async () => { + const mw = makeCollaboratorMiddleware(false, () => {}) + await expect(mw.tx(makeCtx(makeGuestAccount()), [makeAddCollaboratorTx(OWN_CARD)])).rejects.toThrow() + }) + + it('allows adding a collaborator to a card the guest created, once opted in', async () => { + let nextCalled = false + const mw = makeCollaboratorMiddleware(true, () => { + nextCalled = true + }) + await mw.tx(makeCtx(makeGuestAccount()), [makeAddCollaboratorTx(OWN_CARD)]) + expect(nextCalled).toBe(true) + }) + + it('allows removing a collaborator from a card the guest created, once opted in', async () => { + let nextCalled = false + const mw = makeCollaboratorMiddleware(true, () => { + nextCalled = true + }) + await mw.tx(makeCtx(makeGuestAccount()), [makeRemoveCollaboratorTx(OWN_CARD)]) + expect(nextCalled).toBe(true) + }) + + it('forbids editing collaborators on a card created by another account, even when opted in', async () => { + const mw = makeCollaboratorMiddleware(true, () => {}) + await expect(mw.tx(makeCtx(makeGuestAccount()), [makeAddCollaboratorTx(OTHER_CARD)])).rejects.toThrow() + }) + }) + + // ─── process.class.ApproveRequest actions (process.ids.GuestApproveRequestClassPermission) ── + describe('process.class.ApproveRequest approve/reject', () => { + const APPROVE_REQUEST_CLASS = 'process:class:ApproveRequest' as Ref> + const GUEST_SOCIAL = 'test:guest-social' as PersonId + const GUEST_PERSON = 'test:guest-person' as Ref + const OTHER_PERSON = 'test:other-person' as Ref + const OWN_REQUEST = 'test:request:own' as Ref + const OTHER_REQUEST = 'test:request:other' as Ref + const REQUEST_SPACE = 'test:space:requests' as Ref + const APPROVE_PERMISSION = 'test:permission:approve' as Ref + + function makeAccount (): Account { + return { + uuid: 'test:guest-account' as any, + role: AccountRole.Guest, + primarySocialId: GUEST_SOCIAL, + socialIds: [GUEST_SOCIAL], + fullSocialIds: [] + } + } + + function makeApproveMiddleware (runProcessActions: boolean, nextCalled: () => void): GuestPermissionsMiddleware { + const mw = makeMiddleware( + async (_ctx, _class, query: any) => { + if (_class === core.class.ModulePermissionGroup) { + return [ + { + role: AccountRole.Guest, + permissions: [APPROVE_PERMISSION], + disabledPermissions: runProcessActions ? [] : [APPROVE_PERMISSION], + enabled: true + } + ] as any + } + if (_class === core.class.ClassPermission) { + return [{ _id: APPROVE_PERMISSION, targetClass: APPROVE_REQUEST_CLASS }] as any + } + if (_class === contact.class.Person) { + return [{ _id: GUEST_PERSON, personUuid: 'test:guest-account' }] as any + } + if (_class === APPROVE_REQUEST_CLASS) { + const requests = [ + { _id: OWN_REQUEST, _class: APPROVE_REQUEST_CLASS, space: REQUEST_SPACE, user: GUEST_PERSON }, + { _id: OTHER_REQUEST, _class: APPROVE_REQUEST_CLASS, space: REQUEST_SPACE, user: OTHER_PERSON } + ] + return requests.filter( + (r) => + (query?._id === undefined || query._id === r._id) && + (query?.user === undefined || query.user === r.user) + ) as any + } + return [] + }, + async () => { + nextCalled() + return {} + } + ) + ;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => a === b + ;(mw as any).context.hierarchy.classHierarchyMixin = (_class: any, mixin: any) => { + if (_class !== APPROVE_REQUEST_CLASS) return undefined + if (mixin === core.mixin.TxAccessLevel) return { updateAccessLevel: AccountRole.ReadOnlyGuest } + if (mixin === core.mixin.RowVisibility) { + return { + policy: { kind: 'ownerField', field: 'user', identity: 'personId' }, + allowKnownIdBypass: false + } + } + return undefined + } + return mw + } + + function makeApproveTx (request: Ref): Tx { + const factory = new TxFactory(GUEST_SOCIAL) + return factory.createTxUpdateDoc(APPROVE_REQUEST_CLASS, REQUEST_SPACE, request, { + doneOn: Date.now(), + approved: true + } as any) + } + + it('forbids by default (runProcessActions off), even for the assigned approver', async () => { + const mw = makeApproveMiddleware(false, () => {}) + await expect(mw.tx(makeCtx(makeAccount()), [makeApproveTx(OWN_REQUEST)])).rejects.toThrow() + }) + + it('allows the assigned approver once opted in', async () => { + let nextCalled = false + const mw = makeApproveMiddleware(true, () => { + nextCalled = true + }) + await mw.tx(makeCtx(makeAccount()), [makeApproveTx(OWN_REQUEST)]) + expect(nextCalled).toBe(true) + }) + + it('forbids a request assigned to someone else, even when opted in', async () => { + const mw = makeApproveMiddleware(true, () => {}) + await expect(mw.tx(makeCtx(makeAccount()), [makeApproveTx(OTHER_REQUEST)])).rejects.toThrow() + }) + }) + // ─── Cache invalidation ────────────────────────────────────────────────────── describe('cache invalidation', () => { it('invalidates cache when GuestPermissionsSettings is updated', async () => { @@ -485,7 +1513,7 @@ describe('GuestPermissionsMiddleware', () => { // Owner updates settings – should invalidate cache await mw.tx(userCtx, [settingsTx]) // Cache should be cleared after settings update - expect((mw as any).permissionsCache).toBeUndefined() + expect((mw as any).classAccess.cache).toBeUndefined() }) }) }) diff --git a/foundations/server/packages/middleware/src/tests/guestVisibility.test.ts b/foundations/server/packages/middleware/src/tests/guestVisibility.test.ts new file mode 100644 index 0000000000..3a1f700e19 --- /dev/null +++ b/foundations/server/packages/middleware/src/tests/guestVisibility.test.ts @@ -0,0 +1,68 @@ +// +// Copyright © 2026 TraceX SAS. +// +// Licensed under the PolyForm Shield License 1.0.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://polyformproject.org/licenses/shield/1.0.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. +// + +/** + * `excludeSpacesFromQuery` used to dereference `current.$in` without the null-guard its siblings + * `mergeEquals`/`mergeIn` (`../rowVisibility`) have, so a query narrowing a space field to `null` + * (e.g. `{ space: null }`) crashed instead of being treated as a plain scalar value. Regression + * tests for that fix. + */ + +import type { Ref, Space } from '@hcengineering/core' +import { excludeSpacesFromQuery } from '../guestVisibility' + +const SPACE_A = 'test:space:A' as Ref +const SPACE_B = 'test:space:B' as Ref +const SPACE_C = 'test:space:C' as Ref + +describe('excludeSpacesFromQuery', () => { + it('does not throw for a null field constraint and leaves it unchanged (regression test for the null-guard fix)', () => { + const result = excludeSpacesFromQuery(null as any, new Set([SPACE_A])) + expect(result).toEqual({ query: null }) + }) + + it('passes through unchanged when there is nothing to exclude', () => { + const result = excludeSpacesFromQuery(undefined, new Set()) + expect(result).toEqual({ query: undefined }) + }) + + it('turns an undefined constraint into $nin', () => { + const result = excludeSpacesFromQuery(undefined, new Set([SPACE_A])) + expect(result).toEqual({ query: { $nin: [SPACE_A] } }) + }) + + it('denies a bare ref that is excluded', () => { + const result = excludeSpacesFromQuery(SPACE_A, new Set([SPACE_A])) + expect(result).toEqual({ deny: true }) + }) + + it('leaves a bare ref that is not excluded unchanged', () => { + const result = excludeSpacesFromQuery(SPACE_B, new Set([SPACE_A])) + expect(result).toEqual({ query: SPACE_B }) + }) + + it('filters an existing $in, denying once nothing is left', () => { + const narrowed = excludeSpacesFromQuery({ $in: [SPACE_A, SPACE_B] }, new Set([SPACE_A])) + expect(narrowed).toEqual({ query: { $in: [SPACE_B] } }) + + const emptied = excludeSpacesFromQuery({ $in: [SPACE_A] }, new Set([SPACE_A])) + expect(emptied).toEqual({ deny: true }) + }) + + it('merges into an existing $nin', () => { + const result = excludeSpacesFromQuery({ $nin: [SPACE_C] }, new Set([SPACE_A])) + expect(result).toEqual({ query: { $nin: [SPACE_C, SPACE_A] } }) + }) +}) diff --git a/foundations/server/packages/middleware/src/tests/rowVisibilityCanUpdate.test.ts b/foundations/server/packages/middleware/src/tests/rowVisibilityCanUpdate.test.ts new file mode 100644 index 0000000000..a3f7aa6373 --- /dev/null +++ b/foundations/server/packages/middleware/src/tests/rowVisibilityCanUpdate.test.ts @@ -0,0 +1,251 @@ +// +// Copyright © 2026 TraceX SAS. +// +// Licensed under the PolyForm Shield License 1.0.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://polyformproject.org/licenses/shield/1.0.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. +// + +/** + * `RowVisibilityResolver.canUpdate` ("Ensures an update or mixin extension cannot transfer a row + * to another owner") used to only run for `TxUpdateDoc`, silently allowing any `TxMixin` through - + * even though Layer 1 (`hasClassAccessLevel` in `../accessGate`) already grants `TxMixin` the same + * access as `TxUpdateDoc`. These are regression tests for closing that gap. + * + * `TxProcessor.updateMixin4Doc` only ever writes to `doc[tx.mixin]`, never to a base-class field + * like the `user`/`attachedTo` fields real `ownerField` policies protect, so an end-to-end + * `SpaceSecurityMiddleware` scenario can't actually demonstrate a blocked transfer via `TxMixin`. + * These tests instead exercise `canUpdate` directly, proving the guard judges the fetched document + * itself rather than defaulting to "allowed" whenever the tx happens to be a `TxMixin`. + */ + +import { + AccountRole, + generateId, + MeasureMetricsContext, + TxFactory, + type Account, + type Class, + type Doc, + type Hierarchy, + type MeasureContext, + type PersonId, + type Ref, + type RowVisibility, + type SessionData, + type Space +} from '@hcengineering/core' +import type { Middleware } from '@hcengineering/server-core' +import { AccountIdentityResolver, RowVisibilityResolver } from '../rowVisibility' + +const TEST_CLASS = 'test:class:OwnedDoc' as Ref> +const TEST_MIXIN = 'test:mixin:Extra' as Ref> +const TEST_SPACE = 'test:space:Workspace' as Ref + +function makeAccount (uuid: string): Account { + return { + uuid: uuid as any, + role: AccountRole.Guest, + primarySocialId: 'test' as PersonId, + socialIds: ['test' as PersonId], + fullSocialIds: [] + } +} + +function makeHierarchy (): Hierarchy { + return { + classHierarchyMixin: (_class: Ref>): Partial | undefined => { + if (_class !== TEST_CLASS) return undefined + return { + policy: { kind: 'ownerField', field: 'user', identity: 'accountUuid' }, + allowKnownIdBypass: false + } + } + } as any +} + +describe('RowVisibilityResolver.canUpdate - TxMixin parity with TxUpdateDoc', () => { + const ctx = new MeasureMetricsContext('test', {}) as MeasureContext + const OWNER = 'owner-account' + const OTHER = 'other-account' + + it('TxUpdateDoc: denies an operation that reassigns the owner field to someone else', async () => { + const resolver = new RowVisibilityResolver(undefined) + const identity = new AccountIdentityResolver(undefined, ctx, makeAccount(OWNER)) + const doc = { _id: generateId(), _class: TEST_CLASS, space: TEST_SPACE, user: OWNER } as any + const factory = new TxFactory('test' as PersonId) + const tx = factory.createTxUpdateDoc(TEST_CLASS, TEST_SPACE, doc._id, { user: OTHER } as any) + + const allowed = await resolver.canUpdate(ctx, makeHierarchy(), TEST_CLASS, doc, tx, identity) + expect(allowed).toBe(false) + }) + + it('TxUpdateDoc: allows an operation that leaves the owner field untouched', async () => { + const resolver = new RowVisibilityResolver(undefined) + const identity = new AccountIdentityResolver(undefined, ctx, makeAccount(OWNER)) + const doc = { _id: generateId(), _class: TEST_CLASS, space: TEST_SPACE, user: OWNER } as any + const factory = new TxFactory('test' as PersonId) + const tx = factory.createTxUpdateDoc(TEST_CLASS, TEST_SPACE, doc._id, { note: 'x' } as any) + + const allowed = await resolver.canUpdate(ctx, makeHierarchy(), TEST_CLASS, doc, tx, identity) + expect(allowed).toBe(true) + }) + + it('TxMixin: denies when the fetched document does not belong to the caller (regression test for the Layer 1/Layer 2 parity fix)', async () => { + const resolver = new RowVisibilityResolver(undefined) + const identity = new AccountIdentityResolver(undefined, ctx, makeAccount(OWNER)) + const doc = { _id: generateId(), _class: TEST_CLASS, space: TEST_SPACE, user: OTHER } as any + const factory = new TxFactory('test' as PersonId) + const tx = factory.createTxMixin(doc._id, TEST_CLASS, TEST_SPACE, TEST_MIXIN, { note: 'x' } as any) + + const allowed = await resolver.canUpdate(ctx, makeHierarchy(), TEST_CLASS, doc, tx, identity) + expect(allowed).toBe(false) + }) + + it('TxMixin: allows extending a document the caller owns', async () => { + const resolver = new RowVisibilityResolver(undefined) + const identity = new AccountIdentityResolver(undefined, ctx, makeAccount(OWNER)) + const doc = { _id: generateId(), _class: TEST_CLASS, space: TEST_SPACE, user: OWNER } as any + const factory = new TxFactory('test' as PersonId) + const tx = factory.createTxMixin(doc._id, TEST_CLASS, TEST_SPACE, TEST_MIXIN, { note: 'x' } as any) + + const allowed = await resolver.canUpdate(ctx, makeHierarchy(), TEST_CLASS, doc, tx, identity) + expect(allowed).toBe(true) + }) +}) + +describe('AccountIdentityResolver - socialId matches any linked social id, not only primary', () => { + const ctx = new MeasureMetricsContext('test', {}) as MeasureContext + const SOCIAL_CLASS = 'test:class:SocialOwnedDoc' as Ref> + const PRIMARY = 'primary-social' as PersonId + const SECONDARY = 'secondary-social' as PersonId + + function makeMultiSocialAccount (): Account { + return { + uuid: 'multi-social-account' as any, + role: AccountRole.Guest, + primarySocialId: PRIMARY, + socialIds: [PRIMARY, SECONDARY], + fullSocialIds: [] + } + } + + function makeSocialHierarchy (): Hierarchy { + return { + classHierarchyMixin: (_class: Ref>): Partial | undefined => { + if (_class !== SOCIAL_CLASS) return undefined + return { + policy: { kind: 'ownerField', field: 'createdBy', identity: 'socialId' }, + allowKnownIdBypass: false + } + } + } as any + } + + it('read narrowing matches a document created under a non-primary social id', async () => { + const resolver = new RowVisibilityResolver(undefined) + const identity = new AccountIdentityResolver(undefined, ctx, makeMultiSocialAccount()) + const decision = await resolver.resolve(ctx, makeSocialHierarchy(), SOCIAL_CLASS, {}, identity) + expect(decision).toEqual({ kind: 'narrow', query: { createdBy: { $in: [PRIMARY, SECONDARY] } } }) + }) + + it('canUpdate allows editing a document created under a non-primary social id', async () => { + const resolver = new RowVisibilityResolver(undefined) + const identity = new AccountIdentityResolver(undefined, ctx, makeMultiSocialAccount()) + const doc = { _id: generateId(), _class: SOCIAL_CLASS, space: TEST_SPACE, createdBy: SECONDARY } as any + const factory = new TxFactory(SECONDARY) + const tx = factory.createTxUpdateDoc(SOCIAL_CLASS, TEST_SPACE, doc._id, { note: 'x' } as any) + + const allowed = await resolver.canUpdate(ctx, makeSocialHierarchy(), SOCIAL_CLASS, doc, tx, identity) + expect(allowed).toBe(true) + }) + + it('canCreate allows authoring a document under a non-primary social id', async () => { + const resolver = new RowVisibilityResolver(undefined) + const identity = new AccountIdentityResolver(undefined, ctx, makeMultiSocialAccount()) + const doc = { _id: generateId(), _class: SOCIAL_CLASS, space: TEST_SPACE, createdBy: SECONDARY } as any + + const allowed = await resolver.canCreate(ctx, makeSocialHierarchy(), SOCIAL_CLASS, doc, identity) + expect(allowed).toBe(true) + }) +}) + +describe('RowVisibilityResolver.canUpdate - linkedViaRecord ownership-transfer guard', () => { + const ctx = new MeasureMetricsContext('test', {}) as MeasureContext + const LINKED_CLASS = 'test:class:LinkedDoc' as Ref> + const LINK_CLASS = 'test:class:Link' as Ref> + const CALLER = 'caller-account' + const ALLOWED_ROOM = 'allowed-room' as Ref + const OTHER_ROOM = 'other-room' as Ref + + function makeAccount (): Account { + return { + uuid: CALLER as any, + role: AccountRole.Guest, + primarySocialId: 'test' as PersonId, + socialIds: ['test' as PersonId], + fullSocialIds: [] + } + } + + function makeNext (): Middleware { + return { + findAll: (async (_ctx: any, _class: any, query: any) => { + if (_class === LINK_CLASS && query.collaborator === CALLER) { + return [{ _id: generateId(), collaborator: CALLER, attachedTo: ALLOWED_ROOM }] as any + } + return [] + }) as any + } as any + } + + function makeLinkedHierarchy (): Hierarchy { + return { + classHierarchyMixin: (_class: Ref>): Partial | undefined => { + if (_class !== LINKED_CLASS) return undefined + return { + policy: { + kind: 'linkedViaRecord', + linkClass: LINK_CLASS, + linkTargetField: 'attachedTo', + linkIdentityField: 'collaborator', + identity: 'accountUuid', + targetField: 'room' + }, + allowKnownIdBypass: false + } + } + } as any + } + + it('denies moving the document to a target the caller has no link to (regression test for the linkedViaRecord ownership-transfer gap)', async () => { + const next = makeNext() + const resolver = new RowVisibilityResolver(next) + const identity = new AccountIdentityResolver(next, ctx, makeAccount()) + const doc = { _id: generateId(), _class: LINKED_CLASS, space: TEST_SPACE, room: ALLOWED_ROOM } as any + const factory = new TxFactory('test' as PersonId) + const tx = factory.createTxUpdateDoc(LINKED_CLASS, TEST_SPACE, doc._id, { room: OTHER_ROOM } as any) + + const allowed = await resolver.canUpdate(ctx, makeLinkedHierarchy(), LINKED_CLASS, doc, tx, identity) + expect(allowed).toBe(false) + }) + + it('allows an update that leaves the linked target untouched', async () => { + const next = makeNext() + const resolver = new RowVisibilityResolver(next) + const identity = new AccountIdentityResolver(next, ctx, makeAccount()) + const doc = { _id: generateId(), _class: LINKED_CLASS, space: TEST_SPACE, room: ALLOWED_ROOM } as any + const factory = new TxFactory('test' as PersonId) + const tx = factory.createTxUpdateDoc(LINKED_CLASS, TEST_SPACE, doc._id, { note: 'x' } as any) + + const allowed = await resolver.canUpdate(ctx, makeLinkedHierarchy(), LINKED_CLASS, doc, tx, identity) + expect(allowed).toBe(true) + }) +}) diff --git a/foundations/server/packages/middleware/src/tests/rowVisibilityInvariant.test.ts b/foundations/server/packages/middleware/src/tests/rowVisibilityInvariant.test.ts new file mode 100644 index 0000000000..d77c3fbf7d --- /dev/null +++ b/foundations/server/packages/middleware/src/tests/rowVisibilityInvariant.test.ts @@ -0,0 +1,456 @@ +// +// Copyright © 2026 TraceX SAS. +// +// Licensed under the PolyForm Shield License 1.0.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://polyformproject.org/licenses/shield/1.0.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. +// + +/** + * CI invariant: every class `SpaceSecurityMiddleware` row-level-restricts must declare + * `core.mixin.RowVisibility` - a missing policy should fail the build, not a later review. + * + * Scope: only the sensitive classes restricted today. Widening to every class outside ordinary space + * filtering platform-wide needs a full audit first (dozens of classes are in `core.space.Workspace` + * for unrelated reasons - shared tags, reactions, global settings, ...) - see `docs/security-model.md`. + */ + +import contact, { type SocialIdentityRef } from '@hcengineering/contact' +import core, { + AccountRole, + generateId, + MeasureMetricsContext, + type Account, + type AccountUuid, + type Class, + type Doc, + type Hierarchy, + type MeasureContext, + type PersonId, + type Ref, + type SessionData +} from '@hcengineering/core' +import buildModel from '@hcengineering/model-all' +import type { Middleware, PipelineContext } from '@hcengineering/server-core' +import { SpaceSecurityMiddleware } from '../spaceSecurity' + +const DOCUMENT_PRESENCE = 'pulse:class:DocumentPresence' as Ref> +const TYPING_INDICATOR = 'pulse:class:TypingIndicator' as Ref> +const CHAT_MESSAGE = 'chunter:class:ChatMessage' as Ref> +const THREAD_MESSAGE = 'chunter:class:ThreadMessage' as Ref> +const ATTACHMENT = 'attachment:class:Attachment' as Ref> +const SAVED_MESSAGE = 'activity:class:SavedMessage' as Ref> +const LOVE_ROOM = 'love:class:Room' as Ref> +const LOVE_FLOOR = 'love:class:Floor' as Ref> +const PARTICIPANT_INFO = 'love:class:ParticipantInfo' as Ref> +const PENDING_RECORDING = 'love:class:PendingRecording' as Ref> +const DEVICES_PREFERENCE = 'love:class:DevicesPreference' as Ref> +const CARD = 'card:class:Card' as Ref> + +const SENSITIVE_CLASSES: Array<{ name: string, _class: Ref> }> = [ + { name: 'core.class.Collaborator', _class: core.class.Collaborator }, + { name: 'love.class.MeetingMinutes', _class: 'love:class:MeetingMinutes' as Ref> }, + { name: 'love.class.RoomInfo', _class: 'love:class:RoomInfo' as Ref> }, + { name: 'hr.class.Request', _class: 'hr:class:Request' as Ref> }, + { name: 'notification.class.PushSubscription', _class: 'notification:class:PushSubscription' as Ref> }, + { name: 'guest.class.PublicLink', _class: 'guest:class:PublicLink' as Ref> }, + { name: 'contact.class.SocialIdentity', _class: contact.class.SocialIdentity }, + { name: 'pulse.class.DocumentPresence', _class: DOCUMENT_PRESENCE }, + { name: 'pulse.class.TypingIndicator', _class: TYPING_INDICATOR }, + { name: 'chunter.class.ChatMessage', _class: CHAT_MESSAGE }, + { name: 'chunter.class.ThreadMessage', _class: THREAD_MESSAGE }, + { name: 'attachment.class.Attachment', _class: ATTACHMENT }, + { name: 'activity.class.SavedMessage', _class: SAVED_MESSAGE }, + { name: 'love.class.Room', _class: LOVE_ROOM }, + { name: 'love.class.Floor', _class: LOVE_FLOOR }, + { name: 'love.class.ParticipantInfo', _class: PARTICIPANT_INFO }, + { name: 'love.class.PendingRecording', _class: PENDING_RECORDING }, + { name: 'love.class.DevicesPreference', _class: DEVICES_PREFERENCE }, + { name: 'card.class.Card', _class: CARD } +] + +describe('RowVisibility invariant', () => { + let hierarchy: Hierarchy + + beforeAll(() => { + hierarchy = buildModel().hierarchy + }) + + it.each(SENSITIVE_CLASSES)('$name declares core.mixin.RowVisibility', ({ _class }) => { + const mixin = hierarchy.classHierarchyMixin(_class, core.mixin.RowVisibility) + expect(mixin).toBeDefined() + expect(mixin?.policy).toBeDefined() + expect(typeof mixin?.allowKnownIdBypass).toBe('boolean') + }) + + it('core.class.Collaborator opens Layer 1 to any restricted role, still self-service-only at Layer 2 unless card.ids.GuestCollaboratorClassPermission opts a role in (regression test for the collaborator-edit permission)', () => { + const COLLABORATOR = core.class.Collaborator as Ref> + const access = hierarchy.classHierarchyMixin(COLLABORATOR, core.mixin.TxAccessLevel) + expect(access?.createAccessLevel).toBe(AccountRole.ReadOnlyGuest) + expect(access?.removeAccessLevel).toBe(AccountRole.ReadOnlyGuest) + + const visibility = hierarchy.classHierarchyMixin(COLLABORATOR, core.mixin.RowVisibility) + expect(visibility?.policy).toEqual({ kind: 'ownerField', field: 'collaborator', identity: 'accountUuid' }) + }) + + it('process.class.ApproveRequest opens Layer 1 to any restricted role, scoped to the assigned approver at Layer 2 (regression test for the process-actions permission)', () => { + const APPROVE_REQUEST = 'process:class:ApproveRequest' as Ref> + const access = hierarchy.classHierarchyMixin(APPROVE_REQUEST, core.mixin.TxAccessLevel) + expect(access?.updateAccessLevel).toBe(AccountRole.ReadOnlyGuest) + + const visibility = hierarchy.classHierarchyMixin(APPROVE_REQUEST, core.mixin.RowVisibility) + expect(visibility?.policy).toEqual({ kind: 'ownerField', field: 'user', identity: 'personId' }) + }) + + it('hr.class.Request and love.class.MeetingMinutes do not list their own ownerField/linkTargetField as a known-id bypass (regression test for the ownership bypass fix)', () => { + const hrMixin = hierarchy.classHierarchyMixin('hr:class:Request' as Ref>, core.mixin.RowVisibility) + expect(hrMixin?.knownIdBypassFields ?? []).not.toContain('attachedTo') + + const mmMixin = hierarchy.classHierarchyMixin( + 'love:class:MeetingMinutes' as Ref>, + core.mixin.RowVisibility + ) + expect(mmMixin?.knownIdBypassFields ?? []).not.toContain('attachedTo') + }) + + it('guest.class.PublicLink is scoped to _id, not a bypassable field (regression guard for the linkId-enumeration fix)', () => { + const mixin = hierarchy.classHierarchyMixin('guest:class:PublicLink' as Ref>, core.mixin.RowVisibility) + expect(mixin?.policy).toEqual({ kind: 'ownerField', field: '_id', identity: 'linkId' }) + expect(mixin?.allowKnownIdBypass).toBe(false) + }) + + it('contact.class.SocialIdentity is scoped to the current person but supports known-id resolution', () => { + const mixin = hierarchy.classHierarchyMixin( + contact.class.SocialIdentity as Ref>, + core.mixin.RowVisibility + ) + expect(mixin?.policy).toEqual({ kind: 'ownerField', field: 'attachedTo', identity: 'personId' }) + expect(mixin?.allowKnownIdBypass).toBe(true) + }) + + it('pulse.class.DocumentPresence is public ephemeral activity state', () => { + const mixin = hierarchy.classHierarchyMixin(DOCUMENT_PRESENCE, core.mixin.RowVisibility) + expect(mixin?.policy.kind).toBe('publicReadable') + expect(mixin?.allowKnownIdBypass).toBe(false) + }) + + it('pulse.class.DocumentPresence permits writes starting from ReadOnlyGuest', () => { + const mixin = hierarchy.classHierarchyMixin(DOCUMENT_PRESENCE, core.mixin.TxAccessLevel) + expect(mixin?.createAccessLevel).toBe(AccountRole.ReadOnlyGuest) + expect(mixin?.updateAccessLevel).toBe(AccountRole.ReadOnlyGuest) + expect(mixin?.removeAccessLevel).toBe(AccountRole.ReadOnlyGuest) + }) + + it('pulse.class.TypingIndicator is public ephemeral activity state', () => { + const visibility = hierarchy.classHierarchyMixin(TYPING_INDICATOR, core.mixin.RowVisibility) + expect(visibility?.policy.kind).toBe('publicReadable') + expect(visibility?.allowKnownIdBypass).toBe(false) + + const access = hierarchy.classHierarchyMixin(TYPING_INDICATOR, core.mixin.TxAccessLevel) + expect(access?.createAccessLevel).toBe(AccountRole.ReadOnlyGuest) + expect(access?.updateAccessLevel).toBe(AccountRole.ReadOnlyGuest) + expect(access?.removeAccessLevel).toBe(AccountRole.ReadOnlyGuest) + }) + + it.each([CHAT_MESSAGE, THREAD_MESSAGE])('%s restricts writes to the original author', (_class) => { + const visibility = hierarchy.classHierarchyMixin(_class, core.mixin.RowVisibility) + expect(visibility?.policy.kind).toBe('publicReadable') + expect((visibility as typeof visibility & { writePolicy?: object })?.writePolicy).toEqual({ + kind: 'ownerField', + field: 'createdBy', + identity: 'socialId' + }) + expect(visibility?.allowKnownIdBypass).toBe(false) + + const access = hierarchy.classHierarchyMixin(_class, core.mixin.TxAccessLevel) + expect(access?.createAccessLevel).toBe(AccountRole.Guest) + expect(access?.updateAccessLevel).toBe(AccountRole.Guest) + expect(access?.removeAccessLevel).toBe(AccountRole.Guest) + }) + + it('attachment.class.Attachment restricts writes to the uploader', () => { + const visibility = hierarchy.classHierarchyMixin(ATTACHMENT, core.mixin.RowVisibility) + expect(visibility?.policy.kind).toBe('publicReadable') + expect((visibility as typeof visibility & { writePolicy?: object })?.writePolicy).toEqual({ + kind: 'ownerField', + field: 'createdBy', + identity: 'socialId' + }) + expect(visibility?.allowKnownIdBypass).toBe(false) + + const access = hierarchy.classHierarchyMixin(ATTACHMENT, core.mixin.TxAccessLevel) + expect(access?.createAccessLevel).toBe(AccountRole.Guest) + expect(access?.updateAccessLevel).toBe(AccountRole.Guest) + expect(access?.removeAccessLevel).toBe(AccountRole.Guest) + }) + + it('activity.class.SavedMessage is private to the account social identity', () => { + const visibility = hierarchy.classHierarchyMixin(SAVED_MESSAGE, core.mixin.RowVisibility) + expect(visibility?.policy).toEqual({ kind: 'ownerField', field: 'createdBy', identity: 'socialId' }) + expect(visibility?.allowKnownIdBypass).toBe(false) + + const access = hierarchy.classHierarchyMixin(SAVED_MESSAGE, core.mixin.TxAccessLevel) + expect(access?.createAccessLevel).toBe(AccountRole.Guest) + expect(access?.updateAccessLevel).toBe(AccountRole.Guest) + expect(access?.removeAccessLevel).toBe(AccountRole.Guest) + }) + + it('card.class.Card restricts updates to the creator, reads stay ordinary space-scoped (regression test for the File-card guest-upload fix)', () => { + const visibility = hierarchy.classHierarchyMixin(CARD, core.mixin.RowVisibility) + expect(visibility?.policy.kind).toBe('publicReadable') + expect((visibility as typeof visibility & { writePolicy?: object })?.writePolicy).toEqual({ + kind: 'ownerField', + field: 'createdBy', + identity: 'socialId' + }) + expect(visibility?.allowKnownIdBypass).toBe(false) + + const access = hierarchy.classHierarchyMixin(CARD, core.mixin.TxAccessLevel) + expect(access?.updateAccessLevel).toBe(AccountRole.Guest) + }) + + it('Office room activity is scoped through room collaborators', () => { + const expectedPolicy = { + kind: 'linkedViaRecord', + linkClass: core.class.Collaborator, + linkTargetField: 'attachedTo', + linkIdentityField: 'collaborator', + identity: 'accountUuid', + targetField: 'room', + through: { + documentClass: 'love:class:MeetingMinutes', + sourceField: '_id', + targetField: 'attachedTo', + includeDirect: true + } + } + expect(hierarchy.classHierarchyMixin(PARTICIPANT_INFO, core.mixin.RowVisibility)?.policy).toEqual(expectedPolicy) + expect( + hierarchy.classHierarchyMixin('love:class:RoomInfo' as Ref>, core.mixin.RowVisibility)?.policy + ).toEqual(expectedPolicy) + }) + + it('Office floors are public metadata and device preferences remain private', () => { + expect(hierarchy.classHierarchyMixin(LOVE_FLOOR, core.mixin.RowVisibility)?.policy.kind).toBe('publicReadable') + expect(hierarchy.classHierarchyMixin(DEVICES_PREFERENCE, core.mixin.RowVisibility)?.policy).toEqual({ + kind: 'ownerField', + field: 'createdBy', + identity: 'socialId' + }) + }) +}) + +/** + * Closes the gap the two tests above don't cover: `spaceSecuritySensitiveClasses.test.ts` proves + * `RowVisibilityResolver` behaves correctly against a hand-copied mock of the policies, and the + * `it.each` above proves the real model declares *some* policy - but nothing proves the two agree. + * A typo in a model registration (wrong field name, wrong `identity`) would pass both suites. + * + * Here `SpaceSecurityMiddleware.findAll` runs against the real `buildModel()` hierarchy, so + * `classHierarchyMixin` returns what's actually registered in `models/hr` and `models/guest`. + */ +describe('RowVisibility integration - real model + real resolver', () => { + let hierarchy: Hierarchy + + beforeAll(() => { + hierarchy = buildModel().hierarchy + }) + + function makeAccount (role: AccountRole, uuid: AccountUuid): Account { + return { + uuid, + role, + primarySocialId: 'test' as PersonId, + socialIds: ['test' as PersonId], + fullSocialIds: [] + } + } + + function makeCtx (account: Account, extra?: Record): MeasureContext { + const ctx = new MeasureMetricsContext('test', {}) as MeasureContext + ctx.contextData = { + account, + broadcast: { txes: [], queue: [], sessions: {} }, + extra + } as any + return ctx + } + + function matches (doc: Record, query: Record | undefined): boolean { + for (const key of Object.keys(query ?? {})) { + const cond = (query as any)[key] + const val = doc[key] + if (cond !== null && typeof cond === 'object' && !Array.isArray(cond) && cond.$in !== undefined) { + if (!(cond.$in as any[]).includes(val)) return false + } else if (val !== cond) { + return false + } + } + return true + } + + it('hr.class.Request: real model registration + real resolver clamp open queries to the caller own request', async () => { + const HR_REQUEST = 'hr:class:Request' as Ref> + const ALICE = generateId() as unknown as AccountUuid + const personAlice = { + _id: generateId(), + _class: contact.class.Person, + personUuid: ALICE, + space: contact.space.Contacts + } + const reqAlice = { _id: generateId(), _class: HR_REQUEST, attachedTo: personAlice._id } + const reqBob = { _id: generateId(), _class: HR_REQUEST, attachedTo: generateId() } + + const next: Middleware = { + findAll: (async (_ctx: any, _class: any, query: any) => { + if (_class === core.class.Space) return [] + if (_class === contact.class.Person) return [personAlice].filter((d) => matches(d, query)) as any + if (_class === HR_REQUEST) return [reqAlice, reqBob].filter((d) => matches(d, query)) as any + return [] + }) as any, + groupBy: (async () => new Map()) as any, + searchFulltext: (async () => ({ docs: [], total: 0 })) as any, + tx: (async () => ({})) as any, + handleBroadcast: (async () => {}) as any, + loadModel: (async () => []) as any, + domainRequest: (async () => ({ domain: 'test', value: null })) as any, + closeSession: (async () => {}) as any + } as any + + const context: PipelineContext = { + workspace: { uuid: 'test-workspace' as any, url: 'test', dataId: 'test' as any }, + hierarchy, + modelDb: { findAllSync: () => [] } as any, + branding: null as any, + adapterManager: {} as any, + storageAdapter: {} as any, + contextVars: {}, + lastTx: '', + lastHash: '', + broadcastEvent: async () => {} + } as any + + const mw = new (SpaceSecurityMiddleware as any)(false, context, next) as SpaceSecurityMiddleware + const ctx = makeCtx(makeAccount(AccountRole.Guest, ALICE)) + const res = await mw.findAll(ctx, HR_REQUEST, {}) + expect(res.map((r: any) => r._id)).toEqual([reqAlice._id]) + + // Regression test for the ownership bypass fix: attachedTo is the field the policy itself + // protects, so querying by Bob's attachedTo (his Person id) must not resolve his request. + const byAttachedTo = await mw.findAll(ctx, HR_REQUEST, { attachedTo: reqBob.attachedTo }) + expect(byAttachedTo).toHaveLength(0) + }) + + it('contact.class.SocialIdentity: a Guest sees only identities attached to its own Person', async () => { + const ALICE = generateId() as unknown as AccountUuid + const personAlice = { + _id: generateId(), + _class: contact.class.Person, + personUuid: ALICE, + space: contact.space.Contacts + } + const ownIdentity = { + _id: 'test:alice-social' as SocialIdentityRef, + _class: contact.class.SocialIdentity, + attachedTo: personAlice._id, + space: contact.space.Contacts + } + const otherIdentity = { + _id: 'test:other-social' as SocialIdentityRef, + _class: contact.class.SocialIdentity, + attachedTo: generateId(), + space: contact.space.Contacts + } + + const next: Middleware = { + findAll: (async (_ctx: any, _class: any, query: any) => { + if (_class === core.class.Space) return [] + if (_class === contact.class.Person) return [personAlice].filter((d) => matches(d, query)) as any + if (_class === contact.class.SocialIdentity) { + return [ownIdentity, otherIdentity].filter((d) => matches(d, query)) as any + } + return [] + }) as any, + groupBy: (async () => new Map()) as any, + searchFulltext: (async () => ({ docs: [], total: 0 })) as any, + tx: (async () => ({})) as any, + handleBroadcast: (async () => {}) as any, + loadModel: (async () => []) as any, + domainRequest: (async () => ({ domain: 'test', value: null })) as any, + closeSession: (async () => {}) as any + } as any + + const context: PipelineContext = { + workspace: { uuid: 'test-workspace' as any, url: 'test', dataId: 'test' as any }, + hierarchy, + modelDb: { findAllSync: () => [] } as any, + branding: null as any, + adapterManager: {} as any, + storageAdapter: {} as any, + contextVars: {}, + lastTx: '', + lastHash: '', + broadcastEvent: async () => {} + } as any + + const mw = new (SpaceSecurityMiddleware as any)(false, context, next) as SpaceSecurityMiddleware + const ctx = makeCtx(makeAccount(AccountRole.Guest, ALICE)) + + const visible = await mw.findAll(ctx, contact.class.SocialIdentity, {}) + expect(visible.map((identity: any) => identity._id)).toEqual([ownIdentity._id]) + + const foreignByKnownId = await mw.findAll(ctx, contact.class.SocialIdentity, { _id: otherIdentity._id }) + expect(foreignByKnownId.map((identity: any) => identity._id)).toEqual([otherIdentity._id]) + }) + + it("guest.class.PublicLink: real model registration + real resolver deny a known _id for someone else's link (enumeration-fix regression, end-to-end)", async () => { + const PUBLIC_LINK = 'guest:class:PublicLink' as Ref> + const ALICE = generateId() as unknown as AccountUuid + const linkAlice = { _id: generateId(), _class: PUBLIC_LINK, attachedTo: generateId() } + const linkOther = { _id: generateId(), _class: PUBLIC_LINK, attachedTo: generateId() } + + const next: Middleware = { + findAll: (async (_ctx: any, _class: any, query: any) => { + if (_class === core.class.Space) return [] + if (_class === PUBLIC_LINK) return [linkAlice, linkOther].filter((d) => matches(d, query)) as any + return [] + }) as any, + groupBy: (async () => new Map()) as any, + searchFulltext: (async () => ({ docs: [], total: 0 })) as any, + tx: (async () => ({})) as any, + handleBroadcast: (async () => {}) as any, + loadModel: (async () => []) as any, + domainRequest: (async () => ({ domain: 'test', value: null })) as any, + closeSession: (async () => {}) as any + } as any + + const context: PipelineContext = { + workspace: { uuid: 'test-workspace' as any, url: 'test', dataId: 'test' as any }, + hierarchy, + modelDb: { findAllSync: () => [] } as any, + branding: null as any, + adapterManager: {} as any, + storageAdapter: {} as any, + contextVars: {}, + lastTx: '', + lastHash: '', + broadcastEvent: async () => {} + } as any + + const mw = new (SpaceSecurityMiddleware as any)(false, context, next) as SpaceSecurityMiddleware + const ctx = makeCtx(makeAccount(AccountRole.DocGuest, ALICE), { linkId: linkAlice._id }) + + const own = await mw.findAll(ctx, PUBLIC_LINK, { _id: linkAlice._id } as any) + expect(own.map((r: any) => r._id)).toEqual([linkAlice._id]) + + const other = await mw.findAll(ctx, PUBLIC_LINK, { _id: linkOther._id } as any) + expect(other.length).toBe(0) + }) +}) diff --git a/foundations/server/packages/middleware/src/tests/spaceSecurityDisabledModules.test.ts b/foundations/server/packages/middleware/src/tests/spaceSecurityDisabledModules.test.ts new file mode 100644 index 0000000000..b4f9d7fce5 --- /dev/null +++ b/foundations/server/packages/middleware/src/tests/spaceSecurityDisabledModules.test.ts @@ -0,0 +1,270 @@ +// +// Copyright © 2026 TraceX SAS. +// +// 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 excluding a role-disabled module's spaces from both `searchFulltext` (@-mention/search + * picker) and `findAll` (plain reads, e.g. opening an object by direct navigation). Settings → + * Guest permissions lets an admin flip `ModulePermissionGroup.enabled` off for a role; previously + * this only hid the sidebar app icon and gated writes, while the module's objects still turned up + * in search/mention results *and* remained readable via `findAll` for that role. `spaceSecurity.ts` + * now drops any space whose class matches a disabled group's `spaceClass` from both the + * search-space set and the space field `findAll` narrows content queries to. + */ + +import contact from '@hcengineering/contact' +import core, { + AccountRole, + generateId, + MeasureMetricsContext, + type Account, + type AccountUuid, + type Class, + type Doc, + type MeasureContext, + type PersonId, + type Ref, + type SearchQuery, + type SessionData, + type Space +} from '@hcengineering/core' +import type { Middleware, PipelineContext } from '@hcengineering/server-core' +import { SpaceSecurityMiddleware } from '../spaceSecurity' + +const PRODUCTS_SPACE_CLASS = 'test:class:ProductsSpace' as Ref> +const OTHER_SPACE_CLASS = 'test:class:OtherSpace' as Ref> +const CARD_CLASS = 'test:class:Card' as Ref> + +function makeAccount (role: AccountRole, uuid: AccountUuid): Account { + return { + uuid, + role, + primarySocialId: 'test' as PersonId, + socialIds: ['test' as PersonId], + fullSocialIds: [] + } +} + +function makeCtx (account: Account): MeasureContext { + const ctx = new MeasureMetricsContext('test', {}) as MeasureContext + ctx.contextData = { + account, + broadcast: { txes: [], queue: [], sessions: {} } + } as any + return ctx +} + +function matchesQuery (doc: Record, query: Record | undefined): boolean { + for (const key of Object.keys(query ?? {})) { + const cond = query?.[key] + const val = doc[key] + if (cond !== null && typeof cond === 'object' && !Array.isArray(cond)) { + if (cond.$in !== undefined) { + const included: boolean = cond.$in.includes(val) + if (!included) return false + continue + } + if (cond.$nin !== undefined) { + const excluded: boolean = cond.$nin.includes(val) + if (excluded) return false + continue + } + } + if (val !== cond) { + return false + } + } + return true +} + +function byId (a: string, b: string): number { + return a.localeCompare(b) +} + +async function setup ( + moduleGroups: Array<{ role: AccountRole, enabled: boolean, spaceClass: Ref> }> +): Promise<{ + mw: SpaceSecurityMiddleware + ALICE: AccountUuid + productsSpaceId: Ref + otherSpaceId: Ref + productsCardId: Ref + otherCardId: Ref + capturedQuery: () => SearchQuery | undefined + }> { + const ALICE = generateId() as unknown as AccountUuid + + const productsSpaceId: Ref = generateId() + const otherSpaceId: Ref = generateId() + + const spaces = [ + { _id: productsSpaceId, members: [ALICE], private: false, _class: PRODUCTS_SPACE_CLASS, archived: false }, + { _id: otherSpaceId, members: [ALICE], private: false, _class: OTHER_SPACE_CLASS, archived: false } + ] + + const productsCardId: Ref = generateId() + const otherCardId: Ref = generateId() + const cards = [ + { _id: productsCardId, _class: CARD_CLASS, space: productsSpaceId }, + { _id: otherCardId, _class: CARD_CLASS, space: otherSpaceId } + ] + + let capturedQuery: SearchQuery | undefined + + const next: Middleware = { + findAll: (async (_ctx: any, _class: any, query: any) => { + if (_class === core.class.Space) return spaces.filter((sp) => matchesQuery(sp, query)) as any + if (_class === core.class.ModulePermissionGroup) { + return moduleGroups.filter((g) => matchesQuery(g, query)) as any + } + if (_class === CARD_CLASS) return cards.filter((c) => matchesQuery(c, query)) as any + if (_class === contact.class.Person) return [] + return [] + }) as any, + groupBy: (async (_ctx: any, domain: string) => { + if (domain === 'card') { + return new Map([ + [productsSpaceId, 1], + [otherSpaceId, 1] + ]) + } + return new Map() + }) as any, + searchFulltext: (async (_ctx: any, query: any) => { + capturedQuery = query + return { docs: [], total: 0 } + }) as any, + tx: (async () => ({})) as any, + handleBroadcast: (async () => {}) as any, + loadModel: (async () => []) as any, + domainRequest: (async () => ({ domain: 'test', value: null })) as any, + closeSession: (async () => {}) as any + } as any + + const hierarchy: any = { + isDerived: (a: Ref>, b: Ref>) => { + if (b === core.class.Space) return a === core.class.Space + return a === b + }, + getDomain: (_class: Ref>) => { + if (_class === core.class.Space) return 'space' + if (_class === CARD_CLASS) return 'card' + return 'tx' + } + } + + const context: PipelineContext = { + workspace: { uuid: 'test-workspace' as any, url: 'test', dataId: 'test' as any }, + hierarchy, + modelDb: { findAllSync: () => [] } as any, + branding: null as any, + adapterManager: {} as any, + storageAdapter: {} as any, + contextVars: {}, + lastTx: '', + lastHash: '', + broadcastEvent: async () => {} + } as any + + const mw = new (SpaceSecurityMiddleware as any)(false, context, next) as SpaceSecurityMiddleware + + return { + mw, + ALICE, + productsSpaceId, + otherSpaceId, + productsCardId, + otherCardId, + capturedQuery: () => capturedQuery + } +} + +describe('SpaceSecurityMiddleware – disabled-module exclusion from search', () => { + it('drops the disabled module space from the search-space set for the matching role', async () => { + const s = await setup([{ role: AccountRole.Guest, enabled: false, spaceClass: PRODUCTS_SPACE_CLASS }]) + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE)) + await s.mw.searchFulltext(ctx, { query: 'product' }, { limit: 5 }) + const spaces = s.capturedQuery()?.spaces ?? [] + expect(spaces).not.toContain(s.productsSpaceId) + expect(spaces).toContain(s.otherSpaceId) + }) + + it('does not affect a role the disabled group does not target', async () => { + const s = await setup([{ role: AccountRole.Guest, enabled: false, spaceClass: PRODUCTS_SPACE_CLASS }]) + const ctx = makeCtx(makeAccount(AccountRole.User, s.ALICE)) + await s.mw.searchFulltext(ctx, { query: 'product' }, { limit: 5 }) + const spaces = s.capturedQuery()?.spaces ?? [] + expect(spaces).toContain(s.productsSpaceId) + }) + + it('an enabled module is not excluded', async () => { + const s = await setup([{ role: AccountRole.Guest, enabled: true, spaceClass: PRODUCTS_SPACE_CLASS }]) + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE)) + await s.mw.searchFulltext(ctx, { query: 'product' }, { limit: 5 }) + const spaces = s.capturedQuery()?.spaces ?? [] + expect(spaces).toContain(s.productsSpaceId) + }) + + it('no ModulePermissionGroup docs at all is a no-op', async () => { + const s = await setup([]) + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE)) + await s.mw.searchFulltext(ctx, { query: 'product' }, { limit: 5 }) + const spaces = s.capturedQuery()?.spaces ?? [] + expect(spaces).toContain(s.productsSpaceId) + expect(spaces).toContain(s.otherSpaceId) + }) +}) + +describe('SpaceSecurityMiddleware – disabled-module exclusion from findAll', () => { + it('excludes content living in the disabled module space for the matching role', async () => { + const s = await setup([{ role: AccountRole.Guest, enabled: false, spaceClass: PRODUCTS_SPACE_CLASS }]) + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE)) + const res = await s.mw.findAll(ctx, CARD_CLASS, {}) + const ids = res.map((r: any) => r._id) + expect(ids).not.toContain(s.productsCardId) + expect(ids).toContain(s.otherCardId) + }) + + it('does not affect a role the disabled group does not target', async () => { + const s = await setup([{ role: AccountRole.Guest, enabled: false, spaceClass: PRODUCTS_SPACE_CLASS }]) + const ctx = makeCtx(makeAccount(AccountRole.User, s.ALICE)) + const res = await s.mw.findAll(ctx, CARD_CLASS, {}) + const ids = res.map((r: any) => r._id).sort(byId) + expect(ids).toEqual([s.productsCardId, s.otherCardId].sort(byId)) + }) + + it('an enabled module is not excluded', async () => { + const s = await setup([{ role: AccountRole.Guest, enabled: true, spaceClass: PRODUCTS_SPACE_CLASS }]) + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE)) + const res = await s.mw.findAll(ctx, CARD_CLASS, {}) + expect(res.map((r: any) => r._id)).toContain(s.productsCardId) + }) + + it('no ModulePermissionGroup docs at all is a no-op', async () => { + const s = await setup([]) + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE)) + const res = await s.mw.findAll(ctx, CARD_CLASS, {}) + const ids = res.map((r: any) => r._id).sort(byId) + expect(ids).toEqual([s.productsCardId, s.otherCardId].sort(byId)) + }) + + it('also excludes the Space document itself from a direct core.class.Space query', async () => { + const s = await setup([{ role: AccountRole.Guest, enabled: false, spaceClass: PRODUCTS_SPACE_CLASS }]) + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE)) + const res = await s.mw.findAll(ctx, core.class.Space, {}) + const ids = res.map((r: any) => r._id) + expect(ids).not.toContain(s.productsSpaceId) + expect(ids).toContain(s.otherSpaceId) + }) +}) diff --git a/foundations/server/packages/middleware/src/tests/spaceSecurityPersonVisibility.test.ts b/foundations/server/packages/middleware/src/tests/spaceSecurityPersonVisibility.test.ts new file mode 100644 index 0000000000..09ae17817b --- /dev/null +++ b/foundations/server/packages/middleware/src/tests/spaceSecurityPersonVisibility.test.ts @@ -0,0 +1,324 @@ +// +// Copyright © 2026 TraceX SAS. +// +// 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 the guest/read-only-guest/doc-guest People-visibility restriction in + * SpaceSecurityMiddleware. + * + * Verifies that: + * - Open (no `_id`) Person/Employee queries from Guest/ReadOnlyGuest/DocGuest are narrowed to + * accounts that share a real space with the caller (plus the caller themself). + * - A query that already names specific `_id`s (a bare ref or `{ $in: [...] }`) is never + * narrowed — this is how an already-visible document's author/assignee keeps resolving. + * - Guests belonging to several spaces see the union of all those spaces' members. + * - A guest with no space membership at all (and DocGuest, which is never a real space member) + * gets an empty result for open queries, but can still resolve a known `_id`. + * - Regular `User` accounts are completely unaffected. + * - `searchFulltext` (the path the @-mention picker uses) is unblocked for Person/Employee + * classes for these roles (fixing the "empty picker" bug) while still only returning + * space-mates in the result set (fixing the "guest sees everyone" leak). + */ + +import contact from '@hcengineering/contact' +import core, { + AccountRole, + generateId, + MeasureMetricsContext, + type Account, + type AccountUuid, + type Class, + type Doc, + type MeasureContext, + type PersonId, + type Ref, + type SearchResult, + type SessionData, + type Space +} from '@hcengineering/core' +import type { Middleware, PipelineContext } from '@hcengineering/server-core' +import { SpaceSecurityMiddleware } from '../spaceSecurity' + +const PERSON_CLASS = contact.class.Person + +function makeAccount (role: AccountRole, uuid?: AccountUuid): Account { + return { + uuid: (uuid ?? generateId()) as AccountUuid, + role, + primarySocialId: 'test' as PersonId, + socialIds: ['test' as PersonId], + fullSocialIds: [] + } +} + +function makeCtx (account: Account): MeasureContext { + const ctx = new MeasureMetricsContext('test', {}) as MeasureContext + ctx.contextData = { + account, + broadcast: { txes: [], queue: [], sessions: {} } + } as any + return ctx +} + +function matchesQuery (doc: Record, query: Record | undefined): boolean { + for (const key of Object.keys(query ?? {})) { + const cond = query?.[key] + const val = doc[key] + if (cond !== null && typeof cond === 'object' && !Array.isArray(cond) && cond.$in !== undefined) { + const included: boolean = cond.$in.includes(val) + if (!included) return false + } else if (val !== cond) { + return false + } + } + return true +} + +function byId (a: string, b: string): number { + return a.localeCompare(b) +} + +interface TestSetup { + mw: SpaceSecurityMiddleware + ALICE: AccountUuid + BOB: AccountUuid + CAROL: AccountUuid + DAVE: AccountUuid + personAlice: Ref + personBob: Ref + personCarol: Ref + searchDocs: SearchResult + capturedSearchQuery: { spaces?: Ref[] } | undefined +} + +async function setup (): Promise { + const ALICE = generateId() as unknown as AccountUuid + const BOB = generateId() as unknown as AccountUuid + const CAROL = generateId() as unknown as AccountUuid + // DAVE belongs to both P1 and P2 - used to verify multi-space guests see the union of members. + const DAVE = generateId() as unknown as AccountUuid + + const P1: Ref = generateId() + const P2: Ref = generateId() + + const spaces = [ + { + _id: P1, + members: [ALICE, BOB, DAVE], + private: false, + _class: 'test:class:Project' as Ref>, + archived: false + }, + { + _id: P2, + members: [CAROL, DAVE], + private: false, + _class: 'test:class:Project' as Ref>, + archived: false + } + ] + + const personAlice = { _id: generateId(), _class: PERSON_CLASS, personUuid: ALICE, space: contact.space.Contacts } + const personBob = { _id: generateId(), _class: PERSON_CLASS, personUuid: BOB, space: contact.space.Contacts } + const personCarol = { _id: generateId(), _class: PERSON_CLASS, personUuid: CAROL, space: contact.space.Contacts } + const persons = [personAlice, personBob, personCarol] + + let capturedSearchQuery: { spaces?: Ref[] } | undefined + let searchDocs: SearchResult = { docs: [], total: 0 } + + const next: Middleware = { + findAll: (async (_ctx: any, _class: any, query: any) => { + if (_class === core.class.Space) { + return spaces as any + } + if (_class === PERSON_CLASS) { + return persons.filter((p) => matchesQuery(p, query)) as any + } + return [] + }) as any, + groupBy: (async () => new Map()) as any, + searchFulltext: (async (_ctx: any, query: any) => { + capturedSearchQuery = query + return searchDocs + }) as any, + tx: (async () => ({})) as any, + handleBroadcast: (async () => {}) as any, + loadModel: (async () => []) as any, + groupByField: undefined, + domainRequest: (async () => ({ domain: 'test', value: null })) as any, + closeSession: (async () => {}) as any + } as any + + const hierarchy: any = { + isDerived: (a: Ref>, b: Ref>) => { + if (b === core.class.Space) return a === core.class.Space + if (b === PERSON_CLASS) return a === PERSON_CLASS + return a === b + }, + getDomain: (_class: Ref>) => { + if (_class === core.class.Space) return 'space' + if (_class === PERSON_CLASS) return 'contact' + return 'tx' + } + } + + const context: PipelineContext = { + workspace: { uuid: 'test-workspace' as any, url: 'test', dataId: 'test' as any }, + hierarchy, + modelDb: { findAllSync: () => [] } as any, + branding: null as any, + adapterManager: {} as any, + storageAdapter: {} as any, + contextVars: {}, + lastTx: '', + lastHash: '', + broadcastEvent: async () => {} + } as any + + const mw = new (SpaceSecurityMiddleware as any)(false, context, next) as SpaceSecurityMiddleware + + return { + mw, + ALICE, + BOB, + CAROL, + DAVE, + personAlice: personAlice._id, + personBob: personBob._id, + personCarol: personCarol._id, + get searchDocs () { + return searchDocs + }, + set searchDocs (v: SearchResult) { + searchDocs = v + }, + get capturedSearchQuery () { + return capturedSearchQuery + } + } as any +} + +describe('SpaceSecurityMiddleware – guest People visibility', () => { + describe('findAll: open (browse) queries', () => { + it('Guest sees only people from spaces they belong to', async () => { + const s = await setup() + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE)) + const res = await s.mw.findAll(ctx, PERSON_CLASS, {}) + const ids = res.map((r: any) => r._id).sort(byId) + expect(ids).toEqual([s.personAlice, s.personBob].sort(byId)) + }) + + it('ReadOnlyGuest sees only people from spaces they belong to', async () => { + const s = await setup() + const ctx = makeCtx(makeAccount(AccountRole.ReadOnlyGuest, s.ALICE)) + const res = await s.mw.findAll(ctx, PERSON_CLASS, {}) + const ids = res.map((r: any) => r._id).sort(byId) + expect(ids).toEqual([s.personAlice, s.personBob].sort(byId)) + }) + + it('a guest belonging to only one space (P2) does not see P1 members', async () => { + const s = await setup() + const carolCtx = makeCtx(makeAccount(AccountRole.Guest, s.CAROL)) + const carolRes = await s.mw.findAll(carolCtx, PERSON_CLASS, {}) + expect(carolRes.map((r: any) => r._id)).toEqual([s.personCarol]) + }) + + it('a guest belonging to both P1 and P2 sees the union of both spaces members', async () => { + const s = await setup() + const daveCtx = makeCtx(makeAccount(AccountRole.Guest, s.DAVE)) + const daveRes = await s.mw.findAll(daveCtx, PERSON_CLASS, {}) + const ids = daveRes.map((r: any) => r._id).sort(byId) + expect(ids).toEqual([s.personAlice, s.personBob, s.personCarol].sort(byId)) + }) + + it('_id-scoped lookup bypasses the restriction (resolving an already-visible doc reference)', async () => { + const s = await setup() + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE)) + const res = await s.mw.findAll(ctx, PERSON_CLASS, { _id: s.personCarol } as any) + expect(res.map((r: any) => r._id)).toEqual([s.personCarol]) + }) + + it('_id: { $in: [...] } lookup also bypasses the restriction', async () => { + const s = await setup() + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE)) + const res = await s.mw.findAll(ctx, PERSON_CLASS, { _id: { $in: [s.personCarol, s.personBob] } } as any) + expect(res.map((r: any) => r._id).sort(byId)).toEqual([s.personCarol, s.personBob].sort(byId)) + }) + + it('a guest with no space membership gets an empty result for an open query', async () => { + const s = await setup() + const stranger = generateId() as unknown as AccountUuid + const ctx = makeCtx(makeAccount(AccountRole.Guest, stranger)) + const res = await s.mw.findAll(ctx, PERSON_CLASS, {}) + expect(res.length).toBe(0) + }) + + it('DocGuest (never a real space member) gets an empty result for an open query, but can still resolve a known _id', async () => { + const s = await setup() + const ctx = makeCtx(makeAccount(AccountRole.DocGuest, generateId() as unknown as AccountUuid)) + const openRes = await s.mw.findAll(ctx, PERSON_CLASS, {}) + expect(openRes.length).toBe(0) + + const idRes = await s.mw.findAll(ctx, PERSON_CLASS, { _id: s.personAlice } as any) + expect(idRes.map((r: any) => r._id)).toEqual([s.personAlice]) + }) + + it('regular User accounts are not restricted', async () => { + const s = await setup() + const ctx = makeCtx(makeAccount(AccountRole.User, s.BOB)) + const res = await s.mw.findAll(ctx, PERSON_CLASS, {}) + const ids = res.map((r: any) => r._id).sort(byId) + expect(ids).toEqual([s.personAlice, s.personBob, s.personCarol].sort(byId)) + }) + }) + + describe('searchFulltext: @-mention / People picker', () => { + it('is unblocked for Guest for Person/Employee classes, but only returns space-mates', async () => { + const s = await setup() + // Simulate the underlying fulltext index returning everyone that matched the text query - + // this is what would happen today before our fix narrows contact.space.Contacts visibility. + ;(s as any).searchDocs = { + docs: [ + { id: s.personAlice, doc: { _id: s.personAlice, _class: PERSON_CLASS, createdOn: 0 } }, + { id: s.personBob, doc: { _id: s.personBob, _class: PERSON_CLASS, createdOn: 0 } }, + { id: s.personCarol, doc: { _id: s.personCarol, _class: PERSON_CLASS, createdOn: 0 } } + ], + total: 3 + } as any + + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE)) + const result = await s.mw.searchFulltext(ctx, { query: 'a', classes: [PERSON_CLASS] }, { limit: 5 }) + + // Bug being fixed: previously contact.space.Contacts was stripped from allowed search + // spaces for Guest/ReadOnlyGuest, so the query sent downstream would exclude it entirely + // and the picker would always come back empty. Confirm it's included now. + expect(s.capturedSearchQuery?.spaces).toContain(contact.space.Contacts) + + // But the result set must still be narrowed to Alice's actual space-mates. + expect(result.docs.map((d) => d.id).sort(byId)).toEqual([s.personAlice, s.personBob].sort(byId)) + }) + + it('does not leak people outside the guest space when searching a broader class set', async () => { + const s = await setup() + ;(s as any).searchDocs = { + docs: [{ id: s.personCarol, doc: { _id: s.personCarol, _class: PERSON_CLASS, createdOn: 0 } }], + total: 1 + } as any + + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE)) + const result = await s.mw.searchFulltext(ctx, { query: 'carol', classes: [PERSON_CLASS] }, { limit: 5 }) + expect(result.docs).toEqual([]) + }) + }) +}) diff --git a/foundations/server/packages/middleware/src/tests/spaceSecuritySensitiveClasses.test.ts b/foundations/server/packages/middleware/src/tests/spaceSecuritySensitiveClasses.test.ts new file mode 100644 index 0000000000..290272fede --- /dev/null +++ b/foundations/server/packages/middleware/src/tests/spaceSecuritySensitiveClasses.test.ts @@ -0,0 +1,462 @@ +// +// Copyright © 2026 TraceX SAS. +// +// 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 `RowVisibilityResolver`'s behavior (Layer 2) against a mock `Hierarchy` whose + * `classHierarchyMixin` returns the same policies the real model declares (see + * `rowVisibilityInvariant.test.ts` for checking they're actually declared there). + * + * - Collaborator: open queries clamped to the caller's own records; `_id`/`attachedTo` lookups + * bypass the clamp (`attachedTo` here is the *linked* record, not the policy's own field). + * - MeetingMinutes/HR Request: open queries clamped to the caller's own records; only `_id` + * bypasses the clamp - `attachedTo` is the field the policy itself protects, so it must not + * also appear as a bypass field (regression test for the ownership bypass fix). + * - Room: publicReadable - visible to every guest regardless of collaborator status (the office + * layout needs to render all rooms; only the MeetingMinutes documents attached to a room stay + * collaborator-restricted). + * - RoomInfo: open queries are still clamped to rooms where the caller is a collaborator. + * - PushSubscription: always clamped to the caller's own `user`, no bypass. + * - PublicLink: denied unless `_id` matches the caller's own `linkId` (from the session token, + * not the account - every guest shares one account) - regression test for the enumeration fix. + * - Regular `User` accounts are unaffected. + */ + +import contact from '@hcengineering/contact' +import core, { + AccountRole, + generateId, + MeasureMetricsContext, + type Account, + type AccountUuid, + type Class, + type Doc, + type MeasureContext, + type PersonId, + type RowVisibility, + type SessionData, + type Ref +} from '@hcengineering/core' +import type { Middleware, PipelineContext } from '@hcengineering/server-core' +import { SpaceSecurityMiddleware } from '../spaceSecurity' + +const MEETING_MINUTES = 'love:class:MeetingMinutes' as Ref> +const ROOM_INFO = 'love:class:RoomInfo' as Ref> +const ROOM = 'love:class:Room' as Ref> +const HR_REQUEST = 'hr:class:Request' as Ref> +const PUSH_SUBSCRIPTION = 'notification:class:PushSubscription' as Ref> +const PUBLIC_LINK = 'guest:class:PublicLink' as Ref> +const UNDECLARED_SHARED_CLASS = 'test:class:UndeclaredShared' as Ref> +const PERSON_CLASS = contact.class.Person + +// Mirrors the real policies registered via `builder.mixin(..., core.mixin.RowVisibility, {...})` +// in `models/core`, `models/love`, `models/hr`, `models/notification` and `models/guest`. +const ROW_VISIBILITY: Partial>, Partial>> = { + [core.class.Collaborator]: { + policy: { kind: 'ownerField', field: 'collaborator', identity: 'accountUuid' }, + allowKnownIdBypass: true, + knownIdBypassFields: ['attachedTo'] + }, + [MEETING_MINUTES]: { + policy: { + kind: 'linkedViaRecord', + linkClass: core.class.Collaborator, + linkTargetField: 'attachedTo', + linkIdentityField: 'collaborator', + identity: 'accountUuid' + }, + allowKnownIdBypass: true + }, + [ROOM]: { + policy: { kind: 'publicReadable', reason: 'Office rooms are visible to every guest' }, + allowKnownIdBypass: false + }, + [ROOM_INFO]: { + policy: { + kind: 'linkedViaRecord', + linkClass: core.class.Collaborator, + linkTargetField: 'attachedTo', + linkIdentityField: 'collaborator', + identity: 'accountUuid', + targetField: 'room', + through: { + documentClass: MEETING_MINUTES, + sourceField: '_id', + targetField: 'attachedTo', + includeDirect: true + } + }, + allowKnownIdBypass: false + }, + [PUBLIC_LINK]: { + policy: { kind: 'ownerField', field: '_id', identity: 'linkId' }, + allowKnownIdBypass: false + }, + [HR_REQUEST]: { + policy: { kind: 'ownerField', field: 'attachedTo', identity: 'personId' }, + allowKnownIdBypass: true + }, + [PUSH_SUBSCRIPTION]: { + policy: { kind: 'ownerField', field: 'user', identity: 'accountUuid' }, + allowKnownIdBypass: false + } +} + +function makeAccount (role: AccountRole, uuid?: AccountUuid): Account { + return { + uuid: (uuid ?? generateId()) as AccountUuid, + role, + primarySocialId: 'test' as PersonId, + socialIds: ['test' as PersonId], + fullSocialIds: [] + } +} + +function makeCtx (account: Account, extra?: Record): MeasureContext { + const ctx = new MeasureMetricsContext('test', {}) as MeasureContext + ctx.contextData = { + account, + broadcast: { txes: [], queue: [], sessions: {} }, + extra + } as any + return ctx +} + +function matchesQuery (doc: Record, query: Record | undefined): boolean { + for (const key of Object.keys(query ?? {})) { + const cond = query?.[key] + const val = doc[key] + if (cond !== null && typeof cond === 'object' && !Array.isArray(cond) && cond.$in !== undefined) { + const included: boolean = cond.$in.includes(val) + if (!included) return false + } else if (cond !== null && typeof cond === 'object' && !Array.isArray(cond) && cond.$nin !== undefined) { + if (cond.$nin.includes(val) === true) return false + } else if (val !== cond) { + return false + } + } + return true +} + +async function setup (): Promise<{ + mw: SpaceSecurityMiddleware + ALICE: AccountUuid + BOB: AccountUuid + personAlice: Ref + personBob: Ref + mmAlice: Ref + mmBob: Ref + roomAlice: Ref + roomDirect: Ref + roomBob: Ref + reqAlice: Ref + reqBob: Ref + linkAlice: Ref + linkOther: Ref +}> { + const ALICE = generateId() as unknown as AccountUuid + const BOB = generateId() as unknown as AccountUuid + + const personAlice = { _id: generateId(), _class: PERSON_CLASS, personUuid: ALICE, space: contact.space.Contacts } + const personBob = { _id: generateId(), _class: PERSON_CLASS, personUuid: BOB, space: contact.space.Contacts } + const persons = [personAlice, personBob] + + const roomAlice = { _id: generateId(), _class: ROOM, space: core.space.Workspace } + const roomDirect = { _id: generateId(), _class: ROOM, space: core.space.Workspace } + const roomBob = { _id: generateId(), _class: ROOM, space: core.space.Workspace } + const rooms = [roomAlice, roomDirect, roomBob] + + const mmAlice = { + _id: generateId(), + _class: MEETING_MINUTES, + space: core.space.Workspace, + attachedTo: roomAlice._id + } + const mmBob = { + _id: generateId(), + _class: MEETING_MINUTES, + space: core.space.Workspace, + attachedTo: roomBob._id + } + const meetingMinutes = [mmAlice, mmBob] + + const collaborators = [ + { _id: generateId(), _class: core.class.Collaborator, collaborator: ALICE, attachedTo: mmAlice._id }, + { _id: generateId(), _class: core.class.Collaborator, collaborator: BOB, attachedTo: mmBob._id }, + { _id: generateId(), _class: core.class.Collaborator, collaborator: ALICE, attachedTo: roomDirect._id }, + { _id: generateId(), _class: core.class.Collaborator, collaborator: BOB, attachedTo: roomBob._id } + ] + + const roomInfos = [ + { _id: generateId(), _class: ROOM_INFO, room: roomAlice._id, persons: [] }, + { _id: generateId(), _class: ROOM_INFO, room: roomDirect._id, persons: [] }, + { _id: generateId(), _class: ROOM_INFO, room: roomBob._id, persons: [] } + ] + + const reqAlice = { _id: generateId(), _class: HR_REQUEST, attachedTo: personAlice._id } + const reqBob = { _id: generateId(), _class: HR_REQUEST, attachedTo: personBob._id } + const hrRequests = [reqAlice, reqBob] + + const pushSubs = [ + { _id: generateId(), _class: PUSH_SUBSCRIPTION, user: ALICE }, + { _id: generateId(), _class: PUSH_SUBSCRIPTION, user: BOB } + ] + + const linkAlice = { _id: generateId(), _class: PUBLIC_LINK, attachedTo: generateId() } + const linkOther = { _id: generateId(), _class: PUBLIC_LINK, attachedTo: generateId() } + const publicLinks = [linkAlice, linkOther] + const undeclaredShared = [{ _id: generateId(), _class: UNDECLARED_SHARED_CLASS, space: core.space.Workspace }] + + const next: Middleware = { + findAll: (async (_ctx: any, _class: any, query: any) => { + if (_class === core.class.Space) return [] + if (_class === PERSON_CLASS) return persons.filter((p) => matchesQuery(p, query)) as any + if (_class === MEETING_MINUTES) return meetingMinutes.filter((d) => matchesQuery(d, query)) as any + if (_class === ROOM) return rooms.filter((d) => matchesQuery(d, query)) as any + if (_class === core.class.Collaborator) return collaborators.filter((d) => matchesQuery(d, query)) as any + if (_class === ROOM_INFO) return roomInfos.filter((d) => matchesQuery(d, query)) as any + if (_class === HR_REQUEST) return hrRequests.filter((d) => matchesQuery(d, query)) as any + if (_class === PUSH_SUBSCRIPTION) return pushSubs.filter((d) => matchesQuery(d, query)) as any + if (_class === PUBLIC_LINK) return publicLinks.filter((d) => matchesQuery(d, query)) as any + if (_class === UNDECLARED_SHARED_CLASS) return undeclaredShared.filter((d) => matchesQuery(d, query)) as any + return [] + }) as any, + groupBy: (async () => new Map()) as any, + searchFulltext: (async () => ({ + docs: meetingMinutes.map((doc) => ({ + id: doc._id, + doc: { _id: doc._id, _class: doc._class, createdOn: 0 } + })), + total: meetingMinutes.length + })) as any, + tx: (async () => ({})) as any, + handleBroadcast: (async () => {}) as any, + loadModel: (async () => []) as any, + domainRequest: (async () => ({ domain: 'test', value: null })) as any, + closeSession: (async () => {}) as any + } as any + + const hierarchy: any = { + isDerived: (a: Ref>, b: Ref>) => { + if (b === core.class.Space) return a === core.class.Space + return a === b + }, + getDomain: (_class: Ref>) => { + if (_class === core.class.Space) return 'space' + return 'test-domain' + }, + classHierarchyMixin: (_class: Ref>) => ROW_VISIBILITY[_class] + } + + const context: PipelineContext = { + workspace: { uuid: 'test-workspace' as any, url: 'test', dataId: 'test' as any }, + hierarchy, + modelDb: { findAllSync: () => [] } as any, + branding: null as any, + adapterManager: {} as any, + storageAdapter: {} as any, + contextVars: {}, + lastTx: '', + lastHash: '', + broadcastEvent: async () => {} + } as any + + const mw = new (SpaceSecurityMiddleware as any)(false, context, next) as SpaceSecurityMiddleware + + return { + mw, + ALICE, + BOB, + personAlice: personAlice._id, + personBob: personBob._id, + mmAlice: mmAlice._id, + mmBob: mmBob._id, + roomAlice: roomAlice._id, + roomDirect: roomDirect._id, + roomBob: roomBob._id, + reqAlice: reqAlice._id, + reqBob: reqBob._id, + linkAlice: linkAlice._id, + linkOther: linkOther._id + } +} + +describe('SpaceSecurityMiddleware – row-level visibility for core.space.Workspace-resident classes', () => { + it('default-denies an undeclared policy in a shared space', async () => { + const s = await setup() + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE)) + const result = await s.mw.findAll(ctx, UNDECLARED_SHARED_CLASS, {}) + expect(result).toEqual([]) + }) + + describe('full-text search', () => { + it('does not treat a search result id as a known-id bypass', async () => { + const s = await setup() + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE)) + const result = await s.mw.searchFulltext(ctx, { query: 'minutes', classes: [MEETING_MINUTES] }, {}) + expect(result.docs.map((doc) => doc.doc._id)).toEqual([s.mmAlice]) + expect(result.total).toBe(1) + }) + }) + + describe('core.class.Collaborator', () => { + it('an open query is clamped to the caller own collaborator records', async () => { + const s = await setup() + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE)) + const res = await s.mw.findAll(ctx, core.class.Collaborator, {}) + expect(res).toHaveLength(2) + expect(new Set(res.map((r: any) => r.collaborator))).toEqual(new Set([s.ALICE])) + }) + + it('a query narrowed by attachedTo bypasses the restriction', async () => { + const s = await setup() + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE)) + const res = await s.mw.findAll(ctx, core.class.Collaborator, { attachedTo: s.mmBob } as any) + expect(res.map((r: any) => r.attachedTo)).toEqual([s.mmBob]) + }) + }) + + describe('love.class.MeetingMinutes', () => { + it('an open query only returns minutes the caller is a collaborator on', async () => { + const s = await setup() + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE)) + const res = await s.mw.findAll(ctx, MEETING_MINUTES, {}) + expect(res.map((r: any) => r._id)).toEqual([s.mmAlice]) + }) + + it('a caller with no collaborator record gets nothing back for an open query', async () => { + const s = await setup() + const stranger = generateId() as unknown as AccountUuid + const ctx = makeCtx(makeAccount(AccountRole.Guest, stranger)) + const res = await s.mw.findAll(ctx, MEETING_MINUTES, {}) + expect(res.length).toBe(0) + }) + + it('an _id lookup bypasses the restriction', async () => { + const s = await setup() + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE)) + const byId = await s.mw.findAll(ctx, MEETING_MINUTES, { _id: s.mmBob } as any) + expect(byId.map((r: any) => r._id)).toEqual([s.mmBob]) + }) + + it('an attachedTo lookup does NOT bypass the restriction (regression test for the ownership bypass fix)', async () => { + const s = await setup() + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE)) + // Alice asks for Bob's minutes by their attachedTo (Bob's Room id). Since attachedTo is the + // field the linkedViaRecord policy itself protects, this must not resolve Bob's minutes. + const res = await s.mw.findAll(ctx, MEETING_MINUTES, { attachedTo: s.roomBob } as any) + expect(res).toHaveLength(0) + }) + }) + + describe('love.class.RoomInfo', () => { + it('only returns activity for rooms where the caller is a collaborator', async () => { + const s = await setup() + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE)) + const res = await s.mw.findAll(ctx, ROOM_INFO, {}) + expect(res.map((r: any) => r.room)).toEqual([s.roomAlice, s.roomDirect]) + }) + }) + + describe('love.class.Room', () => { + it('returns every room regardless of collaborator status (publicReadable, per the office-layout change)', async () => { + const s = await setup() + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE)) + const res = await s.mw.findAll(ctx, ROOM, {}) + expect(new Set(res.map((r: any) => r._id))).toEqual(new Set([s.roomAlice, s.roomDirect, s.roomBob])) + }) + + it('resolves a room the caller is not a collaborator on through a known id', async () => { + const s = await setup() + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE)) + const res = await s.mw.findAll(ctx, ROOM, { _id: s.roomBob } as any) + expect(res.map((r: any) => r._id)).toEqual([s.roomBob]) + }) + }) + + describe('guest.class.PublicLink', () => { + it('open browse only ever narrows to the caller own link, never lists others', async () => { + const s = await setup() + const ctx = makeCtx(makeAccount(AccountRole.DocGuest, s.ALICE), { linkId: s.linkAlice }) + const res = await s.mw.findAll(ctx, PUBLIC_LINK, {}) + expect(res.map((r: any) => r._id)).toEqual([s.linkAlice]) + }) + + it('a known _id query for the caller own link resolves', async () => { + const s = await setup() + const ctx = makeCtx(makeAccount(AccountRole.DocGuest, s.ALICE), { linkId: s.linkAlice }) + const res = await s.mw.findAll(ctx, PUBLIC_LINK, { _id: s.linkAlice } as any) + expect(res.map((r: any) => r._id)).toEqual([s.linkAlice]) + }) + + it('a known _id query for a DIFFERENT link is denied, not silently redirected (regression test for the enumeration fix)', async () => { + const s = await setup() + const ctx = makeCtx(makeAccount(AccountRole.DocGuest, s.ALICE), { linkId: s.linkAlice }) + const res = await s.mw.findAll(ctx, PUBLIC_LINK, { _id: s.linkOther } as any) + expect(res.length).toBe(0) + }) + + it('a session with no linkId claim at all gets nothing, even for a known _id', async () => { + const s = await setup() + const ctx = makeCtx(makeAccount(AccountRole.DocGuest, s.ALICE)) + const res = await s.mw.findAll(ctx, PUBLIC_LINK, { _id: s.linkAlice } as any) + expect(res.length).toBe(0) + }) + }) + + describe('hr.class.Request', () => { + it('an open query is clamped to the caller own attached request', async () => { + const s = await setup() + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE)) + const res = await s.mw.findAll(ctx, HR_REQUEST, {}) + expect(res.map((r: any) => r._id)).toEqual([s.reqAlice]) + }) + + it('an attachedTo lookup does NOT bypass the own-record clamp (regression test for the ownership bypass fix)', async () => { + const s = await setup() + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE)) + // Alice explicitly asks for Bob's request by its attachedTo (Bob's Person id). attachedTo is + // the field the ownerField policy itself protects, so this must still come back empty. + const res = await s.mw.findAll(ctx, HR_REQUEST, { attachedTo: s.personBob } as any) + expect(res).toHaveLength(0) + }) + }) + + describe('notification.class.PushSubscription', () => { + it('is always clamped to the caller own user, even for an open query', async () => { + const s = await setup() + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE)) + const res = await s.mw.findAll(ctx, PUSH_SUBSCRIPTION, {}) + expect(res.map((r: any) => r.user)).toEqual([s.ALICE]) + }) + + it('an _id-narrowed query for someone else’s subscription does not bypass the clamp', async () => { + const s = await setup() + const ctx = makeCtx(makeAccount(AccountRole.Guest, s.ALICE)) + const res = await s.mw.findAll(ctx, PUSH_SUBSCRIPTION, {}) + expect(res.every((r: any) => r.user === s.ALICE)).toBe(true) + }) + }) + + describe('regular User accounts', () => { + it('are not restricted for any of the sensitive classes', async () => { + const s = await setup() + const ctx = makeCtx(makeAccount(AccountRole.User, s.BOB)) + const collabRes = await s.mw.findAll(ctx, core.class.Collaborator, {}) + expect(collabRes.length).toBe(4) + const mmRes = await s.mw.findAll(ctx, MEETING_MINUTES, {}) + expect(mmRes.length).toBe(2) + }) + }) +}) diff --git a/foundations/server/packages/server/src/client.ts b/foundations/server/packages/server/src/client.ts index b7770d41e1..0e1dbb6131 100644 --- a/foundations/server/packages/server/src/client.ts +++ b/foundations/server/packages/server/src/client.ts @@ -1,5 +1,6 @@ // // Copyright © 2022 Hardcore Engineering Inc. +// Copyright © 2026 TraceX SAS. // // 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 @@ -180,7 +181,8 @@ export class ClientSession implements Session { ctx.pipeline.context.modelDb, ctx.socialStringsToUsers, this.token.extra?.service ?? '🤦‍♂️user', - this.getPermissionsGrant() + this.getPermissionsGrant(), + this.token.extra ) ctx.ctx.contextData = contextData } @@ -318,7 +320,19 @@ export class ClientSession implements Session { } } catch (err) { await ctx.sendError(ctx.requestId, 'Failed to tx', unknownError(err)) - ctx.ctx.error('failed to tx', { err }) + const sessionData = ctx.ctx.contextData as SessionData + const cud = TxProcessor.isExtendsCUD(tx._class) ? (tx as TxCUD) : undefined + ctx.ctx.error('failed to tx', { + err, + accountUuid: sessionData.account.uuid, + accountRole: sessionData.account.role, + service: sessionData.service, + txClass: tx._class, + modifiedBy: tx.modifiedBy, + objectClass: cud?.objectClass, + objectId: cud?.objectId, + objectSpace: cud?.objectSpace + }) } }) } diff --git a/models/activity/src/index.ts b/models/activity/src/index.ts index 5bc99c74b3..90905d37bd 100644 --- a/models/activity/src/index.ts +++ b/models/activity/src/index.ts @@ -1,5 +1,6 @@ // // Copyright © 2020, 2021 Anticrm Platform Contributors. +// Copyright © 2026 TraceX SAS. // // 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 @@ -278,6 +279,17 @@ export function createModel (builder: Builder): void { TUserMentionInfo ) + builder.mixin(activity.class.SavedMessage, core.class.Class, core.mixin.TxAccessLevel, { + createAccessLevel: AccountRole.Guest, + updateAccessLevel: AccountRole.Guest, + removeAccessLevel: AccountRole.Guest + }) + + builder.mixin(activity.class.SavedMessage, core.class.Class, core.mixin.RowVisibility, { + policy: { kind: 'ownerField', field: 'createdBy', identity: 'socialId' }, + allowKnownIdBypass: false + }) + builder.mixin(activity.class.Reaction, core.class.Class, core.mixin.TxAccessLevel, { createAccessLevel: AccountRole.Guest, updateAccessLevel: AccountRole.Guest, diff --git a/models/attachment/src/index.ts b/models/attachment/src/index.ts index f4adb00a12..553df2b886 100644 --- a/models/attachment/src/index.ts +++ b/models/attachment/src/index.ts @@ -1,5 +1,6 @@ // // Copyright © 2020, 2021 Anticrm Platform Contributors. +// Copyright © 2026 TraceX SAS. // // 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 @@ -125,9 +126,19 @@ export function createModel (builder: Builder): void { }) builder.mixin(attachment.class.Attachment, core.class.Class, core.mixin.TxAccessLevel, { - createAccessLevel: AccountRole.Guest + createAccessLevel: AccountRole.Guest, + updateAccessLevel: AccountRole.Guest, + removeAccessLevel: AccountRole.Guest }) + const attachmentVisibility = { + policy: { kind: 'publicReadable', reason: 'Attachment visibility is governed by parent space access' }, + writePolicy: { kind: 'ownerField', field: 'createdBy', identity: 'socialId' }, + allowKnownIdBypass: false + } as const + + builder.mixin(attachment.class.Attachment, core.class.Class, core.mixin.RowVisibility, attachmentVisibility) + builder.mixin(attachment.class.Photo, core.class.Class, view.mixin.CollectionEditor, { editor: attachment.component.Photos }) diff --git a/models/card/src/index.ts b/models/card/src/index.ts index 1b4f3ac45f..de39f184f4 100644 --- a/models/card/src/index.ts +++ b/models/card/src/index.ts @@ -1047,13 +1047,28 @@ export function createModel (builder: Builder): void { card.ids.GuestCardClassPermission ) + // core.class.Collaborator's own ownerField policy would require the named collaborator to be the + // caller itself, so this is enforced separately - see canEditDocCollaborator. + builder.createDoc( + core.class.ClassPermission, + core.space.Model, + { + label: card.string.AllowEditingCollaborators, + scope: 'space', + targetClass: core.class.Collaborator + }, + card.ids.GuestCollaboratorClassPermission + ) + builder.createDoc( core.class.ModulePermissionGroup, core.space.Model, { application: card.app.Card, role: AccountRole.Guest, - permissions: [card.ids.GuestCardClassPermission], + permissions: [card.ids.GuestCardClassPermission, card.ids.GuestCollaboratorClassPermission], + // Off by default - an admin opts in from Settings -> Guest permissions -> Cards. + disabledPermissions: [card.ids.GuestCollaboratorClassPermission], spaceClass: card.class.CardSpace, enabled: true, order: 20 @@ -1075,6 +1090,18 @@ export function createModel (builder: Builder): void { card.ids.ModulePermissionGroupReadOnlyGuest ) + // Guests may only update cards they created; read visibility stays ordinary space membership. + builder.mixin(card.class.Card, core.class.Class, core.mixin.TxAccessLevel, { + updateAccessLevel: AccountRole.Guest + }) + + builder.mixin(card.class.Card, core.class.Class, core.mixin.RowVisibility, { + policy: { kind: 'publicReadable', reason: 'Card read visibility is governed by ordinary space membership' }, + writePolicy: { kind: 'ownerField', field: 'createdBy', identity: 'socialId' }, + allowKnownIdBypass: false, + scopeActivityToOwner: true + }) + builder.mixin(card.class.Card, core.class.Class, view.mixin.ClassFilters, { filters: ['space'], ignoreKeys: ['parent'] diff --git a/models/chunter/src/index.ts b/models/chunter/src/index.ts index 25284586a0..6805453547 100644 --- a/models/chunter/src/index.ts +++ b/models/chunter/src/index.ts @@ -125,13 +125,27 @@ export function createModel (builder: Builder): void { }) builder.mixin(chunter.class.ChatMessage, core.class.Class, core.mixin.TxAccessLevel, { - createAccessLevel: AccountRole.Guest + createAccessLevel: AccountRole.Guest, + updateAccessLevel: AccountRole.Guest, + removeAccessLevel: AccountRole.Guest }) + const messageVisibility = { + policy: { kind: 'publicReadable', reason: 'Message visibility is governed by channel space access' }, + writePolicy: { kind: 'ownerField', field: 'createdBy', identity: 'socialId' }, + allowKnownIdBypass: false + } as const + + builder.mixin(chunter.class.ChatMessage, core.class.Class, core.mixin.RowVisibility, messageVisibility) + builder.mixin(chunter.class.ThreadMessage, core.class.Class, core.mixin.TxAccessLevel, { - createAccessLevel: AccountRole.Guest + createAccessLevel: AccountRole.Guest, + updateAccessLevel: AccountRole.Guest, + removeAccessLevel: AccountRole.Guest }) + builder.mixin(chunter.class.ThreadMessage, core.class.Class, core.mixin.RowVisibility, messageVisibility) + const spaceClasses = [chunter.class.Channel, chunter.class.DirectMessage] spaceClasses.forEach((spaceClass) => { diff --git a/models/contact/src/index.ts b/models/contact/src/index.ts index 8eee6fdbc8..7257a66812 100644 --- a/models/contact/src/index.ts +++ b/models/contact/src/index.ts @@ -1,6 +1,7 @@ // // Copyright © 2020, 2021 Anticrm Platform Contributors. // Copyright © 2023 Hardcore Engineering Inc. +// Copyright © 2026 TraceX SAS. // // 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 @@ -366,6 +367,11 @@ export function createModel (builder: Builder): void { isIdentity: true }) + builder.mixin(contact.class.SocialIdentity, core.class.Class, core.mixin.RowVisibility, { + policy: { kind: 'ownerField', field: 'attachedTo', identity: 'personId' }, + allowKnownIdBypass: true + }) + builder.mixin(contact.class.Contact, core.class.Class, activity.mixin.ActivityDoc, {}) builder.mixin(contact.class.Person, core.class.Class, activity.mixin.ActivityDoc, { diff --git a/models/core/src/component.ts b/models/core/src/component.ts index c08463c85c..6e081e9f76 100644 --- a/models/core/src/component.ts +++ b/models/core/src/component.ts @@ -13,7 +13,7 @@ // limitations under the License. // -import core, { coreId } from '@hcengineering/core' +import core, { coreId, type Doc, type Ref } from '@hcengineering/core' import type { IntlString } from '@hcengineering/platform' import { mergeIds } from '@hcengineering/platform' @@ -28,5 +28,8 @@ export default mergeIds(coreId, core, { BlobVersion: '' as IntlString, BlobStorageId: '' as IntlString, BlobSize: '' as IntlString + }, + ids: { + GuestActivitySettingsGuest: '' as Ref } }) diff --git a/models/core/src/index.ts b/models/core/src/index.ts index 0377df43ec..9bdb0b8940 100644 --- a/models/core/src/index.ts +++ b/models/core/src/index.ts @@ -14,6 +14,7 @@ // import { + AccountRole, DOMAIN_BENCHMARK, DOMAIN_BLOB, DOMAIN_CONFIGURATION, @@ -21,7 +22,8 @@ import { DOMAIN_SPACE, DOMAIN_STATUS, DOMAIN_TRANSIENT, - DOMAIN_TX + DOMAIN_TX, + GuestActivityScope } from '@hcengineering/core' import { type Builder } from '@hcengineering/model' import { TBenchmarkDoc } from './benchmark' @@ -81,6 +83,7 @@ import { import { definePermissions } from './permissions' import { TAttributePermission, + TGuestActivitySettings, TModulePermissionGroup, TClassPermission, TPermission, @@ -141,6 +144,7 @@ export function createModel (builder: Builder): void { TModulePermissionGroup, TAttributePermission, TClassPermission, + TGuestActivitySettings, TAttribute, TType, TEnumOf, @@ -192,6 +196,28 @@ export function createModel (builder: Builder): void { TTTransientTTL ) + builder.mixin(core.class.Collaborator, core.class.Class, core.mixin.RowVisibility, { + policy: { kind: 'ownerField', field: 'collaborator', identity: 'accountUuid' }, + allowKnownIdBypass: true, + knownIdBypassFields: ['attachedTo'] + }) + + // Layer 1 only; card.ids.GuestCollaboratorClassPermission is the actual gate (see canEditDocCollaborator). + builder.mixin(core.class.Collaborator, core.class.Class, core.mixin.TxAccessLevel, { + createAccessLevel: AccountRole.ReadOnlyGuest, + removeAccessLevel: AccountRole.ReadOnlyGuest + }) + + builder.createDoc( + core.class.GuestActivitySettings, + core.space.Model, + { + role: AccountRole.Guest, + activityScope: GuestActivityScope.Any + }, + core.ids.GuestActivitySettingsGuest + ) + builder.createDoc(core.class.DomainIndexConfiguration, core.space.Model, { domain: DOMAIN_TX, disabled: [ diff --git a/models/core/src/security.ts b/models/core/src/security.ts index 5be7c0d12c..ed825b0f72 100644 --- a/models/core/src/security.ts +++ b/models/core/src/security.ts @@ -16,6 +16,7 @@ import { DOMAIN_MODEL, DOMAIN_SPACE, + type GuestActivityScope, IndexKind, type ModulePermissionGroup, type AccountRole, @@ -26,10 +27,13 @@ import { type ClassPermission, type CollectionSize, type Doc, + type GuestActivitySettings, type Permission, type Ref, type Role, type RolesAssignment, + type RowVisibility, + type RowVisibilityPolicy, type Space, type SpaceType, type SpaceTypeDescriptor, @@ -200,6 +204,16 @@ export class TTxAccessLevel extends TClass implements TxAccessLevel { isIdentity?: boolean } +/** See `RowVisibility` in `@hcengineering/core`. */ +@Mixin(core.mixin.RowVisibility, core.class.Class) +export class TRowVisibility extends TClass implements RowVisibility { + policy!: RowVisibilityPolicy + writePolicy?: RowVisibilityPolicy + allowKnownIdBypass!: boolean + knownIdBypassFields?: string[] + scopeActivityToOwner?: boolean +} + @Model(core.class.ModulePermissionGroup, core.class.Doc, DOMAIN_MODEL) export class TModulePermissionGroup extends TDoc implements ModulePermissionGroup { @Prop(TypeRef(core.class.Doc), core.string.AttachedTo) @@ -223,3 +237,13 @@ export class TModulePermissionGroup extends TDoc implements ModulePermissionGrou @Prop(TypeNumber(), core.string.Order) order?: number } + +/** See `GuestActivitySettings` in `@hcengineering/core`. */ +@Model(core.class.GuestActivitySettings, core.class.Doc, DOMAIN_MODEL) +export class TGuestActivitySettings extends TDoc implements GuestActivitySettings { + @Prop(TypeString(), core.string.Roles) + role!: AccountRole + + @Prop(TypeString(), core.string.Name) + activityScope!: GuestActivityScope +} diff --git a/models/core/src/spaceType.ts b/models/core/src/spaceType.ts index f982484700..5d424a6c2a 100644 --- a/models/core/src/spaceType.ts +++ b/models/core/src/spaceType.ts @@ -17,7 +17,7 @@ import { ArrOf, Prop, TypeString, type Builder } from '@hcengineering/model' import { type Asset } from '@hcengineering/platform' import { getRoleAttributeLabel } from '@hcengineering/core' -import { TSpacesTypeData, TTxAccessLevel } from './security' +import { TRowVisibility, TSpacesTypeData, TTxAccessLevel } from './security' import core from './component' const roles = [ @@ -38,6 +38,7 @@ export function defineSpaceType (builder: Builder): void { builder.createModel(TSpacesTypeData) builder.createModel(TTxAccessLevel) + builder.createModel(TRowVisibility) builder.createDoc( core.class.SpaceTypeDescriptor, diff --git a/models/guest/src/index.ts b/models/guest/src/index.ts index 743b4d37cc..0e9af1d892 100644 --- a/models/guest/src/index.ts +++ b/models/guest/src/index.ts @@ -19,6 +19,13 @@ export class TPublicLink extends TDoc implements PublicLink { export function createModel (builder: Builder): void { builder.createModel(TPublicLink) + // `_id` doubles as the link's bearer secret (see exchangeGuestToken), so unlike other + // RowVisibility classes it must NOT allow a known-id bypass - only the caller's own linkId. + builder.mixin(guest.class.PublicLink, core.class.Class, core.mixin.RowVisibility, { + policy: { kind: 'ownerField', field: '_id', identity: 'linkId' }, + allowKnownIdBypass: false + }) + builder.createDoc(core.class.DomainIndexConfiguration, core.space.Model, { domain: GUEST_DOMAIN, disabled: [ diff --git a/models/hr/src/index.ts b/models/hr/src/index.ts index dd6841ec1f..0dc7216d20 100644 --- a/models/hr/src/index.ts +++ b/models/hr/src/index.ts @@ -188,6 +188,11 @@ export class TPublicHoliday extends TDoc implements PublicHoliday { export function createModel (builder: Builder): void { builder.createModel(TDepartment, TRequest, TRequestType, TPublicHoliday, TStaff, TTzDate) + builder.mixin(hr.class.Request, core.class.Class, core.mixin.RowVisibility, { + policy: { kind: 'ownerField', field: 'attachedTo', identity: 'personId' }, + allowKnownIdBypass: true + }) + builder.createDoc( workbench.class.Application, core.space.Model, diff --git a/models/love/src/index.ts b/models/love/src/index.ts index e3ae2d995f..5446be2f60 100644 --- a/models/love/src/index.ts +++ b/models/love/src/index.ts @@ -353,6 +353,75 @@ export function createModel (builder: Builder): void { TUserMeetingInvite ) + builder.mixin(love.class.MeetingMinutes, core.class.Class, core.mixin.RowVisibility, { + policy: { + kind: 'linkedViaRecord', + linkClass: core.class.Collaborator, + linkTargetField: 'attachedTo', + linkIdentityField: 'collaborator', + identity: 'accountUuid' + }, + allowKnownIdBypass: true + }) + + builder.mixin(love.class.Room, core.class.Class, core.mixin.RowVisibility, { + policy: { + kind: 'publicReadable', + reason: + 'Office rooms are visible to every guest so the office layout renders correctly; the meeting minutes documents attached to a room stay collaborator-restricted' + }, + allowKnownIdBypass: false + }) + + builder.mixin(love.class.Floor, core.class.Class, core.mixin.RowVisibility, { + policy: { kind: 'publicReadable', reason: 'Floor metadata is required to render accessible Office rooms' }, + allowKnownIdBypass: false + }) + + const roomActivityVisibility = { + policy: { + kind: 'linkedViaRecord', + linkClass: core.class.Collaborator, + linkTargetField: 'attachedTo', + linkIdentityField: 'collaborator', + identity: 'accountUuid', + targetField: 'room', + through: { + documentClass: love.class.MeetingMinutes, + sourceField: '_id', + targetField: 'attachedTo', + includeDirect: true + } + }, + allowKnownIdBypass: false + } as const + + builder.mixin(love.class.ParticipantInfo, core.class.Class, core.mixin.RowVisibility, roomActivityVisibility) + builder.mixin(love.class.RoomInfo, core.class.Class, core.mixin.RowVisibility, roomActivityVisibility) + + builder.mixin(love.class.PendingRecording, core.class.Class, core.mixin.RowVisibility, { + policy: { + kind: 'linkedViaRecord', + linkClass: core.class.Collaborator, + linkTargetField: 'attachedTo', + linkIdentityField: 'collaborator', + identity: 'accountUuid', + targetField: 'attachedTo' + }, + allowKnownIdBypass: false + }) + + builder.mixin(love.class.DevicesPreference, core.class.Class, core.mixin.TxAccessLevel, { + createAccessLevel: AccountRole.Guest, + updateAccessLevel: AccountRole.Guest, + removeAccessLevel: AccountRole.Guest + }) + + builder.mixin(love.class.DevicesPreference, core.class.Class, core.mixin.RowVisibility, { + policy: { kind: 'ownerField', field: 'createdBy', identity: 'socialId' }, + allowKnownIdBypass: false + }) + builder.createDoc( workbench.class.Application, core.space.Model, @@ -754,6 +823,12 @@ export function createModel (builder: Builder): void { provideSecurity: true }) + builder.createDoc>(core.class.ClassCollaborators, core.space.Model, { + attachedTo: love.class.Room, + fields: ['createdBy'], + provideSecurity: true + }) + builder.mixin(love.class.Room, core.class.Class, core.mixin.IndexConfiguration, { indexes: [], searchDisabled: true diff --git a/models/notification/src/index.ts b/models/notification/src/index.ts index 7bfd0131ef..a39168225d 100644 --- a/models/notification/src/index.ts +++ b/models/notification/src/index.ts @@ -400,6 +400,11 @@ export function createModel (builder: Builder): void { TOnDemandNotification ) + builder.mixin(notification.class.PushSubscription, core.class.Class, core.mixin.RowVisibility, { + policy: { kind: 'ownerField', field: 'user', identity: 'accountUuid' }, + allowKnownIdBypass: false + }) + builder.mixin(notification.class.BrowserNotification, core.class.Class, core.mixin.TransientConfiguration, { broadcastOnly: true }) diff --git a/models/process/src/index.ts b/models/process/src/index.ts index 63e7ddde86..f351614e7e 100644 --- a/models/process/src/index.ts +++ b/models/process/src/index.ts @@ -548,6 +548,42 @@ export function createModel (builder: Builder): void { actions: [view.action.Delete] }) + // Layer 1 only; GuestApproveRequestClassPermission below is the actual gate. + builder.mixin(process.class.ApproveRequest, core.class.Class, core.mixin.TxAccessLevel, { + updateAccessLevel: AccountRole.ReadOnlyGuest + }) + + builder.mixin(process.class.ApproveRequest, core.class.Class, core.mixin.RowVisibility, { + policy: { kind: 'ownerField', field: 'user', identity: 'personId' }, + allowKnownIdBypass: false + }) + + builder.createDoc( + core.class.ClassPermission, + core.space.Model, + { + label: process.string.AllowApproveRequestActions, + scope: 'space', + targetClass: process.class.ApproveRequest + }, + process.ids.GuestApproveRequestClassPermission + ) + + builder.createDoc( + core.class.ModulePermissionGroup, + core.space.Model, + { + application: process.app.Process, + role: AccountRole.Guest, + permissions: [process.ids.GuestApproveRequestClassPermission], + // Off by default - an admin opts in from Settings -> Guest permissions -> Process. + disabledPermissions: [process.ids.GuestApproveRequestClassPermission], + enabled: true, + order: 30 + }, + process.ids.ModulePermissionGroupGuest + ) + builder.createDoc( view.class.Viewlet, core.space.Model, diff --git a/models/process/src/plugin.ts b/models/process/src/plugin.ts index 728b3b1ef6..4f3ad610f4 100644 --- a/models/process/src/plugin.ts +++ b/models/process/src/plugin.ts @@ -33,7 +33,9 @@ export default mergeIds(processId, process, { ids: { ProcessSettings: '' as Ref, ProcessToDoCreated: '' as Ref, - ApproveRequestCreated: '' as Ref + ApproveRequestCreated: '' as Ref, + GuestApproveRequestClassPermission: '' as Ref, + ModulePermissionGroupGuest: '' as Ref }, actionImpl: { ContinueExecution: '' as ViewAction @@ -46,6 +48,7 @@ export default mergeIds(processId, process, { NewProcessToDo: '' as IntlString, ConfigLabel: '' as IntlString, ConfigDescription: '' as IntlString, - LogAction: '' as IntlString + LogAction: '' as IntlString, + AllowApproveRequestActions: '' as IntlString } }) diff --git a/models/pulse/src/index.ts b/models/pulse/src/index.ts index 687761a08a..d67bb003ab 100644 --- a/models/pulse/src/index.ts +++ b/models/pulse/src/index.ts @@ -1,5 +1,6 @@ // // Copyright © 2026 Intabia Fusion. +// Copyright © 2026 TraceX SAS. // // 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 @@ -14,7 +15,15 @@ // import type { Person } from '@hcengineering/contact' -import { DOMAIN_TRANSIENT, type Class, type Doc, type PersonId, type Ref, type Timestamp } from '@hcengineering/core' +import { + AccountRole, + DOMAIN_TRANSIENT, + type Class, + type Doc, + type PersonId, + type Ref, + type Timestamp +} from '@hcengineering/core' import { Model, type Builder } from '@hcengineering/model' import core, { TDoc } from '@hcengineering/model-core' import type { IntlString } from '@hcengineering/platform' @@ -45,7 +54,29 @@ export function createModel (builder: Builder): void { ttl: 10 }) + builder.mixin(pulse.class.DocumentPresence, core.class.Class, core.mixin.TxAccessLevel, { + createAccessLevel: AccountRole.ReadOnlyGuest, + updateAccessLevel: AccountRole.ReadOnlyGuest, + removeAccessLevel: AccountRole.ReadOnlyGuest + }) + + builder.mixin(pulse.class.DocumentPresence, core.class.Class, core.mixin.RowVisibility, { + policy: { kind: 'publicReadable', reason: 'Ephemeral presence state contains no business data and expires by TTL' }, + allowKnownIdBypass: false + }) + builder.mixin(pulse.class.TypingIndicator, core.class.Class, core.mixin.TransientTTL, { ttl: 3 }) + + builder.mixin(pulse.class.TypingIndicator, core.class.Class, core.mixin.TxAccessLevel, { + createAccessLevel: AccountRole.ReadOnlyGuest, + updateAccessLevel: AccountRole.ReadOnlyGuest, + removeAccessLevel: AccountRole.ReadOnlyGuest + }) + + builder.mixin(pulse.class.TypingIndicator, core.class.Class, core.mixin.RowVisibility, { + policy: { kind: 'publicReadable', reason: 'Ephemeral typing state contains no business data and expires by TTL' }, + allowKnownIdBypass: false + }) } diff --git a/plugins/card-assets/lang/en.json b/plugins/card-assets/lang/en.json index 42fd1ea4c3..34fa3c2000 100644 --- a/plugins/card-assets/lang/en.json +++ b/plugins/card-assets/lang/en.json @@ -6,6 +6,7 @@ "Content": "Content", "CreateCard": "Create Card", "AllowCreatingCards": "Allow creating cards", + "AllowEditingCollaborators": "Allow editing collaborators on own cards", "CreateMasterTag": "Create Type", "CreateTag": "Create Tag", "MasterTag": "Type", diff --git a/plugins/card-assets/lang/ru.json b/plugins/card-assets/lang/ru.json index 8ed237f7c7..2ed8a45f06 100644 --- a/plugins/card-assets/lang/ru.json +++ b/plugins/card-assets/lang/ru.json @@ -6,6 +6,7 @@ "Content": "Содержание", "CreateCard": "Создать карту", "AllowCreatingCards": "Разрешить создание карт", + "AllowEditingCollaborators": "Разрешить редактирование участников своих карт", "CreateMasterTag": "Создать тип", "CreateTag": "Создать тег", "MasterTag": "Тип", diff --git a/plugins/card/src/index.ts b/plugins/card/src/index.ts index 4b85a7aa82..df7c02ab01 100644 --- a/plugins/card/src/index.ts +++ b/plugins/card/src/index.ts @@ -222,6 +222,7 @@ const cardPlugin = plugin(cardId, { Favorites: '' as IntlString, CreateCard: '' as IntlString, AllowCreatingCards: '' as IntlString, + AllowEditingCollaborators: '' as IntlString, Version: '' as IntlString, Versions: '' as IntlString, Effective: '' as IntlString, @@ -246,6 +247,7 @@ const cardPlugin = plugin(cardId, { ids: { CardWidget: '' as Ref, GuestCardClassPermission: '' as Ref, + GuestCollaboratorClassPermission: '' as Ref, ModulePermissionGroup: '' as Ref, ModulePermissionGroupReadOnlyGuest: '' as Ref }, diff --git a/plugins/process-assets/lang/en.json b/plugins/process-assets/lang/en.json index f0635eff6a..902b4a8cc7 100644 --- a/plugins/process-assets/lang/en.json +++ b/plugins/process-assets/lang/en.json @@ -132,6 +132,7 @@ "ProcessStateChanged": "Process \"{process}\" changed state to \"{state}\"", "ProcessFinished": "Process \"{process}\" finished in \"{state}\"", "NewProcessToDo": "New process Action item", + "AllowApproveRequestActions": "Allow approve/reject actions", "EmptyArray": "Empty array", "ExecutionInitiator": "Execution initiator", "ExecutionStarted": "Execution started", diff --git a/plugins/process-assets/lang/ru.json b/plugins/process-assets/lang/ru.json index 26edd4defd..107a8cc51f 100644 --- a/plugins/process-assets/lang/ru.json +++ b/plugins/process-assets/lang/ru.json @@ -132,6 +132,7 @@ "ProcessStateChanged": "Состояние процесса \"{process}\" изменено на \"{state}\"", "ProcessFinished": "Процесс \"{process}\" завершен в состояние \"{state}\"", "NewProcessToDo": "Новый Action Item процесса", + "AllowApproveRequestActions": "Разрешить действия approve/reject", "EmptyArray": "Пустой массив", "ExecutionInitiator": "Инициатор выполнения", "ExecutionStarted": "Выполнение начато", diff --git a/plugins/setting-resources/src/accessPermissions.ts b/plugins/setting-resources/src/accessPermissions.ts index 7683a8c882..87f753508c 100644 --- a/plugins/setting-resources/src/accessPermissions.ts +++ b/plugins/setting-resources/src/accessPermissions.ts @@ -16,7 +16,7 @@ import { AccountRole, hasAccountRole, - isGuestRole, + isRowLevelRestricted, readOnlyGuestAccountUuid, type Account, type AccountUuid, @@ -91,7 +91,7 @@ export function getMemberSpaceAvailability ( ): MemberSpaceAvailability | undefined { if (person === undefined || role === undefined) return undefined if (space.members.includes(person)) return 'member' - if (space.private !== true && !isGuestRole(role)) return 'joinable' + if (space.private !== true && !isRowLevelRestricted(role)) return 'joinable' return undefined } diff --git a/plugins/setting-resources/src/components/EditAttribute.svelte b/plugins/setting-resources/src/components/EditAttribute.svelte index 8669b19692..e7f3a695ce 100644 --- a/plugins/setting-resources/src/components/EditAttribute.svelte +++ b/plugins/setting-resources/src/components/EditAttribute.svelte @@ -134,7 +134,7 @@ const items = getTypes() let selectedType: Ref>> = attribute.type._class - $: selectedType && selectType(selectedType) + $: selectType(selectedType) function selectType (type: Ref>>): void { const _class = hierarchy.getClass(type) @@ -143,10 +143,10 @@ is = editor.editor } } - const handleSelect = (e: any) => { + const handleSelect = (e: any): void => { selectType(e.detail) } - const handleChange = (e: any) => { + const handleChange = (e: any): void => { if (e.detail.type !== undefined && e.detail.type !== type && !disabled) { type = e.detail?.type index = e.detail?.index @@ -169,7 +169,7 @@ } async function hide (): Promise { - const value = !attribute.hidden + const value = attribute.hidden !== true attribute.hidden = value await client.update(attribute, { hidden: value }) } @@ -259,8 +259,8 @@ {#if !disabled} candidate.personUuid === personUuid) if (employee !== undefined) { - await client.update(employee, { role: isGuestRole(value) ? 'GUEST' : 'USER' }) + await client.update(employee, { role: isRowLevelRestricted(value) ? 'GUEST' : 'USER' }) } } catch (error) { handleOperationError(error) diff --git a/plugins/tracker-resources/src/utils.ts b/plugins/tracker-resources/src/utils.ts index c0afa1ed7b..166a65a9a5 100644 --- a/plugins/tracker-resources/src/utils.ts +++ b/plugins/tracker-resources/src/utils.ts @@ -16,7 +16,6 @@ import { Analytics } from '@hcengineering/analytics' import { type Person } from '@hcengineering/contact' import core, { - AccountRole, SortingOrder, toIdMap, type ApplyOperations, @@ -36,6 +35,7 @@ import core, { type TxResult, type TxUpdateDoc, getCurrentAccount, + isRowLevelRestricted, type WithLookup } from '@hcengineering/core' import { type IntlString } from '@hcengineering/platform' @@ -282,10 +282,7 @@ export async function canEditIssue (issue?: Issue | WithLookup): Promise< if (issue === undefined) return false const account = getCurrentAccount() - const isGuest = - account.role === AccountRole.Guest || - account.role === AccountRole.DocGuest || - account.role === AccountRole.ReadOnlyGuest + const isGuest = isRowLevelRestricted(account.role) if (!isGuest) return true diff --git a/plugins/view-resources/src/__tests__/permissions.test.ts b/plugins/view-resources/src/__tests__/permissions.test.ts index a5799a8edf..dabcbbfc09 100644 --- a/plugins/view-resources/src/__tests__/permissions.test.ts +++ b/plugins/view-resources/src/__tests__/permissions.test.ts @@ -123,12 +123,15 @@ jest.doMock('@hcengineering/platform', () => ({ getResource: jest.fn(async () => mockPermissionsData) })) +const mockClassHierarchyMixin = jest.fn(() => undefined as { policy: { kind: string } } | undefined) + jest.doMock('@hcengineering/presentation', () => ({ getClient: jest.fn(() => ({ getHierarchy: jest.fn(() => ({ getAncestors: jest.fn(() => []), isDerived: jest.fn((objectClass: Ref>, baseClass: Ref>) => objectClass === baseClass), - isMixin: jest.fn(() => false) + isMixin: jest.fn(() => false), + classHierarchyMixin: mockClassHierarchyMixin })), getModel: jest.fn(() => ({ findAllSync: jest.fn(() => []) @@ -196,6 +199,7 @@ describe('permissions', () => { disableNavigation: false, disableActions: false }) + mockClassHierarchyMixin.mockReturnValue(undefined) await Promise.resolve() }) @@ -385,6 +389,44 @@ describe('permissions', () => { expect(current.canComment(own)).toBe(false) }) + test('canRead/isOwnRecordsOnly are permissive for User regardless of RowVisibility', () => { + mockClassHierarchyMixin.mockReturnValue({ policy: { kind: 'denyAll' } }) + setCurrentAccount(createAccount(AccountRole.User)) + + const current = getPermissions() + + expect(current.canRead('test:class:Doc' as Ref>)).toBe(true) + expect(current.isOwnRecordsOnly('test:class:Doc' as Ref>)).toBe(false) + }) + + test('canRead/isOwnRecordsOnly default to permissive/shared for a restricted role without a declared policy', () => { + setCurrentAccount(createAccount(AccountRole.Guest)) + + const current = getPermissions() + + expect(current.canRead('test:class:Doc' as Ref>)).toBe(true) + expect(current.isOwnRecordsOnly('test:class:Doc' as Ref>)).toBe(false) + }) + + test('canRead is false for a restricted role on a denyAll class', () => { + mockClassHierarchyMixin.mockReturnValue({ policy: { kind: 'denyAll' } }) + setCurrentAccount(createAccount(AccountRole.Guest)) + + const current = getPermissions() + + expect(current.canRead('test:class:Sensitive' as Ref>)).toBe(false) + }) + + test('isOwnRecordsOnly is true for a restricted role on an ownerField/linkedViaRecord class', () => { + setCurrentAccount(createAccount(AccountRole.Guest)) + + mockClassHierarchyMixin.mockReturnValue({ policy: { kind: 'ownerField' } }) + expect(getPermissions().isOwnRecordsOnly('test:class:Request' as Ref>)).toBe(true) + + mockClassHierarchyMixin.mockReturnValue({ policy: { kind: 'linkedViaRecord' } }) + expect(getPermissions().isOwnRecordsOnly('test:class:MeetingMinutes' as Ref>)).toBe(true) + }) + test('denies commenting when the guest link disables comments', () => { const doc = { _id: 'doc' as Ref, diff --git a/plugins/view-resources/src/middleware.ts b/plugins/view-resources/src/middleware.ts index a3e6dcf4d4..fa5e629053 100644 --- a/plugins/view-resources/src/middleware.ts +++ b/plugins/view-resources/src/middleware.ts @@ -1,5 +1,6 @@ import { Analytics } from '@hcengineering/analytics' import core, { + type Account, AccountRole, type AnyAttribute, type Attribute, @@ -11,6 +12,7 @@ import core, { type FindResult, generateId, getCurrentAccount, + hasAccountRole, type Hierarchy, type Ref, type RefTo, @@ -352,7 +354,8 @@ export class ReadOnlyAccessMiddleware extends BasePresentationMiddleware impleme } async tx (tx: Tx): Promise { - if (getCurrentAccount()?.role === AccountRole.ReadOnlyGuest) { + const account = getCurrentAccount() + if (account?.role === AccountRole.ReadOnlyGuest && !this.isTxAllowedByAccessLevel(tx, account)) { addNotification( await translate(view.string.ReadOnlyWarningTitle, {}, getCurrentLanguage()), await translate(view.string.ReadOnlyWarningMessage, {}, getCurrentLanguage()), @@ -382,4 +385,25 @@ export class ReadOnlyAccessMiddleware extends BasePresentationMiddleware impleme } } } + + private isTxAllowedByAccessLevel (tx: Tx, account: Account): boolean { + if (tx._class === core.class.TxApplyIf) { + const nested = (tx as TxApplyIf).txes + return nested.length > 0 && nested.every((item) => this.isTxAllowedByAccessLevel(item, account)) + } + if (!TxProcessor.isExtendsCUD(tx._class)) return false + + const mixin = this.client + .getHierarchy() + .classHierarchyMixin((tx as TxCUD).objectClass, core.mixin.TxAccessLevel) + if (mixin === undefined) return false + + const requiredRole = + tx._class === core.class.TxCreateDoc + ? mixin.createAccessLevel + : tx._class === core.class.TxRemoveDoc + ? mixin.removeAccessLevel + : mixin.updateAccessLevel + return requiredRole !== undefined && hasAccountRole(account, requiredRole) + } } diff --git a/plugins/view-resources/src/permissions.ts b/plugins/view-resources/src/permissions.ts index bf2fa254f6..534a782358 100644 --- a/plugins/view-resources/src/permissions.ts +++ b/plugins/view-resources/src/permissions.ts @@ -2,8 +2,8 @@ import contact, { type PermissionsStore } from '@hcengineering/contact' import core, { AccountRole, hasAccountRole, - isGuestRole, isReadOnlyRole, + isRowLevelRestricted, onCurrentAccountChanged, type Account, type AnyAttribute, @@ -152,6 +152,10 @@ export interface Permissions { // Workspace level canManageWorkspace: boolean + // Class level (row visibility, Layer 2 - mirrors core.mixin.RowVisibility on the server) + canRead: (_class: Ref>) => boolean + isOwnRecordsOnly: (_class: Ref>) => boolean + // Space level canEditSpace: (space: Space | undefined) => boolean canArchiveSpace: (space: Space | undefined) => boolean @@ -179,6 +183,8 @@ export interface Permissions { */ const forbidAll: Permissions = { canManageWorkspace: false, + canRead: () => false, + isOwnRecordsOnly: () => true, canEditSpace: () => false, canArchiveSpace: () => false, canAddMembers: () => false, @@ -226,6 +232,31 @@ export function ownsDoc (doc: Doc | undefined, account: Account): boolean { return isDocCreatedByAccount(doc, account) } +/** + * Whether the server would narrow reads of `_class` to the account's own records (`core.mixin. + * RowVisibility`, Layer 2 - same mixin, same lookup as the server's `RowVisibilityResolver`, no + * separate copy of the policy). `false` for `User`+ and for classes without the mixin, since those + * are ordinarily space-filtered or unrestricted. + * @public + */ +export function isOwnRecordsOnly (_class: Ref>, account: Account): boolean { + if (!isRowLevelRestricted(account.role)) return false + const mixin = getClient().getHierarchy().classHierarchyMixin(_class, core.mixin.RowVisibility) + return mixin?.policy.kind === 'ownerField' || mixin?.policy.kind === 'linkedViaRecord' +} + +/** + * Whether `_class` could return anything at all for the account, per `core.mixin.RowVisibility`. + * Best-effort: `true` doesn't guarantee any given document is visible, only that the class isn't + * an outright `denyAll` for a restricted role. + * @public + */ +export function canReadClass (_class: Ref>, account: Account): boolean { + if (!isRowLevelRestricted(account.role)) return true + const mixin = getClient().getHierarchy().classHierarchyMixin(_class, core.mixin.RowVisibility) + return mixin?.policy.kind !== 'denyAll' +} + function hasSpacePermission (permission: Ref, space: Ref, store: PermissionsStore): boolean { const arePermissionsDisabled = getMetadata(core.metadata.DisablePermissions) ?? false if (arePermissionsDisabled) return true @@ -237,7 +268,7 @@ function buildPermissions ( store: PermissionsStore | undefined, restrictions: Restrictions ): Permissions { - const isGuest = isGuestRole(account.role) + const isGuest = isRowLevelRestricted(account.role) const isReadOnly = isReadOnlyRole(account.role) || restrictions.readonly const isUser = hasAccountRole(account, AccountRole.User) @@ -295,6 +326,9 @@ function buildPermissions ( return { canManageWorkspace: hasAccountRole(account, AccountRole.Maintainer), + canRead: (_class) => canReadClass(_class, account), + isOwnRecordsOnly: (_class) => isOwnRecordsOnly(_class, account), + canEditSpace, canArchiveSpace, canAddMembers, diff --git a/plugins/workbench-resources/src/connect.ts b/plugins/workbench-resources/src/connect.ts index ab3956b729..03160cf92d 100644 --- a/plugins/workbench-resources/src/connect.ts +++ b/plugins/workbench-resources/src/connect.ts @@ -9,6 +9,7 @@ import core, { ClientConnectEvent, concatLink, type Person as GlobalPerson, + isRowLevelRestricted, isWorkspaceCreating, type MeasureMetricsContext, metricsToString, @@ -514,10 +515,7 @@ export async function connect (title: string): Promise { branding: workspace.branding ?? 'unknown' } - const guestRole = - workspaceLoginInfo.role === AccountRole.ReadOnlyGuest || - workspaceLoginInfo.role === AccountRole.DocGuest || - workspaceLoginInfo.role === AccountRole.Guest + const guestRole = isRowLevelRestricted(workspaceLoginInfo.role) if (guestRole) { data.visited_workspace = workspace.url data.visited_workspace_uuid = workspace.uuid diff --git a/server-plugins/contact-resources/src/__tests__/onEmployeeCreate.test.ts b/server-plugins/contact-resources/src/__tests__/onEmployeeCreate.test.ts new file mode 100644 index 0000000000..405346b163 --- /dev/null +++ b/server-plugins/contact-resources/src/__tests__/onEmployeeCreate.test.ts @@ -0,0 +1,242 @@ +// +// Copyright © 2026 TraceX SAS. +// +// 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. +// + +/** + * Regression tests for `OnEmployeeCreate`'s guest-provisioning branch. + * + * The branch used to blanket-copy `readOnlyGuestAccountUuid`'s space membership (the "Spaces + * visible to anonymous" setting, Settings → Guest permissions → Anonymous tab) onto every newly + * created named Guest account. That setting is documented as governing only the shared read-only + * anonymous session, not named guest invites - named guests are meant to be scoped by + * `Space.autoJoinForRoles` (the "Guest" tab's "Auto-join spaces" control) and by explicit grants + * (`PermissionsGrant`). Copying it let a guest inherit access to any space ever marked + * anonymous-visible, which is how e.g. a "Products" card space leaked into a guest's @-mention + * results even with guest auto-join turned off for that space. + */ + +import chunter from '@hcengineering/chunter' +import contact, { type Employee, type Person } from '@hcengineering/contact' +import core, { + AccountRole, + generateId, + readOnlyGuestAccountUuid, + TxFactory, + type AccountUuid, + type Class, + type Doc, + type DocumentQuery, + type PermissionsGrant, + type Ref, + type Space, + type Tx, + type TxMixin, + type TxUpdateDoc +} from '@hcengineering/core' +import { type TriggerControl } from '@hcengineering/server-core' + +import { OnEmployeeCreate } from '../index' + +interface MockSpace { + _id: Ref + _class: Ref> + members: AccountUuid[] + autoJoin?: boolean + autoJoinForRoles?: AccountRole[] + private?: boolean +} + +function createControl ( + spaces: MockSpace[], + person: Person & { role: string }, + grant?: PermissionsGrant +): TriggerControl { + const applied: Tx[] = [] + + const findAll = jest.fn( + async (_ctx: any, _class: Ref>, query: DocumentQuery, _options?: any): Promise => { + if (_class === contact.class.Person) { + return [person].filter((p) => (query as any)._id === undefined || p._id === (query as any)._id) + } + if (_class === contact.class.PersonSpace) { + return [] + } + if (_class === core.class.Space) { + const idIn = (query as any)._id?.$in as Array> | undefined + if (idIn !== undefined) { + return spaces.filter((s) => idIn.includes(s._id)) + } + const autoJoinForRoles = (query as any).autoJoinForRoles as AccountRole | undefined + if (autoJoinForRoles !== undefined) { + return spaces.filter((s) => s.autoJoinForRoles?.includes(autoJoinForRoles) === true) + } + const autoJoin = (query as any).autoJoin as boolean | undefined + if (autoJoin !== undefined) { + return spaces.filter((s) => s.autoJoin === autoJoin) + } + return [] + } + if (_class === core.class.TypedSpace || _class === (core.class.Space as any)) { + return [] + } + return [] + } + ) + + return { + ctx: { + contextData: { account: { uuid: generateId() as unknown as AccountUuid, role: AccountRole.User }, grant }, + warn: jest.fn() + } as any, + findAll, + apply: jest.fn(async (_ctx: any, txes: Tx[]) => { + applied.push(...txes) + return {} + }), + txFactory: new TxFactory(core.account.System), + hierarchy: { + as: (doc: any) => doc, + isDerived: (a: Ref>, b: Ref>) => a === b + }, + __applied: applied + } as unknown as TriggerControl & { __applied: Tx[] } +} + +function employeeCreateTx (personId: Ref): Tx { + const tx: Partial> = { + _id: generateId(), + _class: core.class.TxMixin, + space: core.space.Tx, + objectId: personId, + objectClass: contact.class.Person, + objectSpace: contact.space.Contacts, + mixin: contact.mixin.Employee, + attributes: { active: true }, + modifiedBy: core.account.System, + modifiedOn: Date.now() + } + return tx as Tx +} + +function memberAdds (control: TriggerControl, account: AccountUuid): Array> { + const applied = (control as any).__applied as Tx[] + return applied.filter( + (t) => t._class === core.class.TxUpdateDoc && (t as TxUpdateDoc).operations.$push?.members === account + ) as Array> +} + +describe('OnEmployeeCreate – guest space provisioning', () => { + const personId = generateId() + const account = generateId() as unknown as AccountUuid + + function guestPerson (): Person & { role: string } { + return { _id: personId, personUuid: account, role: 'GUEST' } as unknown as Person & { role: string } + } + + it('does not grant a space that is only visible to the anonymous read-only account', async () => { + const anonymousOnlySpace: MockSpace = { + _id: generateId(), + _class: core.class.Space, + members: [readOnlyGuestAccountUuid] + } + const control = createControl([anonymousOnlySpace], guestPerson()) + + await OnEmployeeCreate([employeeCreateTx(personId)], control) + + expect(memberAdds(control, account)).toHaveLength(0) + }) + + it('grants a space with autoJoinForRoles including Guest', async () => { + const guestAutoJoinSpace: MockSpace = { + _id: generateId(), + _class: core.class.Space, + members: [], + autoJoinForRoles: [AccountRole.Guest] + } + const control = createControl([guestAutoJoinSpace], guestPerson()) + + await OnEmployeeCreate([employeeCreateTx(personId)], control) + + const adds = memberAdds(control, account) + expect(adds).toHaveLength(1) + expect(adds[0].objectId).toBe(guestAutoJoinSpace._id) + }) + + it('does not grant a DirectMessage space even if it has autoJoinForRoles Guest', async () => { + const dmSpace: MockSpace = { + _id: generateId(), + _class: chunter.class.DirectMessage, + members: [], + autoJoinForRoles: [AccountRole.Guest] + } + const control = createControl([dmSpace], guestPerson()) + + await OnEmployeeCreate([employeeCreateTx(personId)], control) + + expect(memberAdds(control, account)).toHaveLength(0) + }) + + it('does not re-add a space the guest is already a member of', async () => { + const guestAutoJoinSpace: MockSpace = { + _id: generateId(), + _class: core.class.Space, + members: [account], + autoJoinForRoles: [AccountRole.Guest] + } + const control = createControl([guestAutoJoinSpace], guestPerson()) + + await OnEmployeeCreate([employeeCreateTx(personId)], control) + + expect(memberAdds(control, account)).toHaveLength(0) + }) + + it('grants a space explicitly listed in the invite grant', async () => { + const grantedSpace: MockSpace = { + _id: generateId(), + _class: core.class.Space, + members: [], + private: false + } + const control = createControl([grantedSpace], guestPerson(), { spaces: [grantedSpace._id] }) + + await OnEmployeeCreate([employeeCreateTx(personId)], control) + + const adds = memberAdds(control, account) + expect(adds).toHaveLength(1) + expect(adds[0].objectId).toBe(grantedSpace._id) + }) + + it('combines grant spaces and auto-join spaces without duplicating either', async () => { + const grantedSpace: MockSpace = { _id: generateId(), _class: core.class.Space, members: [], private: false } + const guestAutoJoinSpace: MockSpace = { + _id: generateId(), + _class: core.class.Space, + members: [], + autoJoinForRoles: [AccountRole.Guest] + } + const anonymousOnlySpace: MockSpace = { + _id: generateId(), + _class: core.class.Space, + members: [readOnlyGuestAccountUuid] + } + const control = createControl([grantedSpace, guestAutoJoinSpace, anonymousOnlySpace], guestPerson(), { + spaces: [grantedSpace._id] + }) + + await OnEmployeeCreate([employeeCreateTx(personId)], control) + + const adds = memberAdds(control, account) + expect(adds.map((a) => a.objectId).sort()).toEqual([grantedSpace._id, guestAutoJoinSpace._id].sort()) + }) +}) diff --git a/server-plugins/contact-resources/src/index.ts b/server-plugins/contact-resources/src/index.ts index 4a6662393c..51a403f37c 100644 --- a/server-plugins/contact-resources/src/index.ts +++ b/server-plugins/contact-resources/src/index.ts @@ -44,7 +44,6 @@ import core, { type Space, SpaceType, systemAccountUuid, - readOnlyGuestAccountUuid, Tx, TxCreateDoc, TxCUD, @@ -174,22 +173,18 @@ export async function OnEmployeeCreate (_txes: Tx[], control: TriggerControl): P const emp = control.hierarchy.as(person, contact.mixin.Employee) if (emp.role === 'GUEST') { - let readOnlyGuestSpaces: Space[] = [] - const readonlyEmployees = await control.findAll(control.ctx, contact.mixin.Employee, { - personUuid: readOnlyGuestAccountUuid - }) - if (readonlyEmployees.length !== 0) { - const readonlyEmployee = readonlyEmployees[0] - if (readonlyEmployee.active) { - readOnlyGuestSpaces = await control.findAll(control.ctx, core.class.Space, { - members: readOnlyGuestAccountUuid - }) - } - } - + // Note: this deliberately does NOT copy `readOnlyGuestAccountUuid`'s space membership or + // Collaborator grants onto the new named Guest account. "Spaces visible to anonymous" + // (Settings → Guest permissions → Anonymous tab, `AnonymousGuestSpaceInput.svelte`) is + // documented and presented to admins as governing the shared read-only/anonymous session + // only ("visitors without an account") - a separate, independent audience from named Guest + // invites, which are governed by the "Auto-join spaces" control on the Guest tab + // (`AvailableSpacesInput.svelte`, `Space.autoJoinForRoles`) below. Blanket-copying the + // anonymous list here previously let a named guest inherit access to any space ever marked + // anonymous-visible, regardless of `autoJoin`/`autoJoinForRoles` being off for it. const grantSpaces = await getGrantSpaces(control, control.ctx.contextData.grant) - for (const space of [...readOnlyGuestSpaces, ...grantSpaces]) { + for (const space of grantSpaces) { if (space._class === contact.class.PersonSpace || space.members.includes(account)) continue systemTxes.push( @@ -217,20 +212,6 @@ export async function OnEmployeeCreate (_txes: Tx[], control: TriggerControl): P ) } - const collabs = await control.findAll(control.ctx, core.class.Collaborator, { - collaborator: readOnlyGuestAccountUuid - }) - - for (const collab of collabs) { - const pushTx = systemTxFactory.createTxCreateDoc(core.class.Collaborator, collab.space, { - attachedTo: collab.attachedTo, - collaborator: account, - attachedToClass: collab.attachedToClass, - collection: 'collaborators' - }) - systemTxes.push(pushTx) - } - continue } diff --git a/ws-tests/api-tests/src/__tests__/guestVisibility.test.ts b/ws-tests/api-tests/src/__tests__/guestVisibility.test.ts new file mode 100644 index 0000000000..9a1973f2ec --- /dev/null +++ b/ws-tests/api-tests/src/__tests__/guestVisibility.test.ts @@ -0,0 +1,370 @@ +// +// Copyright © 2026 TraceX SAS. +// +// 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. +// + +/** + * Real REST/WS integration tests for guest visibility restrictions, run against a live + * transactor + account service (see ws-tests/docker-compose.yaml), following the conventions of + * rest.test.ts in this package. + * + * `provisionAccount` reproduces the real account -> workspace-member -> local-employee pipeline, + * since two earlier, cheaper attempts turned out to skip real steps of it: + * + * - `ensurePerson` (server/account/src/operations.ts) only inserts a `db.person` + `db.socialId` + * row - no `db.account` row at all - so `assignWorkspace`/`updateWorkspaceRoleBySocialKey` + * (which both resolve the target through `db.account`) fail with `AccountNotFound`/`Forbidden`. + * - `signUp` + `assignWorkspace` alone creates a real account with a workspace role, but never + * touches the workspace's own document DB, so no `contact.class.Person`/`contact.mixin.Employee` + * gets created there and `OnEmployeeCreate` never fires - `ensureEmployee` (the same helper + * rest.test.ts uses for its own two seed accounts) is what actually creates that local record, + * deriving the Employee's `role` ('GUEST' vs 'USER') from the account's real `AccountRole`. + */ + +import { + createRestClient, + getWorkspaceToken, + loadServerConfig, + type RestClient, + type ServerConfig, + type WorkspaceToken +} from '@hcengineering/api-client' +import core, { + AccountRole, + generateId, + MeasureMetricsContext, + pickPrimarySocialId, + readOnlyGuestAccountUuid, + systemAccountUuid, + type Account, + type AccountUuid, + type Class, + type ModulePermissionGroup, + type Ref, + type Space, + type TxCreateDoc, + type TxRemoveDoc +} from '@hcengineering/core' +import { type AccountClient, getClient as getAccountClient } from '@hcengineering/account-client' +import chunter from '@hcengineering/chunter' +import contact, { ensureEmployee, type Person } from '@hcengineering/contact' +import { generateToken } from '@hcengineering/server-token' + +describe('guest-visibility (ws api)', () => { + const testCtx = new MeasureMetricsContext('test', {}) + const wsName = 'api-tests' + const serverUrl = 'http://tracex.local:8083' + + let serverConfig: ServerConfig + let apiWorkspace1: WorkspaceToken + let adminAccountClient: AccountClient + + beforeAll(async () => { + serverConfig = await loadServerConfig(serverUrl) + apiWorkspace1 = await getWorkspaceToken( + serverUrl, + { email: 'user1', password: '1234', workspace: wsName }, + serverConfig + ) + adminAccountClient = getAccountClient( + serverConfig.ACCOUNTS_URL, + generateToken(systemAccountUuid, apiWorkspace1.workspaceId, { service: 'workspace', admin: 'true' }, 'secret') + ) + }, 10000) + + function connect (): RestClient { + return createRestClient(apiWorkspace1.endpoint, apiWorkspace1.workspaceId, apiWorkspace1.token) + } + + async function provisionAccount ( + label: string, + role: AccountRole + ): Promise<{ uuid: AccountUuid, personId: Ref, conn: RestClient }> { + const email = `${label}-${generateId()}@guest-visibility.test` + const password = 'guest-visibility-1234' + + await adminAccountClient.signUp(email, password, label, 'Test') + await adminAccountClient.assignWorkspace(email, apiWorkspace1.workspaceId, role) + + const login = await getWorkspaceToken(serverUrl, { email, password, workspace: wsName }, serverConfig) + const conn = createRestClient(login.endpoint, login.workspaceId, login.token) + const loggedInAccountClient = getAccountClient(serverConfig.ACCOUNTS_URL, login.token) + + const person = await loggedInAccountClient.getPerson() + const socialIds = await loggedInAccountClient.getSocialIds(true) + + const account: Account = { + uuid: login.info.account, + role: login.info.role, + primarySocialId: pickPrimarySocialId(socialIds)._id, + socialIds: socialIds.map((si) => si._id), + fullSocialIds: socialIds + } + + const personId = await ensureEmployee(testCtx, account, conn, socialIds, async () => person) + if (personId === null) { + throw new Error(`Failed to provision local person for ${email}`) + } + + return { uuid: login.info.account, personId, conn } + } + + async function createGuestAccount ( + label: string + ): Promise<{ uuid: AccountUuid, personId: Ref, conn: RestClient }> { + return await provisionAccount(label, AccountRole.Guest) + } + + async function createSpace ( + members: AccountUuid[], + extra: Partial = {}, + objectClass: Ref> = core.class.Space + ): Promise> { + const owner = connect() + const ownerAccount = await owner.getAccount() + const objectId: Ref = generateId() + const tx: TxCreateDoc = { + _class: core.class.TxCreateDoc, + space: core.space.Tx, + _id: generateId(), + objectSpace: core.space.Model, + modifiedBy: ownerAccount.primarySocialId, + modifiedOn: Date.now(), + attributes: { + name: `guest-visibility-${generateId()}`, + description: '', + private: false, + archived: false, + members, + autoJoin: false, + ...extra + }, + objectClass, + objectId + } + await owner.tx(tx) + return objectId + } + + async function waitFor ( + check: () => Promise, + timeoutMs = 5000, + stepMs = 250 + ): Promise { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + const result = await check() + if (result !== undefined) return result + await new Promise((resolve) => setTimeout(resolve, stepMs)) + } + return undefined + } + + describe('Person visibility', () => { + let guest: { uuid: AccountUuid, personId: Ref, conn: RestClient } + let visiblePersonId: Ref + let hiddenPersonId: Ref + + beforeAll(async () => { + guest = await createGuestAccount('guest') + const visible = await provisionAccount('visible', AccountRole.User) + const hidden = await provisionAccount('hidden', AccountRole.User) + visiblePersonId = visible.personId + hiddenPersonId = hidden.personId + + // Guest shares a space with `visible`, but never with `hidden`. + await createSpace([guest.uuid, visible.uuid]) + await createSpace([hidden.uuid]) + + // Fulltext indexing is asynchronous (a separate indexer consumes the tx queue), unlike + // `findAll`, which reads the primary DB directly. Without waiting for the index to catch up, + // the "does not surface `hidden`" assertion below would trivially pass for the wrong reason + // (nothing indexed yet at all) while the "surfaces `visible`" assertion would be flaky. + await waitFor( + async () => { + const result = await guest.conn.searchFulltext( + { query: 'visible', classes: [contact.class.Person] }, + { limit: 10 } + ) + return result.docs.some((d) => d.id === visiblePersonId) ? true : undefined + }, + 20000, + 500 + ) + }, 40000) + + it('open findAll only returns people from spaces the guest shares (plus self)', async () => { + const persons = await guest.conn.findAll(contact.class.Person, {}) + const ids = persons.map((p) => p._id) + expect(ids).toContain(visiblePersonId) + expect(ids).not.toContain(hiddenPersonId) + }) + + it('a narrow _id query still resolves a person outside shared spaces (bypass for known refs)', async () => { + const found = await guest.conn.findOne(contact.class.Person, { _id: hiddenPersonId }) + expect(found?._id).toBe(hiddenPersonId) + }) + + it('mention/search picker does not surface people outside shared spaces', async () => { + const result = await guest.conn.searchFulltext( + { query: 'hidden', classes: [contact.class.Person] }, + { limit: 10 } + ) + expect(result.docs.map((d) => d.id)).not.toContain(hiddenPersonId) + }) + + it('mention/search picker surfaces people from shared spaces', async () => { + const result = await guest.conn.searchFulltext( + { query: 'visible', classes: [contact.class.Person] }, + { limit: 10 } + ) + expect(result.docs.map((d) => d.id)).toContain(visiblePersonId) + }) + }) + + describe('OnEmployeeCreate guest space provisioning', () => { + it('does not auto-join a space that is only visible to the anonymous read-only account', async () => { + const anonymousOnlySpace = await createSpace([readOnlyGuestAccountUuid]) + const guest = await createGuestAccount('anon-check') + + const owner = connect() + const space = await waitFor(async () => await owner.findOne(core.class.Space, { _id: anonymousOnlySpace })) + expect(space?.members).not.toContain(guest.uuid) + }, 15000) + + it('auto-joins a space with autoJoinForRoles including Guest', async () => { + const extra: Partial = { autoJoinForRoles: [AccountRole.Guest] } + const guestAutoJoinSpace = await createSpace([], extra) + const guest = await createGuestAccount('autojoin-check') + + const owner = connect() + const joined = await waitFor(async () => { + const found = await owner.findOne(core.class.Space, { _id: guestAutoJoinSpace }) + return found?.members.includes(guest.uuid) === true ? found : undefined + }) + expect(joined?.members).toContain(guest.uuid) + }, 15000) + }) + + describe('Disabled-module exclusion (findAll + search)', () => { + // Uses chunter.class.Channel as the "disabled module" space class: it's a plain Space + // subclass (no extra required attributes beyond Space's own), so it's just as easy to create + // as the generic test spaces above, but - unlike core.class.Space itself - disabling it only + // affects Channel spaces, not every space created elsewhere in this file. + let groupId: Ref + + beforeAll(async () => { + const owner = connect() + const ownerAccount = await owner.getAccount() + groupId = generateId() + const createGroupTx: TxCreateDoc = { + _class: core.class.TxCreateDoc, + space: core.space.Tx, + _id: generateId(), + objectSpace: core.space.Model, + modifiedBy: ownerAccount.primarySocialId, + modifiedOn: Date.now(), + attributes: { + application: generateId(), + role: AccountRole.Guest, + permissions: [], + enabled: false, + spaceClass: chunter.class.Channel + }, + objectClass: core.class.ModulePermissionGroup, + objectId: groupId + } + await owner.tx(createGroupTx) + }, 15000) + + afterAll(async () => { + // Don't leave Channels permanently disabled for every guest in this workspace. + // + // Note: `RestClient.remove()` posts to `/api/v1/remove`, which requires a system-account + // token (`ensureSystemAccount` in pods/server/src/rpc.ts) - a regular user token like + // `apiWorkspace1`'s gets a 403 there even though the same user can create/remove the doc + // fine through the normal `tx` pipeline (as the `beforeAll` above already does for create). + // So build the `TxRemoveDoc` by hand and send it through `tx`, not `remove()`. + const owner = connect() + const group = await owner.findOne(core.class.ModulePermissionGroup, { _id: groupId }) + if (group !== undefined) { + const removeTx: TxRemoveDoc = { + _class: core.class.TxRemoveDoc, + space: core.space.Tx, + _id: generateId(), + objectId: group._id, + objectClass: group._class, + objectSpace: group.space, + modifiedBy: (await owner.getAccount()).primarySocialId, + modifiedOn: Date.now() + } + await owner.tx(removeTx) + } + }, 15000) + + it('findAll does not return a disabled-module space even though the guest is a member', async () => { + const guest = await createGuestAccount('module-disabled') + const channelId = await createSpace([guest.uuid], {}, chunter.class.Channel) + + const found = await guest.conn.findOne(chunter.class.Channel, { _id: channelId }) + expect(found).toBeUndefined() + }, 20000) + + it('a plain (non-disabled) space the guest is a member of remains visible', async () => { + const guest = await createGuestAccount('module-disabled-control') + const spaceId = await createSpace([guest.uuid]) + + const found = await guest.conn.findOne(core.class.Space, { _id: spaceId }) + expect(found?._id).toBe(spaceId) + }, 20000) + + it('a User (role not targeted by the disabled group) still sees the channel', async () => { + const user = await provisionAccount('module-disabled-user', AccountRole.User) + const channelId = await createSpace([user.uuid], {}, chunter.class.Channel) + + const found = await user.conn.findOne(chunter.class.Channel, { _id: channelId }) + expect(found?._id).toBe(channelId) + }, 20000) + + it('mention/search does not surface a disabled-module space', async () => { + const guest = await createGuestAccount('module-disabled-search') + const channelName = `disabled-channel-${generateId()}` + const channelId = await createSpace([guest.uuid], { name: channelName }, chunter.class.Channel) + + const controlName = `control-channel-${generateId()}` + const controlId = await createSpace([guest.uuid], { name: controlName }, chunter.class.Channel) + + // Wait for the fulltext indexer to catch up on the *visible* control channel before + // asserting the disabled one is absent - otherwise the negative assertion below could + // trivially pass because nothing has been indexed yet at all (see the Person-visibility + // beforeAll above for the same issue). + await waitFor( + async () => { + const result = await guest.conn.searchFulltext( + { query: controlName, classes: [chunter.class.Channel] }, + { limit: 10 } + ) + return result.docs.some((d) => d.id === controlId) ? true : undefined + }, + 20000, + 500 + ) + + const result = await guest.conn.searchFulltext( + { query: channelName, classes: [chunter.class.Channel] }, + { limit: 10 } + ) + expect(result.docs.map((d) => d.id)).not.toContain(channelId) + }, 30000) + }) +})