From c2f41118b6de3258471af1b8bf8a989b4aadfd03 Mon Sep 17 00:00:00 2001
From: Philip Molares <philip.molares@udo.edu>
Date: Sun, 26 Mar 2023 14:51:18 +0200
Subject: [PATCH] feat: check permissions in realtime code and frontend

Signed-off-by: Philip Molares <philip.molares@udo.edu>
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
---
 .../realtime-note/realtime-connection.spec.ts | 10 ++-
 .../realtime-note/realtime-connection.ts      |  5 +-
 .../websocket/websocket.gateway.spec.ts       | 16 +++-
 .../realtime/websocket/websocket.gateway.ts   | 14 ++-
 commons/src/index.ts                          |  2 +
 commons/src/utils/permissions.spec.ts         | 87 +++++++++++++++++++
 commons/src/utils/permissions.ts              | 51 +++++++++++
 commons/src/utils/permissions.types.ts        | 31 +++++++
 .../src/y-doc-sync/y-doc-sync-adapter.spec.ts |  6 +-
 commons/src/y-doc-sync/y-doc-sync-adapter.ts  | 10 ++-
 .../y-doc-sync/y-doc-sync-server-adapter.ts   | 10 ++-
 frontend/src/api/notes/types.ts               | 19 +---
 frontend/src/api/permissions/index.ts         |  4 +-
 .../aliases/aliases-add-form.spec.tsx         |  5 +-
 .../permissions/permission-entry-buttons.tsx  |  2 +-
 .../permission-entry-special-group.tsx        |  2 +-
 .../permissions/permission-entry-user.tsx     |  4 +-
 .../permission-section-special-groups.tsx     |  2 +-
 .../document-bar/permissions/types.ts         | 15 ----
 .../editor-page/editor-pane/editor-pane.tsx   |  6 +-
 frontend/src/hooks/common/use-is-owner.ts     |  6 +-
 frontend/src/hooks/common/use-may-edit.ts     | 21 +++++
 frontend/src/redux/note-details/methods.ts    |  5 +-
 ...uild-state-from-server-permissions.spec.ts |  2 +-
 .../build-state-from-server-permissions.ts    |  4 +-
 frontend/src/redux/note-details/types.ts      |  5 +-
 frontend/src/test-utils/note-ownership.ts     |  9 +-
 27 files changed, 287 insertions(+), 66 deletions(-)
 create mode 100644 commons/src/utils/permissions.spec.ts
 create mode 100644 commons/src/utils/permissions.ts
 create mode 100644 commons/src/utils/permissions.types.ts
 delete mode 100644 frontend/src/components/editor-page/document-bar/permissions/types.ts
 create mode 100644 frontend/src/hooks/common/use-may-edit.ts

diff --git a/backend/src/realtime/realtime-note/realtime-connection.spec.ts b/backend/src/realtime/realtime-note/realtime-connection.spec.ts
index d2754f5bf..7c17567e6 100644
--- a/backend/src/realtime/realtime-note/realtime-connection.spec.ts
+++ b/backend/src/realtime/realtime-note/realtime-connection.spec.ts
@@ -1,5 +1,5 @@
 /*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
  *
  * SPDX-License-Identifier: AGPL-3.0-only
  */
@@ -53,6 +53,7 @@ describe('websocket connection', () => {
       mockedMessageTransporter,
       mockedUser,
       mockedRealtimeNote,
+      true,
     );
     expect(sut.getTransporter()).toBe(mockedMessageTransporter);
   });
@@ -62,6 +63,7 @@ describe('websocket connection', () => {
       mockedMessageTransporter,
       mockedUser,
       mockedRealtimeNote,
+      true,
     );
     expect(sut.getRealtimeNote()).toBe(mockedRealtimeNote);
   });
@@ -76,6 +78,7 @@ describe('websocket connection', () => {
       mockedMessageTransporter,
       mockedUser,
       mockedRealtimeNote,
+      true,
     );
 
     expect(sut.getRealtimeUserStateAdapter()).toBe(realtimeUserStatus);
@@ -91,6 +94,7 @@ describe('websocket connection', () => {
       mockedMessageTransporter,
       mockedUser,
       mockedRealtimeNote,
+      true,
     );
 
     expect(sut.getSyncAdapter()).toBe(yDocSyncServerAdapter);
@@ -101,6 +105,7 @@ describe('websocket connection', () => {
       mockedMessageTransporter,
       mockedUser,
       mockedRealtimeNote,
+      true,
     );
 
     const removeClientSpy = jest.spyOn(mockedRealtimeNote, 'removeClient');
@@ -115,6 +120,7 @@ describe('websocket connection', () => {
       mockedMessageTransporter,
       mockedUser,
       mockedRealtimeNote,
+      true,
     );
 
     expect(sut.getUser()).toBe(mockedUser);
@@ -127,6 +133,7 @@ describe('websocket connection', () => {
       mockedMessageTransporter,
       mockedUserWithUsername,
       mockedRealtimeNote,
+      true,
     );
 
     expect(sut.getDisplayName()).toBe('MockUser');
@@ -143,6 +150,7 @@ describe('websocket connection', () => {
       mockedMessageTransporter,
       mockedUser,
       mockedRealtimeNote,
+      true,
     );
 
     expect(sut.getDisplayName()).toBe(randomName);
diff --git a/backend/src/realtime/realtime-note/realtime-connection.ts b/backend/src/realtime/realtime-note/realtime-connection.ts
index 3aae04137..6e215e37b 100644
--- a/backend/src/realtime/realtime-note/realtime-connection.ts
+++ b/backend/src/realtime/realtime-note/realtime-connection.ts
@@ -1,5 +1,5 @@
 /*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
  *
  * SPDX-License-Identifier: AGPL-3.0-only
  */
@@ -28,12 +28,14 @@ export class RealtimeConnection {
    * @param messageTransporter The message transporter that handles the communication with the client.
    * @param user The user of the client
    * @param realtimeNote The {@link RealtimeNote} that the client connected to.
+   * @param acceptEdits If edits by this connection should be accepted.
    * @throws Error if the socket is not open
    */
   constructor(
     messageTransporter: MessageTransporter,
     private user: User | null,
     private realtimeNote: RealtimeNote,
+    private acceptEdits: boolean,
   ) {
     this.displayName = user?.displayName ?? generateRandomName();
     this.transporter = messageTransporter;
@@ -44,6 +46,7 @@ export class RealtimeConnection {
     this.yDocSyncAdapter = new YDocSyncServerAdapter(
       this.transporter,
       realtimeNote.getRealtimeDoc(),
+      acceptEdits,
     );
     this.realtimeUserStateAdapter = new RealtimeUserStatusAdapter(
       this.user?.username ?? null,
diff --git a/backend/src/realtime/websocket/websocket.gateway.spec.ts b/backend/src/realtime/websocket/websocket.gateway.spec.ts
index 2f5ab0d8c..dafaafa48 100644
--- a/backend/src/realtime/websocket/websocket.gateway.spec.ts
+++ b/backend/src/realtime/websocket/websocket.gateway.spec.ts
@@ -1,5 +1,5 @@
 /*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
  *
  * SPDX-License-Identifier: AGPL-3.0-only
  */
@@ -195,8 +195,18 @@ describe('Websocket gateway', () => {
         }
       });
 
-    const mockedNote = Mock.of<Note>({ id: 4711 });
-    const mockedGuestNote = Mock.of<Note>({ id: 1235 });
+    const mockedNote = Mock.of<Note>({
+      id: 4711,
+      owner: Promise.resolve(mockUser),
+      userPermissions: Promise.resolve([]),
+      groupPermissions: Promise.resolve([]),
+    });
+    const mockedGuestNote = Mock.of<Note>({
+      id: 1235,
+      owner: Promise.resolve(null),
+      userPermissions: Promise.resolve([]),
+      groupPermissions: Promise.resolve([]),
+    });
     jest
       .spyOn(notesService, 'getNoteByIdOrAlias')
       .mockImplementation((noteId: string) => {
diff --git a/backend/src/realtime/websocket/websocket.gateway.ts b/backend/src/realtime/websocket/websocket.gateway.ts
index 48ad651ac..5226f5130 100644
--- a/backend/src/realtime/websocket/websocket.gateway.ts
+++ b/backend/src/realtime/websocket/websocket.gateway.ts
@@ -1,9 +1,13 @@
 /*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
  *
  * SPDX-License-Identifier: AGPL-3.0-only
  */
-import { WebsocketTransporter } from '@hedgedoc/commons';
+import {
+  NotePermissions,
+  userCanEdit,
+  WebsocketTransporter,
+} from '@hedgedoc/commons';
 import { OnGatewayConnection, WebSocketGateway } from '@nestjs/websockets';
 import { IncomingMessage } from 'http';
 import WebSocket from 'ws';
@@ -77,10 +81,16 @@ export class WebsocketGateway implements OnGatewayConnection {
         await this.realtimeNoteService.getOrCreateRealtimeNote(note);
 
       const websocketTransporter = new WebsocketTransporter();
+      const permissions = await this.noteService.toNotePermissionsDto(note);
+      const acceptEdits: boolean = userCanEdit(
+        permissions as NotePermissions,
+        user?.username,
+      );
       const connection = new RealtimeConnection(
         websocketTransporter,
         user,
         realtimeNote,
+        acceptEdits,
       );
       websocketTransporter.setWebsocket(clientSocket);
 
diff --git a/commons/src/index.ts b/commons/src/index.ts
index d416c8a20..b1aaf5873 100644
--- a/commons/src/index.ts
+++ b/commons/src/index.ts
@@ -15,6 +15,8 @@ export {
   MissingTrailingSlashError,
   WrongProtocolError
 } from './utils/errors.js'
+export * from './utils/permissions.js'
+export * from './utils/permissions.types.js'
 
 export * from './y-doc-sync/y-doc-sync-client-adapter.js'
 export * from './y-doc-sync/y-doc-sync-server-adapter.js'
diff --git a/commons/src/utils/permissions.spec.ts b/commons/src/utils/permissions.spec.ts
new file mode 100644
index 000000000..605e16fbe
--- /dev/null
+++ b/commons/src/utils/permissions.spec.ts
@@ -0,0 +1,87 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { userCanEdit, userIsOwner } from './permissions.js'
+import { NotePermissions, SpecialGroup } from './permissions.types.js'
+import { describe, expect, it } from '@jest/globals'
+
+describe('Permissions', () => {
+  const testPermissions: NotePermissions = {
+    owner: 'owner',
+    sharedToUsers: [
+      {
+        username: 'logged_in',
+        canEdit: true
+      }
+    ],
+    sharedToGroups: [
+      {
+        groupName: SpecialGroup.EVERYONE,
+        canEdit: true
+      },
+      {
+        groupName: SpecialGroup.LOGGED_IN,
+        canEdit: true
+      }
+    ]
+  }
+  describe('userIsOwner', () => {
+    it('returns true, if user is owner', () => {
+      expect(userIsOwner(testPermissions, 'owner')).toBeTruthy()
+    })
+    it('returns false, if user is not ownerr', () => {
+      expect(userIsOwner(testPermissions, 'not_owner')).toBeFalsy()
+    })
+    it('returns false, if user is undefined', () => {
+      expect(userIsOwner(testPermissions, undefined)).toBeFalsy()
+    })
+  })
+
+  describe('userCanEdit', () => {
+    it('returns true, if user is owner', () => {
+      expect(userCanEdit(testPermissions, 'owner')).toBeTruthy()
+    })
+    it('returns true, if user is logged in and this is user specifically may edit', () => {
+      expect(
+        userCanEdit({ ...testPermissions, sharedToGroups: [] }, 'logged_in')
+      ).toBeTruthy()
+    })
+    it('returns true, if user is logged in and loggedIn users may edit', () => {
+      expect(
+        userCanEdit({ ...testPermissions, sharedToUsers: [] }, 'logged_in')
+      ).toBeTruthy()
+    })
+    it('returns true, if user is guest and guests are allowed to edit', () => {
+      expect(
+        userCanEdit({ ...testPermissions, sharedToUsers: [] }, undefined)
+      ).toBeTruthy()
+    })
+    it('returns false, if user is logged in and loggedIn users may not edit', () => {
+      expect(
+        userCanEdit(
+          { ...testPermissions, sharedToUsers: [], sharedToGroups: [] },
+          'logged_in'
+        )
+      ).toBeFalsy()
+    })
+    it('returns false, if user is guest and guests are not allowed to edit', () => {
+      expect(
+        userCanEdit(
+          {
+            ...testPermissions,
+            sharedToUsers: [],
+            sharedToGroups: [
+              {
+                groupName: SpecialGroup.LOGGED_IN,
+                canEdit: true
+              }
+            ]
+          },
+          undefined
+        )
+      ).toBeFalsy()
+    })
+  })
+})
diff --git a/commons/src/utils/permissions.ts b/commons/src/utils/permissions.ts
new file mode 100644
index 000000000..3cf03b1de
--- /dev/null
+++ b/commons/src/utils/permissions.ts
@@ -0,0 +1,51 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { NotePermissions, SpecialGroup } from './permissions.types.js'
+
+/**
+ * Checks if the given user is the owner of a note.
+ *
+ * @param permissions The permissions of the note to check
+ * @param user The username of the user
+ * @return True if the user is the owner of the note
+ */
+export const userIsOwner = (
+  permissions: NotePermissions,
+  user?: string
+): boolean => {
+  return !!user && permissions.owner === user
+}
+
+/**
+ * Checks if the given user may edit a note.
+ *
+ * @param permissions The permissions of the note to check
+ * @param user The username of the user
+ * @return True if the user has the permission to edit the note
+ */
+export const userCanEdit = (
+  permissions: NotePermissions,
+  user?: string
+): boolean => {
+  const isOwner = userIsOwner(permissions, user)
+  const mayWriteViaUserPermission = permissions.sharedToUsers.some(
+    (value) => value.canEdit && value.username === user
+  )
+  const mayWriteViaGroupPermission =
+    !!user &&
+    permissions.sharedToGroups.some(
+      (value) => value.groupName === SpecialGroup.LOGGED_IN && value.canEdit
+    )
+  const everyoneMayWriteViaGroupPermission = permissions.sharedToGroups.some(
+    (value) => value.groupName === SpecialGroup.EVERYONE && value.canEdit
+  )
+  return (
+    isOwner ||
+    mayWriteViaUserPermission ||
+    mayWriteViaGroupPermission ||
+    everyoneMayWriteViaGroupPermission
+  )
+}
diff --git a/commons/src/utils/permissions.types.ts b/commons/src/utils/permissions.types.ts
new file mode 100644
index 000000000..bb8f95e7e
--- /dev/null
+++ b/commons/src/utils/permissions.types.ts
@@ -0,0 +1,31 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export interface NotePermissions {
+  owner: string | null
+  sharedToUsers: NoteUserPermissionEntry[]
+  sharedToGroups: NoteGroupPermissionEntry[]
+}
+
+export interface NoteUserPermissionEntry {
+  username: string
+  canEdit: boolean
+}
+
+export interface NoteGroupPermissionEntry {
+  groupName: string
+  canEdit: boolean
+}
+export enum AccessLevel {
+  NONE,
+  READ_ONLY,
+  WRITEABLE
+}
+
+export enum SpecialGroup {
+  EVERYONE = '_EVERYONE',
+  LOGGED_IN = '_LOGGED_IN'
+}
diff --git a/commons/src/y-doc-sync/y-doc-sync-adapter.spec.ts b/commons/src/y-doc-sync/y-doc-sync-adapter.spec.ts
index dbb09aa54..683fb3929 100644
--- a/commons/src/y-doc-sync/y-doc-sync-adapter.spec.ts
+++ b/commons/src/y-doc-sync/y-doc-sync-adapter.spec.ts
@@ -104,12 +104,14 @@ describe('message transporter', () => {
 
     const yDocSyncAdapterServerTo1 = new YDocSyncServerAdapter(
       transporterServerTo1,
-      docServer
+      docServer,
+      true
     )
 
     const yDocSyncAdapterServerTo2 = new YDocSyncServerAdapter(
       transporterServerTo2,
-      docServer
+      docServer,
+      true
     )
 
     const waitForClient1Sync = new Promise<void>((resolve) => {
diff --git a/commons/src/y-doc-sync/y-doc-sync-adapter.ts b/commons/src/y-doc-sync/y-doc-sync-adapter.ts
index 3d2778a2e..75d12af72 100644
--- a/commons/src/y-doc-sync/y-doc-sync-adapter.ts
+++ b/commons/src/y-doc-sync/y-doc-sync-adapter.ts
@@ -28,7 +28,7 @@ export abstract class YDocSyncAdapter {
     this.yDocUpdateListener = doc.on(
       'update',
       (update, origin) => {
-        this.processDocUpdate(update, origin)
+        this.distributeDocUpdate(update, origin)
       },
       {
         objectify: true
@@ -92,7 +92,7 @@ export abstract class YDocSyncAdapter {
 
     const noteContentUpdateListener = this.messageTransporter.on(
       MessageType.NOTE_CONTENT_UPDATE,
-      (payload) => this.doc.applyUpdate(payload.payload, this),
+      (payload) => this.applyIncomingUpdatePayload(payload.payload),
       { objectify: true }
     ) as Listener
 
@@ -103,7 +103,11 @@ export abstract class YDocSyncAdapter {
     }
   }
 
-  private processDocUpdate(update: number[], origin: unknown): void {
+  protected applyIncomingUpdatePayload(update: number[]): void {
+    this.doc.applyUpdate(update, this)
+  }
+
+  private distributeDocUpdate(update: number[], origin: unknown): void {
     if (!this.isSynced() || origin === this) {
       return
     }
diff --git a/commons/src/y-doc-sync/y-doc-sync-server-adapter.ts b/commons/src/y-doc-sync/y-doc-sync-server-adapter.ts
index a1c9c1d1c..24f72dfb9 100644
--- a/commons/src/y-doc-sync/y-doc-sync-server-adapter.ts
+++ b/commons/src/y-doc-sync/y-doc-sync-server-adapter.ts
@@ -10,9 +10,17 @@ import { YDocSyncAdapter } from './y-doc-sync-adapter.js'
 export class YDocSyncServerAdapter extends YDocSyncAdapter {
   constructor(
     readonly messageTransporter: MessageTransporter,
-    readonly doc: RealtimeDoc
+    readonly doc: RealtimeDoc,
+    readonly acceptEdits: boolean
   ) {
     super(messageTransporter, doc)
     this.markAsSynced()
   }
+
+  protected applyIncomingUpdatePayload(update: number[]): void {
+    if (!this.acceptEdits) {
+      return
+    }
+    super.applyIncomingUpdatePayload(update)
+  }
 }
diff --git a/frontend/src/api/notes/types.ts b/frontend/src/api/notes/types.ts
index fd6287505..a41d667a8 100644
--- a/frontend/src/api/notes/types.ts
+++ b/frontend/src/api/notes/types.ts
@@ -1,9 +1,10 @@
 /*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
  *
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 import type { Alias } from '../alias/types'
+import type { NotePermissions } from '@hedgedoc/commons'
 
 export interface Note {
   content: string
@@ -35,22 +36,6 @@ export interface NoteEdit {
   updatedAt: string
 }
 
-export interface NotePermissions {
-  owner: string | null
-  sharedToUsers: NoteUserPermissionEntry[]
-  sharedToGroups: NoteGroupPermissionEntry[]
-}
-
-export interface NoteUserPermissionEntry {
-  username: string
-  canEdit: boolean
-}
-
-export interface NoteGroupPermissionEntry {
-  groupName: string
-  canEdit: boolean
-}
-
 export interface NoteDeletionOptions {
   keepMedia: boolean
 }
diff --git a/frontend/src/api/permissions/index.ts b/frontend/src/api/permissions/index.ts
index d6a6c2647..dcb1a3c14 100644
--- a/frontend/src/api/permissions/index.ts
+++ b/frontend/src/api/permissions/index.ts
@@ -1,12 +1,12 @@
 /*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
  *
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
 import { PutApiRequestBuilder } from '../common/api-request-builder/put-api-request-builder'
-import type { NotePermissions } from '../notes/types'
 import type { OwnerChangeDto, PermissionSetDto } from './types'
+import type { NotePermissions } from '@hedgedoc/commons'
 
 /**
  * Sets the owner of a note.
diff --git a/frontend/src/components/editor-page/document-bar/aliases/aliases-add-form.spec.tsx b/frontend/src/components/editor-page/document-bar/aliases/aliases-add-form.spec.tsx
index b99cca806..ce6b26481 100644
--- a/frontend/src/components/editor-page/document-bar/aliases/aliases-add-form.spec.tsx
+++ b/frontend/src/components/editor-page/document-bar/aliases/aliases-add-form.spec.tsx
@@ -4,8 +4,9 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 import * as AliasModule from '../../../../api/alias'
-import * as useApplicationStateModule from '../../../../hooks/common/use-application-state'
 import * as NoteDetailsReduxModule from '../../../../redux/note-details/methods'
+import type { NoteDetails } from '../../../../redux/note-details/types/note-details'
+import { mockNoteOwnership } from '../../../../test-utils/note-ownership'
 import { mockI18n } from '../../../markdown-renderer/test-utils/mock-i18n'
 import * as useUiNotificationsModule from '../../../notifications/ui-notification-boundary'
 import { AliasesAddForm } from './aliases-add-form'
@@ -25,12 +26,12 @@ describe('AliasesAddForm', () => {
     await mockI18n()
     jest.spyOn(AliasModule, 'addAlias').mockImplementation(() => addPromise)
     jest.spyOn(NoteDetailsReduxModule, 'updateMetadata').mockImplementation(() => Promise.resolve())
-    jest.spyOn(useApplicationStateModule, 'useApplicationState').mockReturnValue('mock-note')
     jest.spyOn(useUiNotificationsModule, 'useUiNotifications').mockReturnValue({
       showErrorNotification: jest.fn(),
       dismissNotification: jest.fn(),
       dispatchUiNotification: jest.fn()
     })
+    mockNoteOwnership('test', 'test', { noteDetails: { id: 'mock-note' } as NoteDetails })
   })
 
   afterAll(() => {
diff --git a/frontend/src/components/editor-page/document-bar/permissions/permission-entry-buttons.tsx b/frontend/src/components/editor-page/document-bar/permissions/permission-entry-buttons.tsx
index a45df0a95..2b5094c9b 100644
--- a/frontend/src/components/editor-page/document-bar/permissions/permission-entry-buttons.tsx
+++ b/frontend/src/components/editor-page/document-bar/permissions/permission-entry-buttons.tsx
@@ -5,7 +5,7 @@
  */
 import { UiIcon } from '../../../common/icons/ui-icon'
 import type { PermissionDisabledProps } from './permission-disabled.prop'
-import { AccessLevel } from './types'
+import { AccessLevel } from '@hedgedoc/commons'
 import React, { useMemo } from 'react'
 import { Button, ToggleButtonGroup } from 'react-bootstrap'
 import { Eye as IconEye } from 'react-bootstrap-icons'
diff --git a/frontend/src/components/editor-page/document-bar/permissions/permission-entry-special-group.tsx b/frontend/src/components/editor-page/document-bar/permissions/permission-entry-special-group.tsx
index e4d63f96a..68ac37a48 100644
--- a/frontend/src/components/editor-page/document-bar/permissions/permission-entry-special-group.tsx
+++ b/frontend/src/components/editor-page/document-bar/permissions/permission-entry-special-group.tsx
@@ -9,7 +9,7 @@ import { setNotePermissionsFromServer } from '../../../../redux/note-details/met
 import { IconButton } from '../../../common/icon-button/icon-button'
 import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
 import type { PermissionDisabledProps } from './permission-disabled.prop'
-import { AccessLevel, SpecialGroup } from './types'
+import { AccessLevel, SpecialGroup } from '@hedgedoc/commons'
 import React, { useCallback, useMemo } from 'react'
 import { ToggleButtonGroup } from 'react-bootstrap'
 import { Eye as IconEye } from 'react-bootstrap-icons'
diff --git a/frontend/src/components/editor-page/document-bar/permissions/permission-entry-user.tsx b/frontend/src/components/editor-page/document-bar/permissions/permission-entry-user.tsx
index db2fd17a5..c4aa58061 100644
--- a/frontend/src/components/editor-page/document-bar/permissions/permission-entry-user.tsx
+++ b/frontend/src/components/editor-page/document-bar/permissions/permission-entry-user.tsx
@@ -3,7 +3,6 @@
  *
  * SPDX-License-Identifier: AGPL-3.0-only
  */
-import type { NoteUserPermissionEntry } from '../../../../api/notes/types'
 import { removeUserPermission, setUserPermission } from '../../../../api/permissions'
 import { getUser } from '../../../../api/users'
 import { useApplicationState } from '../../../../hooks/common/use-application-state'
@@ -13,7 +12,8 @@ import { UserAvatarForUser } from '../../../common/user-avatar/user-avatar-for-u
 import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
 import type { PermissionDisabledProps } from './permission-disabled.prop'
 import { PermissionEntryButtons, PermissionType } from './permission-entry-buttons'
-import { AccessLevel } from './types'
+import type { NoteUserPermissionEntry } from '@hedgedoc/commons'
+import { AccessLevel } from '@hedgedoc/commons'
 import React, { useCallback } from 'react'
 import { useAsync } from 'react-use'
 
diff --git a/frontend/src/components/editor-page/document-bar/permissions/permission-section-special-groups.tsx b/frontend/src/components/editor-page/document-bar/permissions/permission-section-special-groups.tsx
index 985fee502..898692bde 100644
--- a/frontend/src/components/editor-page/document-bar/permissions/permission-section-special-groups.tsx
+++ b/frontend/src/components/editor-page/document-bar/permissions/permission-section-special-groups.tsx
@@ -7,7 +7,7 @@ import { useApplicationState } from '../../../../hooks/common/use-application-st
 import { useIsOwner } from '../../../../hooks/common/use-is-owner'
 import type { PermissionDisabledProps } from './permission-disabled.prop'
 import { PermissionEntrySpecialGroup } from './permission-entry-special-group'
-import { AccessLevel, SpecialGroup } from './types'
+import { AccessLevel, SpecialGroup } from '@hedgedoc/commons'
 import React, { Fragment, useMemo } from 'react'
 import { Trans, useTranslation } from 'react-i18next'
 
diff --git a/frontend/src/components/editor-page/document-bar/permissions/types.ts b/frontend/src/components/editor-page/document-bar/permissions/types.ts
deleted file mode 100644
index 35491b167..000000000
--- a/frontend/src/components/editor-page/document-bar/permissions/types.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-export enum AccessLevel {
-  NONE,
-  READ_ONLY,
-  WRITEABLE
-}
-
-export enum SpecialGroup {
-  EVERYONE = '_EVERYONE',
-  LOGGED_IN = '_LOGGED_IN'
-}
diff --git a/frontend/src/components/editor-page/editor-pane/editor-pane.tsx b/frontend/src/components/editor-page/editor-pane/editor-pane.tsx
index 70ce7062f..5c69320a4 100644
--- a/frontend/src/components/editor-page/editor-pane/editor-pane.tsx
+++ b/frontend/src/components/editor-page/editor-pane/editor-pane.tsx
@@ -6,6 +6,7 @@
 import { useApplicationState } from '../../../hooks/common/use-application-state'
 import { ORIGIN, useBaseUrl } from '../../../hooks/common/use-base-url'
 import { useDarkModeState } from '../../../hooks/common/use-dark-mode-state'
+import { useMayEdit } from '../../../hooks/common/use-may-edit'
 import { cypressAttribute, cypressId } from '../../../utils/cypress-attribute'
 import { findLanguageByCodeBlockName } from '../../markdown-renderer/extensions/base/code-block-markdown-extension/find-language-by-code-block-name'
 import type { ScrollProps } from '../synced-scroll/scroll-props'
@@ -130,6 +131,7 @@ export const EditorPane: React.FC<EditorPaneProps> = ({ scrollState, onScroll, o
   const darkModeActivated = useDarkModeState()
   const editorOrigin = useBaseUrl(ORIGIN.EDITOR)
   const isSynced = useApplicationState((state) => state.realtimeStatus.isSynced)
+  const mayEdit = useMayEdit()
 
   useEffect(() => {
     const listener = messageTransporter.doAsSoonAsConnected(() => messageTransporter.sendReady())
@@ -144,11 +146,11 @@ export const EditorPane: React.FC<EditorPaneProps> = ({ scrollState, onScroll, o
       onTouchStart={onMakeScrollSource}
       onMouseEnter={onMakeScrollSource}
       {...cypressId('editor-pane')}
-      {...cypressAttribute('editor-ready', String(updateViewContextExtension !== null && isSynced))}>
+      {...cypressAttribute('editor-ready', String(updateViewContextExtension !== null && isSynced && mayEdit))}>
       <MaxLengthWarning />
       <ToolBar />
       <ReactCodeMirror
-        editable={updateViewContextExtension !== null && isSynced}
+        editable={updateViewContextExtension !== null && isSynced && mayEdit}
         placeholder={t('editor.placeholder', { host: editorOrigin }) ?? ''}
         extensions={extensions}
         width={'100%'}
diff --git a/frontend/src/hooks/common/use-is-owner.ts b/frontend/src/hooks/common/use-is-owner.ts
index 00d6e130b..bfa209cb1 100644
--- a/frontend/src/hooks/common/use-is-owner.ts
+++ b/frontend/src/hooks/common/use-is-owner.ts
@@ -4,6 +4,8 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 import { useApplicationState } from './use-application-state'
+import type { NotePermissions } from '@hedgedoc/commons'
+import { userIsOwner } from '@hedgedoc/commons'
 import { useMemo } from 'react'
 
 /**
@@ -12,8 +14,8 @@ import { useMemo } from 'react'
  * @return True, if the current user is owner.
  */
 export const useIsOwner = (): boolean => {
-  const owner = useApplicationState((state) => state.noteDetails.permissions.owner)
   const me: string | undefined = useApplicationState((state) => state.user?.username)
+  const permissions: NotePermissions = useApplicationState((state) => state.noteDetails.permissions)
 
-  return useMemo(() => !!me && owner === me, [owner, me])
+  return useMemo(() => userIsOwner(permissions, me), [permissions, me])
 }
diff --git a/frontend/src/hooks/common/use-may-edit.ts b/frontend/src/hooks/common/use-may-edit.ts
new file mode 100644
index 000000000..756d2fe47
--- /dev/null
+++ b/frontend/src/hooks/common/use-may-edit.ts
@@ -0,0 +1,21 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { useApplicationState } from './use-application-state'
+import type { NotePermissions } from '@hedgedoc/commons'
+import { userCanEdit } from '@hedgedoc/commons'
+import { useMemo } from 'react'
+
+/**
+ * Determines if the current user is allowed to write to this note.
+ *
+ * @return True, if the current user is allowed to write.
+ */
+export const useMayEdit = (): boolean => {
+  const me: string | undefined = useApplicationState((state) => state.user?.username)
+  const permissions: NotePermissions = useApplicationState((state) => state.noteDetails.permissions)
+
+  return useMemo(() => userCanEdit(permissions, me), [permissions, me])
+}
diff --git a/frontend/src/redux/note-details/methods.ts b/frontend/src/redux/note-details/methods.ts
index c975c4536..cff1e6357 100644
--- a/frontend/src/redux/note-details/methods.ts
+++ b/frontend/src/redux/note-details/methods.ts
@@ -1,11 +1,11 @@
 /*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
  *
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 import { store } from '..'
 import { getNoteMetadata } from '../../api/notes'
-import type { Note, NotePermissions } from '../../api/notes/types'
+import type { Note } from '../../api/notes/types'
 import type { CursorSelection } from '../../components/editor-page/editor-pane/tool-bar/formatters/types/cursor-selection'
 import type {
   SetNoteDetailsFromServerAction,
@@ -16,6 +16,7 @@ import type {
   UpdateNoteTitleByFirstHeadingAction
 } from './types'
 import { NoteDetailsActionType } from './types'
+import type { NotePermissions } from '@hedgedoc/commons'
 
 /**
  * Sets the content of the current note, extracts and parses the frontmatter and extracts the markdown content part.
diff --git a/frontend/src/redux/note-details/reducers/build-state-from-server-permissions.spec.ts b/frontend/src/redux/note-details/reducers/build-state-from-server-permissions.spec.ts
index e2f6415bc..88c4ec7e8 100644
--- a/frontend/src/redux/note-details/reducers/build-state-from-server-permissions.spec.ts
+++ b/frontend/src/redux/note-details/reducers/build-state-from-server-permissions.spec.ts
@@ -3,10 +3,10 @@
  *
  * SPDX-License-Identifier: AGPL-3.0-only
  */
-import type { NotePermissions } from '../../../api/notes/types'
 import { initialState } from '../initial-state'
 import type { NoteDetails } from '../types/note-details'
 import { buildStateFromServerPermissions } from './build-state-from-server-permissions'
+import type { NotePermissions } from '@hedgedoc/commons'
 
 describe('build state from server permissions', () => {
   it('creates a new state with the given permissions', () => {
diff --git a/frontend/src/redux/note-details/reducers/build-state-from-server-permissions.ts b/frontend/src/redux/note-details/reducers/build-state-from-server-permissions.ts
index 77a5eceae..7281f0f2f 100644
--- a/frontend/src/redux/note-details/reducers/build-state-from-server-permissions.ts
+++ b/frontend/src/redux/note-details/reducers/build-state-from-server-permissions.ts
@@ -1,10 +1,10 @@
 /*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
  *
  * SPDX-License-Identifier: AGPL-3.0-only
  */
-import type { NotePermissions } from '../../../api/notes/types'
 import type { NoteDetails } from '../types/note-details'
+import type { NotePermissions } from '@hedgedoc/commons'
 
 /**
  * Builds the updated state from a given previous state and updated NotePermissions data.
diff --git a/frontend/src/redux/note-details/types.ts b/frontend/src/redux/note-details/types.ts
index 95d2d2dfa..96b16c204 100644
--- a/frontend/src/redux/note-details/types.ts
+++ b/frontend/src/redux/note-details/types.ts
@@ -1,10 +1,11 @@
 /*
- * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
  *
  * SPDX-License-Identifier: AGPL-3.0-only
  */
-import type { Note, NoteMetadata, NotePermissions } from '../../api/notes/types'
+import type { Note, NoteMetadata } from '../../api/notes/types'
 import type { CursorSelection } from '../../components/editor-page/editor-pane/tool-bar/formatters/types/cursor-selection'
+import type { NotePermissions } from '@hedgedoc/commons'
 import type { Action } from 'redux'
 
 export enum NoteDetailsActionType {
diff --git a/frontend/src/test-utils/note-ownership.ts b/frontend/src/test-utils/note-ownership.ts
index cfe3487f7..23276dad3 100644
--- a/frontend/src/test-utils/note-ownership.ts
+++ b/frontend/src/test-utils/note-ownership.ts
@@ -7,15 +7,22 @@ import * as useApplicationStateModule from '../hooks/common/use-application-stat
 import type { ApplicationState } from '../redux/application-state'
 
 jest.mock('../hooks/common/use-application-state')
-export const mockNoteOwnership = (ownUsername: string, noteOwner: string) => {
+export const mockNoteOwnership = (
+  ownUsername: string,
+  noteOwner: string,
+  additionalState?: Partial<ApplicationState>
+) => {
   jest.spyOn(useApplicationStateModule, 'useApplicationState').mockImplementation((fn) => {
     return fn({
+      ...additionalState,
       noteDetails: {
+        ...additionalState?.noteDetails,
         permissions: {
           owner: noteOwner
         }
       },
       user: {
+        ...additionalState?.user,
         username: ownUsername
       }
     } as ApplicationState)