Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
73 changes: 72 additions & 1 deletion foundations/server/packages/middleware/src/guestPermissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import core, {
type TxUpdateDoc
} from '@hcengineering/core'
import platform, { PlatformError, Severity, Status } from '@hcengineering/platform'
import contact, { type Person } from '@hcengineering/contact'
import contact, { type Channel, type Person } from '@hcengineering/contact'

/** Cached state loaded from GuestPermissionsSettings configuration document. */
interface GuestPermissionsCache {
Expand Down Expand Up @@ -122,6 +122,11 @@ export class GuestPermissionsMiddleware extends BaseMiddleware implements Middle
async tx (ctx: MeasureContext<SessionData>, txes: Tx[]): Promise<TxMiddlewareResult> {
const account = ctx.contextData.account
if (hasAccountRole(account, AccountRole.User)) {
if (!hasAccountRole(account, AccountRole.Maintainer)) {
for (const tx of txes) {
await this.processUserTx(ctx, tx)
}
}
this.invalidateCacheIfNeeded(txes)
return await this.provideTx(ctx, txes)
}
Expand Down Expand Up @@ -160,6 +165,72 @@ export class GuestPermissionsMiddleware extends BaseMiddleware implements Middle
}
}

private async processUserTx (ctx: MeasureContext<SessionData>, tx: Tx): Promise<void> {
if (tx._class === core.class.TxApplyIf) {
const applyTx = tx as TxApplyIf
for (const t of applyTx.txes) {
await this.processUserTx(ctx, t)
}
return
}

if (!TxProcessor.isExtendsCUD(tx._class)) return

const { account } = ctx.contextData
if (await this.isForbiddenUserContactTx(ctx, tx as TxCUD<Doc>, account)) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
}

private canUserEditPersonContactDetails (person: Person, account: Account): boolean {
if (person.personUuid === account.uuid) return true
return !this.context.hierarchy.hasMixin(person, contact.mixin.Employee)
}

private async isForbiddenUserContactTx (
ctx: MeasureContext<SessionData>,
tx: TxCUD<Doc>,
account: Account
): Promise<boolean> {
const h = this.context.hierarchy

if (h.isDerived(tx.objectClass, contact.class.Person)) {
if (tx._class === core.class.TxCreateDoc) return false

const persons = await this.findAll(ctx, contact.class.Person, { _id: tx.objectId as Ref<Person> }, { limit: 1 })
const person = persons[0]
return person === undefined || !this.canUserEditPersonContactDetails(person, account)
}

if (h.isDerived(tx.objectClass, contact.class.Channel)) {
const parentPersonId = await this.getChannelParentPersonId(ctx, tx)
if (parentPersonId === undefined) return false

const persons = await this.findAll(ctx, contact.class.Person, { _id: parentPersonId }, { limit: 1 })
const person = persons[0]
return person === undefined || !this.canUserEditPersonContactDetails(person, account)
}

return false
}

private async getChannelParentPersonId (
ctx: MeasureContext<SessionData>,
tx: TxCUD<Doc>
): Promise<Ref<Person> | undefined> {
if (tx.attachedToClass === contact.class.Person && tx.attachedTo !== undefined) {
return tx.attachedTo as Ref<Person>
}

if (tx._class === core.class.TxCreateDoc) return undefined

const channels = await this.findAll(ctx, contact.class.Channel, { _id: tx.objectId as Ref<Channel> }, { limit: 1 })
const channel = channels[0]
if (channel?.attachedToClass !== contact.class.Person) return undefined

return channel.attachedTo as Ref<Person>
}

/**
* Returns the covered-class ancestor of the objectClass if one exists in the new permissions model,
* or undefined if the class is not covered.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,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'

Expand Down Expand Up @@ -106,6 +107,28 @@ function makeCreateTx (objectClass: Ref<Class<Doc>>, objectSpace: Ref<Space>): T
return factory.createTxCreateDoc(objectClass, objectSpace, {})
}

function patchContactHierarchy (mw: GuestPermissionsMiddleware): void {
;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => a === b
;(mw as any).context.hierarchy.hasMixin = (doc: any, mixin: any) =>
mixin === contact.mixin.Employee && doc?.employee === true
}

function makePersonDoc (
_id: Ref<Doc>,
personUuid: Account['uuid'],
employee: boolean = true
): Doc {
return {
_id,
_class: contact.class.Person,
space: 'contact:space:Contacts' as Ref<Space>,
modifiedOn: Date.now(),
modifiedBy: 'test' as PersonId,
personUuid,
employee
} as any
}

// Helper: buildGuestSettings - simulate the document that loadPermissionsCache would find
function makeGuestSettingsDoc (allowedPermissions: Ref<Doc>[], disabledPermissions?: Ref<Doc>[]): Doc {
return {
Expand Down Expand Up @@ -157,6 +180,122 @@ describe('GuestPermissionsMiddleware', () => {
})
})

// ─── User contact mutability ────────────────────────────────────────────────
describe('user contact mutability', () => {
it('forbids a user from updating another employee person', async () => {
const otherPersonId = generateId() as Ref<Doc>
const account = makeAccount(AccountRole.User)
const findAll: FindAllFn = async (_ctx, _class, query: any) => {
if (_class === contact.class.Person && query?._id === otherPersonId) {
return [makePersonDoc(otherPersonId, generateId() as any)]
}
return []
}
const mw = makeMiddleware(findAll)
patchContactHierarchy(mw)

const factory = new TxFactory(account.primarySocialId)
const tx = factory.createTxUpdateDoc(
contact.class.Person as Ref<Class<Doc>>,
'core:space:Workspace' as Ref<Space>,
otherPersonId,
{ name: 'Changed' } as any
)

await expect(mw.tx(makeCtx(account), [tx])).rejects.toThrow()
})

it('allows a user to update their own employee person', async () => {
const personId = generateId() as Ref<Doc>
const account = makeAccount(AccountRole.User)
let nextCalled = false
const findAll: FindAllFn = async (_ctx, _class, query: any) => {
if (_class === contact.class.Person && query?._id === personId) {
return [makePersonDoc(personId, account.uuid)]
}
return []
}
const mw = makeMiddleware(findAll, async () => {
nextCalled = true
return {}
})
patchContactHierarchy(mw)

const factory = new TxFactory(account.primarySocialId)
const tx = factory.createTxUpdateDoc(
contact.class.Person as Ref<Class<Doc>>,
'core:space:Workspace' as Ref<Space>,
personId,
{ name: 'Changed' } as any
)

await mw.tx(makeCtx(account), [tx])
expect(nextCalled).toBe(true)
})

it('forbids a user from updating channels attached to another employee person', async () => {
const otherPersonId = generateId() as Ref<Doc>
const channelId = generateId() as Ref<Doc>
const account = makeAccount(AccountRole.User)
const findAll: FindAllFn = async (_ctx, _class, query: any) => {
if (_class === contact.class.Person && query?._id === otherPersonId) {
return [makePersonDoc(otherPersonId, generateId() as any)]
}
if (_class === contact.class.Channel && query?._id === channelId) {
return [
{
_id: channelId,
_class: contact.class.Channel,
space: 'contact:space:Contacts' as Ref<Space>,
modifiedOn: Date.now(),
modifiedBy: 'test' as PersonId,
attachedTo: otherPersonId,
attachedToClass: contact.class.Person
} as any
]
}
return []
}
const mw = makeMiddleware(findAll)
patchContactHierarchy(mw)

const factory = new TxFactory(account.primarySocialId)
const tx = factory.createTxUpdateDoc(
contact.class.Channel as Ref<Class<Doc>>,
'core:space:Workspace' as Ref<Space>,
channelId,
{ value: 'new@example.com' } as any
)

await expect(mw.tx(makeCtx(account), [tx])).rejects.toThrow()
})

it('allows maintainers to update another employee person', async () => {
const otherPersonId = generateId() as Ref<Doc>
const account = makeAccount(AccountRole.Maintainer)
let nextCalled = false
const mw = makeMiddleware(
async () => [],
async () => {
nextCalled = true
return {}
}
)
patchContactHierarchy(mw)

const factory = new TxFactory(account.primarySocialId)
const tx = factory.createTxUpdateDoc(
contact.class.Person as Ref<Class<Doc>>,
'core:space:Workspace' as Ref<Space>,
otherPersonId,
{ name: 'Changed' } as any
)

await mw.tx(makeCtx(account), [tx])
expect(nextCalled).toBe(true)
})
})

// ─── DocGuest / ReadOnlyGuest are always forbidden ──────────────────────────
describe('DocGuest and ReadOnlyGuest', () => {
it('DocGuest: throws Forbidden for any tx', async () => {
Expand Down
31 changes: 27 additions & 4 deletions plugins/contact-resources/src/components/ChannelsEditor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@
import { createQuery, getClient } from '@hcengineering/presentation'
import { ButtonKind, ButtonSize, closeTooltip, showPopup } from '@hcengineering/ui'

import { Channel, ChannelProvider } from '@hcengineering/contact'
import { Channel, ChannelProvider, type Person } from '@hcengineering/contact'
import { restrictionStore } from '@hcengineering/view-resources'
import contact from '../plugin'
import { canEditPersonContactDetails } from '../utils'
import ChannelsDropdown from './ChannelsDropdown.svelte'

export let attachedTo: Ref<Doc>
Expand All @@ -37,6 +38,7 @@
export let restricted: Ref<ChannelProvider>[] = []

let channels: Channel[] = []
let attachedPerson: Person | undefined = undefined

const query = createQuery()
$: attachedTo &&
Expand All @@ -50,17 +52,38 @@
}
)

const personQuery = createQuery()
$: if (attachedClass === contact.class.Person) {
personQuery.query(
contact.class.Person,
{
_id: attachedTo as Ref<Person>
},
(res) => {
attachedPerson = res[0]
}
)
} else {
personQuery.unsubscribe()
attachedPerson = undefined
}

$: effectiveEditable =
attachedClass === contact.class.Person
? editable && attachedPerson !== undefined && canEditPersonContactDetails(attachedPerson)
: editable

const client = getClient()

async function remove (value: Channel | AttachedData<Channel>): Promise<void> {
if (!editable) return
if (!effectiveEditable) return
if ('_id' in value) {
await client.remove(value)
}
}

async function saveHandler (value: Channel | AttachedData<Channel>): Promise<void> {
if (!editable) return
if (!effectiveEditable) return
if ('_id' in value) {
await client.update(value, {
value: value.value
Expand Down Expand Up @@ -89,7 +112,7 @@
{size}
{length}
{integrations}
{editable}
editable={effectiveEditable}
{restricted}
{shape}
{focusIndex}
Expand Down
32 changes: 30 additions & 2 deletions plugins/contact-resources/src/components/ChannelsPresenter.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@
// limitations under the License.
-->
<script lang="ts">
import type { Channel } from '@hcengineering/contact'
import type { Channel, Person } from '@hcengineering/contact'
import type { Ref } from '@hcengineering/core'
import { getResource } from '@hcengineering/platform'
import { createQuery } from '@hcengineering/presentation'
import type { ButtonKind, ButtonSize } from '@hcengineering/ui'
import { showPopup } from '@hcengineering/ui'
import { ViewAction } from '@hcengineering/view'
import contact from '../plugin'
import { canEditPersonContactDetails } from '../utils'
import ChannelsDropdown from './ChannelsDropdown.svelte'

export let value: Channel[] | Channel | null
Expand All @@ -29,6 +33,30 @@
export let length: 'tiny' | 'short' | 'full' = 'short'
export let shape: 'circle' | undefined = 'circle'

let attachedPerson: Person | undefined = undefined
const personQuery = createQuery()

$: channel = Array.isArray(value) ? value[0] : value
$: if (channel?.attachedToClass === contact.class.Person) {
personQuery.query(
contact.class.Person,
{
_id: channel.attachedTo as Ref<Person>
},
(res) => {
attachedPerson = res[0]
}
)
} else {
personQuery.unsubscribe()
attachedPerson = undefined
}

$: effectiveEditable =
channel?.attachedToClass === contact.class.Person
? editable === true && attachedPerson !== undefined && canEditPersonContactDetails(attachedPerson)
: editable

async function _open (ev: CustomEvent): Promise<void> {
if (ev.detail.presenter !== undefined && Array.isArray(value)) {
showPopup(ev.detail.presenter, { channel: ev.detail.channel }, 'float')
Expand All @@ -44,5 +72,5 @@
</script>

{#if value}
<ChannelsDropdown bind:value {length} {kind} {size} {shape} {editable} on:open={_open} />
<ChannelsDropdown bind:value {length} {kind} {size} {shape} editable={effectiveEditable} on:open={_open} />
{/if}
Loading