Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0eb9d2d
Update guest permissions
ArtyomSavchenko Aug 1, 2026
d1285e9
Add tests
ArtyomSavchenko Aug 1, 2026
9c5b239
Do not allow to see objects from disabled apps
ArtyomSavchenko Aug 1, 2026
91d811b
Fix copyright
ArtyomSavchenko Aug 1, 2026
0a76758
Redesign guest security
ArtyomSavchenko Aug 13, 2026
5e6d11e
Refactor security layer
ArtyomSavchenko Aug 13, 2026
3ed7628
Merge with develop
ArtyomSavchenko Aug 13, 2026
5b8e1e8
Fix validation
ArtyomSavchenko Aug 14, 2026
0f9c400
Stricter checks
ArtyomSavchenko Aug 17, 2026
2a5f738
Merge with develop
ArtyomSavchenko Aug 17, 2026
36c0bac
Fix validation
ArtyomSavchenko Aug 17, 2026
8d0af6f
Fix formatting
ArtyomSavchenko Aug 17, 2026
72052e7
Fix formatting
ArtyomSavchenko Aug 17, 2026
d47f213
Fix guest rights
ArtyomSavchenko Aug 19, 2026
2ae1b35
Office security
ArtyomSavchenko Aug 19, 2026
b420e78
Fix row-visibility bypass bugs from PR #65 review
ArtyomSavchenko Aug 22, 2026
e62a75f
Make love.class.Room visible to every guest in the office
ArtyomSavchenko Aug 23, 2026
34a5ba9
Add regression coverage for guest @-mention in a shared channel
ArtyomSavchenko Aug 23, 2026
4efb71f
Allow guests to update cards they created (fixes File-card upload)
ArtyomSavchenko Aug 23, 2026
417f911
Let ClassPermission.txClass cover update/remove, not just create
ArtyomSavchenko Aug 23, 2026
fbaaea0
Add core.class.GuestExtraPermissions settings doc
ArtyomSavchenko Aug 23, 2026
95fb12d
Let a guest edit collaborators on cards it created (item 4)
ArtyomSavchenko Aug 23, 2026
9292d07
Add configurable guest activity-visibility scope (item 5)
ArtyomSavchenko Aug 23, 2026
8fc6d29
Let an opted-in guest approve/reject process requests (item 6)
ArtyomSavchenko Aug 23, 2026
41a7e59
Use the existing Guest-permissions toggle UI for items 4 and 6
ArtyomSavchenko Aug 23, 2026
e3d0a2c
Trim excess comments from this session's guest-permission work
ArtyomSavchenko Aug 23, 2026
2e6560e
Fix TS build error: cast core.class.Collaborator to Ref<Class<Doc>>
ArtyomSavchenko Aug 23, 2026
b042d38
Fix 5 correctness findings from PR #65 review
ArtyomSavchenko Aug 23, 2026
4aec233
Extract security model types and document the restricted-role model
claude Aug 23, 2026
1f7414c
Fix formatting
ArtyomSavchenko Aug 24, 2026
51e9364
Clean up
ArtyomSavchenko Sep 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ARCHITECTURE_OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions common/config/rush/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

129 changes: 129 additions & 0 deletions docs/security-model.md
Original file line number Diff line number Diff line change
@@ -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, number> = {
[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<Permission>`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<Doc> {
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<Doc> {
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.
20 changes: 0 additions & 20 deletions foundations/core/packages/core/src/classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -628,26 +628,6 @@ export enum AccountRole {
Admin = 'ADMIN'
}

/**
* @public
*/
export const roleOrder: Record<AccountRole, number> = {
[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<Doc> {
createAccessLevel?: AccountRole
removeAccessLevel?: AccountRole
updateAccessLevel?: AccountRole
isIdentity?: boolean
}

/**
* @public
*/
Expand Down
7 changes: 5 additions & 2 deletions foundations/core/packages/core/src/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -183,7 +184,8 @@ export default plugin(coreId, {
CustomSequence: '' as Ref<Class<CustomSequence>>,
ClassCollaborators: '' as Ref<Class<ClassCollaborators<Doc>>>,
Collaborator: '' as Ref<Class<Collaborator>>,
ModulePermissionGroup: '' as Ref<Class<ModulePermissionGroup>>
ModulePermissionGroup: '' as Ref<Class<ModulePermissionGroup>>,
GuestActivitySettings: '' as Ref<Class<GuestActivitySettings>>
},
icon: {
TypeString: '' as Asset,
Expand All @@ -203,6 +205,7 @@ export default plugin(coreId, {
mixin: {
ConfigurationElement: '' as Ref<Mixin<ConfigurationElement>>,
IndexConfiguration: '' as Ref<Mixin<IndexingConfiguration<Doc>>>,
RowVisibility: '' as Ref<Mixin<RowVisibility>>,
SpacesTypeData: '' as Ref<Mixin<Space>>,
TransientConfiguration: '' as Ref<Mixin<TransientConfiguration>>,
TxAccessLevel: '' as Ref<Mixin<TxAccessLevel>>,
Expand Down
1 change: 1 addition & 0 deletions foundations/core/packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import core from './component'

export * from './classes'
export * from './security'
export * from './autoJoinRoles'
export * from './client'
export * from './collaboration'
Expand Down
128 changes: 128 additions & 0 deletions foundations/core/packages/core/src/security.ts
Original file line number Diff line number Diff line change
@@ -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, number> = {
[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<Doc> {
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<Class<Doc>>
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<Class<Doc>>
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<Doc> {
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
}
2 changes: 2 additions & 0 deletions foundations/core/packages/core/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>

asyncRequests?: ((ctx: MeasureContext, id?: string) => Promise<void>)[]
}
Expand Down
11 changes: 1 addition & 10 deletions foundations/core/packages/core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ import {
type Rank,
type Ref,
type Role,
roleOrder,
type SocialId,
SocialIdType,
type SocialKey,
Expand All @@ -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'
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion foundations/server/packages/core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,8 @@ export class SessionDataImpl implements SessionData {
}
>,
readonly service: string,
readonly grant?: PermissionsGrant
readonly grant?: PermissionsGrant,
readonly extra?: Record<string, any>
) {
this._removedMap = _removedMap
this._contextCache = _contextCache
Expand Down
1 change: 1 addition & 0 deletions foundations/server/packages/middleware/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading