From df976b5fe1c6d4bc794376d20e8ab5dec4f21692 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Sun, 21 Aug 2022 21:09:24 +0200 Subject: [PATCH] feat(config): add config vars for default permissions for special groups Co-authored-by: Tilman Vatteroth Signed-off-by: Tilman Vatteroth Signed-off-by: Philip Molares --- docs/content/config/index.md | 28 +- docs/content/dev/config.md | 5 +- src/config/default-access-permission.enum.ts | 26 ++ src/config/guest_access.enum.ts | 29 ++ src/config/mock/note.config.mock.ts | 9 + src/config/note.config.spec.ts | 328 +++++++++++++++++- src/config/note.config.ts | 72 +++- .../frontend-config.service.spec.ts | 20 +- .../frontend-config.service.ts | 3 +- src/notes/notes.service.ts | 34 ++ 10 files changed, 518 insertions(+), 36 deletions(-) create mode 100644 src/config/default-access-permission.enum.ts create mode 100644 src/config/guest_access.enum.ts diff --git a/docs/content/config/index.md b/docs/content/config/index.md index 1942bb157..8d1c263a4 100644 --- a/docs/content/config/index.md +++ b/docs/content/config/index.md @@ -19,20 +19,30 @@ We also provide an `.env.example` file containing a minimal configuration in the ## General -| environment variable | default | example | description | -|--------------------------|-----------|------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------| -| `HD_DOMAIN` | - | `https://md.example.com` | The URL the HedgeDoc instance runs on. | -| `PORT` | 3000 | | The port the HedgeDoc instance runs on. | -| `HD_RENDERER_ORIGIN` | HD_DOMAIN | | The URL the renderer runs on. If omitted this will be same as `HD_DOMAIN`. | -| `HD_LOGLEVEL` | warn | | The loglevel that should be used. Options are `error`, `warn`, `info`, `debug` or `trace`. | -| `HD_FORBIDDEN_NOTE_IDS` | - | `notAllowed, alsoNotAllowed` | A list of note ids (separated by `,`), that are not allowed to be created or requested by anyone. | -| `HD_MAX_DOCUMENT_LENGTH` | 100000 | | The maximum length of any one document. Changes to this will impact performance for your users. | -| `HD_PERSIST_INTERVAL` | 10 | `0`, `5`, `10`, `20` | The time interval in **minutes** for the periodic note revision creation during realtime editing. `0` deactivates the periodic note revision creation. | +| environment variable | default | example | description | +|--------------------------|-----------|-----------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------| +| `HD_DOMAIN` | - | `https://md.example.com` | The URL the HedgeDoc instance runs on. | +| `PORT` | 3000 | | The port the HedgeDoc instance runs on. | +| `HD_RENDERER_ORIGIN` | HD_DOMAIN | | The URL the renderer runs on. If omitted this will be same as `HD_DOMAIN`. | +| `HD_LOGLEVEL` | warn | | The loglevel that should be used. Options are `error`, `warn`, `info`, `debug` or `trace`. | +| `HD_FORBIDDEN_NOTE_IDS` | - | `notAllowed,alsoNotAllowed` | A list of note ids (separated by `,`), that are not allowed to be created or requested by anyone. | +| `HD_MAX_DOCUMENT_LENGTH` | 100000 | | The maximum length of any one document. Changes to this will impact performance for your users. | +| `HD_PERSIST_INTERVAL` | 10 | `0`, `5`, `10`, `20` | The time interval in **minutes** for the periodic note revision creation during realtime editing. `0` deactivates the periodic note revision creation. | ### Why should I want to run my renderer on a different (sub-)domain? If the renderer is provided by another domain, it's way harder to manipulate HedgeDoc or steal credentials from the rendered note content, because renderer and editor are more isolated. This increases the security of the software and greatly mitigates [XSS attacks](https://en.wikipedia.org/wiki/Cross-site_scripting). However, you can run HedgeDoc without this extra security, but we recommend using it if possible. +## Notes + +| environment variable | default | example | description | +|------------------------------------------|---------|-----------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `HD_FORBIDDEN_NOTE_IDS` | - | `notAllowed, alsoNotAllowed` | A list of note ids (separated by `,`), that are not allowed to be created or requested by anyone. | +| `HD_MAX_DOCUMENT_LENGTH` | 100000 | | The maximum length of any one document. Changes to this will impact performance for your users. | +| `HD_GUEST_ACCESS` | `write` | `deny`, `read`, `write`, `create` | Defines the maximum access level for guest users to the instance. If guest access is set lower than the "everyone" permission of a note then the note permission will be overridden. | +| `HD_PERMISSION_LOGGED_IN_DEFAULT_ACCESS` | `write` | `none, read, write` | The default permission for the "logged-in" group that is set on new notes. | +| `HD_PERMISSION_EVERYONE_DEFAULT_ACCESS` | `read` | `none, read, write` | The default permission for the "everyone" group (logged-in & guest users), that is set on new notes created by logged-in users. Notes created by guests always set this to "write". | + ## Authentication **ToDo:** Add Authentication docs diff --git a/docs/content/dev/config.md b/docs/content/dev/config.md index 5452ace90..eead4f2f6 100644 --- a/docs/content/dev/config.md +++ b/docs/content/dev/config.md @@ -8,7 +8,7 @@ NestJS - the framework we use - is reading the variables from the environment an ## How the config code works -The config of HedgeDoc is split up into **eight** different modules: +The config of HedgeDoc is split up into **nine** different modules: `app.config.ts` : General configuration of the app @@ -34,6 +34,9 @@ The config of HedgeDoc is split up into **eight** different modules: `media.config.ts` : Where media files are being stored +`note.config.ts` +: Configuration for notes + Each of those files (except `auth.config.ts` which is discussed later) consists of three parts: 1. An interface diff --git a/src/config/default-access-permission.enum.ts b/src/config/default-access-permission.enum.ts new file mode 100644 index 000000000..a21ae1ed2 --- /dev/null +++ b/src/config/default-access-permission.enum.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export enum DefaultAccessPermission { + NONE = 'none', + READ = 'read', + WRITE = 'write', +} + +export function getDefaultAccessPermissionOrdinal( + permission: DefaultAccessPermission, +): number { + switch (permission) { + case DefaultAccessPermission.NONE: + return 0; + case DefaultAccessPermission.READ: + return 1; + case DefaultAccessPermission.WRITE: + return 2; + default: + throw Error('Unknown permission'); + } +} diff --git a/src/config/guest_access.enum.ts b/src/config/guest_access.enum.ts new file mode 100644 index 000000000..79f0066ef --- /dev/null +++ b/src/config/guest_access.enum.ts @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export enum GuestAccess { + DENY = 'deny', + READ = 'read', + WRITE = 'write', + CREATE = 'create', +} + +export function getGuestAccessOrdinal( + guestAccess: GuestAccess, +): number { + switch (guestAccess) { + case GuestAccess.DENY: + return 0; + case GuestAccess.READ: + return 1; + case GuestAccess.WRITE: + return 2; + case GuestAccess.CREATE: + return 3; + default: + throw Error('Unknown permission'); + } +} diff --git a/src/config/mock/note.config.mock.ts b/src/config/mock/note.config.mock.ts index fe7a02086..487dd711d 100644 --- a/src/config/mock/note.config.mock.ts +++ b/src/config/mock/note.config.mock.ts @@ -6,12 +6,21 @@ import { ConfigFactoryKeyHost, registerAs } from '@nestjs/config'; import { ConfigFactory } from '@nestjs/config/dist/interfaces'; +import { DefaultAccessPermission } from '../default-access-permission.enum'; +import { DefaultAccessPermission } from '../default-access-permission.enum'; import { NoteConfig } from '../note.config'; +import { GuestAccess } from '../guest_access.enum'; export function createDefaultMockNoteConfig(): NoteConfig { return { maxDocumentLength: 100000, forbiddenNoteIds: ['forbiddenNoteId'], + permissions: { + default: { + everyone: DefaultAccessPermission.READ, + loggedIn: DefaultAccessPermission.WRITE, + }, + }, }; } diff --git a/src/config/note.config.spec.ts b/src/config/note.config.spec.ts index 3949a2e91..4f4c5e270 100644 --- a/src/config/note.config.spec.ts +++ b/src/config/note.config.spec.ts @@ -5,6 +5,8 @@ */ import mockedEnv from 'mocked-env'; +import { DefaultAccessPermission } from './default-access-permission.enum'; +import { GuestAccess } from './guest_access.enum'; import noteConfig from './note.config'; describe('noteConfig', () => { @@ -15,14 +17,19 @@ describe('noteConfig', () => { const negativeMaxDocumentLength = -123; const floatMaxDocumentLength = 2.71; const invalidMaxDocumentLength = 'not-a-max-document-length'; + const guestAccess = GuestAccess.CREATE; + const wrongDefaultPermission = 'wrong'; describe('correctly parses config', () => { - it('when given correct and complete environment variables', async () => { + it('when given correct and complete environment variables', () => { const restore = mockedEnv( { /* eslint-disable @typescript-eslint/naming-convention */ HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), + HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.READ, + HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.READ, + HD_GUEST_ACCESS: guestAccess, /* eslint-enable @typescript-eslint/naming-convention */ }, { @@ -33,6 +40,13 @@ describe('noteConfig', () => { expect(config.forbiddenNoteIds).toHaveLength(forbiddenNoteIds.length); expect(config.forbiddenNoteIds).toEqual(forbiddenNoteIds); expect(config.maxDocumentLength).toEqual(maxDocumentLength); + expect(config.permissions.default.everyone).toEqual( + DefaultAccessPermission.READ, + ); + expect(config.permissions.default.loggedIn).toEqual( + DefaultAccessPermission.READ, + ); + expect(config.guestAccess).toEqual(guestAccess); restore(); }); @@ -41,6 +55,9 @@ describe('noteConfig', () => { { /* eslint-disable @typescript-eslint/naming-convention */ HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), + HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.READ, + HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.READ, + HD_GUEST_ACCESS: guestAccess, /* eslint-enable @typescript-eslint/naming-convention */ }, { @@ -50,24 +67,13 @@ describe('noteConfig', () => { const config = noteConfig(); expect(config.forbiddenNoteIds).toHaveLength(0); expect(config.maxDocumentLength).toEqual(maxDocumentLength); - restore(); - }); - - it('when no HD_MAX_DOCUMENT_LENGTH is set', () => { - const restore = mockedEnv( - { - /* eslint-disable @typescript-eslint/naming-convention */ - HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), - /* eslint-enable @typescript-eslint/naming-convention */ - }, - { - clear: true, - }, + expect(config.permissions.default.everyone).toEqual( + DefaultAccessPermission.READ, ); - const config = noteConfig(); - expect(config.forbiddenNoteIds).toHaveLength(forbiddenNoteIds.length); - expect(config.forbiddenNoteIds).toEqual(forbiddenNoteIds); - expect(config.maxDocumentLength).toEqual(100000); + expect(config.permissions.default.loggedIn).toEqual( + DefaultAccessPermission.READ, + ); + expect(config.guestAccess).toEqual(guestAccess); restore(); }); @@ -77,6 +83,9 @@ describe('noteConfig', () => { /* eslint-disable @typescript-eslint/naming-convention */ HD_FORBIDDEN_NOTE_IDS: forbiddenNoteId, HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), + HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.READ, + HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.READ, + HD_GUEST_ACCESS: guestAccess, /* eslint-enable @typescript-eslint/naming-convention */ }, { @@ -87,9 +96,133 @@ describe('noteConfig', () => { expect(config.forbiddenNoteIds).toHaveLength(1); expect(config.forbiddenNoteIds[0]).toEqual(forbiddenNoteId); expect(config.maxDocumentLength).toEqual(maxDocumentLength); + expect(config.permissions.default.everyone).toEqual( + DefaultAccessPermission.READ, + ); + expect(config.permissions.default.loggedIn).toEqual( + DefaultAccessPermission.READ, + ); + + expect(config.guestAccess).toEqual(guestAccess); + restore(); + }); + + it('when no HD_MAX_DOCUMENT_LENGTH is set', () => { + const restore = mockedEnv( + { + /* eslint-disable @typescript-eslint/naming-convention */ + HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), + HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.READ, + HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.READ, + HD_GUEST_ACCESS: guestAccess, + /* eslint-enable @typescript-eslint/naming-convention */ + }, + { + clear: true, + }, + ); + const config = noteConfig(); + expect(config.forbiddenNoteIds).toHaveLength(forbiddenNoteIds.length); + expect(config.forbiddenNoteIds).toEqual(forbiddenNoteIds); + expect(config.maxDocumentLength).toEqual(100000); + expect(config.permissions.default.everyone).toEqual( + DefaultAccessPermission.READ, + ); + expect(config.permissions.default.loggedIn).toEqual( + DefaultAccessPermission.READ, + ); + + expect(config.guestAccess).toEqual(guestAccess); + restore(); + }); + + it('when no HD_PERMISSION_DEFAULT_EVERYONE is set', () => { + const restore = mockedEnv( + { + /* eslint-disable @typescript-eslint/naming-convention */ + HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), + HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), + HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.READ, + HD_GUEST_ACCESS: guestAccess, + /* eslint-enable @typescript-eslint/naming-convention */ + }, + { + clear: true, + }, + ); + const config = noteConfig(); + expect(config.forbiddenNoteIds).toHaveLength(forbiddenNoteIds.length); + expect(config.forbiddenNoteIds).toEqual(forbiddenNoteIds); + expect(config.maxDocumentLength).toEqual(maxDocumentLength); + expect(config.permissions.default.everyone).toEqual( + DefaultAccessPermission.READ, + ); + expect(config.permissions.default.loggedIn).toEqual( + DefaultAccessPermission.READ, + ); + + expect(config.guestAccess).toEqual(guestAccess); + restore(); + }); + + it('when no HD_PERMISSION_DEFAULT_LOGGED_IN is set', () => { + const restore = mockedEnv( + { + /* eslint-disable @typescript-eslint/naming-convention */ + HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), + HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), + HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.READ, + HD_GUEST_ACCESS: guestAccess, + /* eslint-enable @typescript-eslint/naming-convention */ + }, + { + clear: true, + }, + ); + const config = noteConfig(); + expect(config.forbiddenNoteIds).toHaveLength(forbiddenNoteIds.length); + expect(config.forbiddenNoteIds).toEqual(forbiddenNoteIds); + expect(config.maxDocumentLength).toEqual(maxDocumentLength); + expect(config.permissions.default.everyone).toEqual( + DefaultAccessPermission.READ, + ); + expect(config.permissions.default.loggedIn).toEqual( + DefaultAccessPermission.WRITE, + ); + + expect(config.guestAccess).toEqual(guestAccess); + restore(); + }); + + it('when no HD_GUEST_ACCESS is set', () => { + const restore = mockedEnv( + { + /* eslint-disable @typescript-eslint/naming-convention */ + HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), + HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), + HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.READ, + /* eslint-enable @typescript-eslint/naming-convention */ + }, + { + clear: true, + }, + ); + const config = noteConfig(); + expect(config.forbiddenNoteIds).toHaveLength(forbiddenNoteIds.length); + expect(config.forbiddenNoteIds).toEqual(forbiddenNoteIds); + expect(config.maxDocumentLength).toEqual(maxDocumentLength); + expect(config.permissions.default.everyone).toEqual( + DefaultAccessPermission.READ, + ); + expect(config.permissions.default.loggedIn).toEqual( + DefaultAccessPermission.WRITE, + ); + + expect(config.guestAccess).toEqual(GuestAccess.WRITE); restore(); }); }); + describe('throws error', () => { it('when given a non-valid HD_FORBIDDEN_NOTE_IDS', async () => { const restore = mockedEnv( @@ -97,6 +230,9 @@ describe('noteConfig', () => { /* eslint-disable @typescript-eslint/naming-convention */ HD_FORBIDDEN_NOTE_IDS: invalidforbiddenNoteIds.join(' , '), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), + HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.READ, + HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.READ, + HD_GUEST_ACCESS: guestAccess, /* eslint-enable @typescript-eslint/naming-convention */ }, { @@ -115,6 +251,9 @@ describe('noteConfig', () => { /* eslint-disable @typescript-eslint/naming-convention */ HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_MAX_DOCUMENT_LENGTH: negativeMaxDocumentLength.toString(), + HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.READ, + HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.READ, + HD_GUEST_ACCESS: guestAccess, /* eslint-enable @typescript-eslint/naming-convention */ }, { @@ -133,6 +272,9 @@ describe('noteConfig', () => { /* eslint-disable @typescript-eslint/naming-convention */ HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_MAX_DOCUMENT_LENGTH: floatMaxDocumentLength.toString(), + HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.READ, + HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.READ, + HD_GUEST_ACCESS: guestAccess, /* eslint-enable @typescript-eslint/naming-convention */ }, { @@ -151,6 +293,9 @@ describe('noteConfig', () => { /* eslint-disable @typescript-eslint/naming-convention */ HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_MAX_DOCUMENT_LENGTH: invalidMaxDocumentLength, + HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.READ, + HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.READ, + HD_GUEST_ACCESS: guestAccess, /* eslint-enable @typescript-eslint/naming-convention */ }, { @@ -162,5 +307,152 @@ describe('noteConfig', () => { ); restore(); }); + + it('when given a non-valid HD_PERMISSION_DEFAULT_EVERYONE', async () => { + const restore = mockedEnv( + { + /* eslint-disable @typescript-eslint/naming-convention */ + HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), + HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), + HD_PERMISSION_DEFAULT_EVERYONE: wrongDefaultPermission, + HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.READ, + HD_GUEST_ACCESS: guestAccess, + /* eslint-enable @typescript-eslint/naming-convention */ + }, + { + clear: true, + }, + ); + expect(() => noteConfig()).toThrow( + '"HD_PERMISSION_DEFAULT_EVERYONE" must be one of [none, read, write]', + ); + restore(); + }); + + it('when given a non-valid HD_PERMISSION_DEFAULT_LOGGED_IN', async () => { + const restore = mockedEnv( + { + /* eslint-disable @typescript-eslint/naming-convention */ + HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), + HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), + HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.READ, + HD_PERMISSION_DEFAULT_LOGGED_IN: wrongDefaultPermission, + HD_GUEST_ACCESS: guestAccess, + /* eslint-enable @typescript-eslint/naming-convention */ + }, + { + clear: true, + }, + ); + expect(() => noteConfig()).toThrow( + '"HD_PERMISSION_DEFAULT_LOGGED_IN" must be one of [none, read, write]', + ); + restore(); + }); + + it('when given a non-valid HD_GUEST_ACCESS', async () => { + const restore = mockedEnv( + { + /* eslint-disable @typescript-eslint/naming-convention */ + HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), + HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), + HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.READ, + HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.READ, + HD_GUEST_ACCESS: wrongDefaultPermission, + /* eslint-enable @typescript-eslint/naming-convention */ + }, + { + clear: true, + }, + ); + expect(() => noteConfig()).toThrow( + '"HD_GUEST_ACCESS" must be one of [deny, read, write, create]', + ); + restore(); + }); + + it('when HD_GUEST_ACCESS is set to deny and HD_PERMISSION_DEFAULT_EVERYONE is set', async () => { + const restore = mockedEnv( + { + /* eslint-disable @typescript-eslint/naming-convention */ + HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), + HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), + HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.READ, + HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.READ, + HD_GUEST_ACCESS: 'deny', + /* eslint-enable @typescript-eslint/naming-convention */ + }, + { + clear: true, + }, + ); + expect(() => noteConfig()).toThrow( + `'HD_GUEST_ACCESS' is set to 'deny', but 'HD_PERMISSION_DEFAULT_EVERYONE' is also configured. Please remove 'HD_PERMISSION_DEFAULT_EVERYONE'.`, + ); + restore(); + }); + + it('when HD_PERMISSION_DEFAULT_EVERYONE is set to write, but HD_PERMISSION_DEFAULT_LOGGED_IN is set to read', async () => { + const restore = mockedEnv( + { + /* eslint-disable @typescript-eslint/naming-convention */ + HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), + HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), + HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.WRITE, + HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.READ, + HD_GUEST_ACCESS: guestAccess, + /* eslint-enable @typescript-eslint/naming-convention */ + }, + { + clear: true, + }, + ); + expect(() => noteConfig()).toThrow( + `'HD_PERMISSION_DEFAULT_EVERYONE' is set to '${DefaultAccessPermission.WRITE}', but 'HD_PERMISSION_DEFAULT_LOGGED_IN' is set to '${DefaultAccessPermission.READ}'. This gives everyone greater permissions than logged-in users which is not allowed.`, + ); + restore(); + }); + + it('when HD_PERMISSION_DEFAULT_EVERYONE is set to write, but HD_PERMISSION_DEFAULT_LOGGED_IN is set to none', async () => { + const restore = mockedEnv( + { + /* eslint-disable @typescript-eslint/naming-convention */ + HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), + HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), + HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.WRITE, + HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.NONE, + HD_GUEST_ACCESS: guestAccess, + /* eslint-enable @typescript-eslint/naming-convention */ + }, + { + clear: true, + }, + ); + expect(() => noteConfig()).toThrow( + `'HD_PERMISSION_DEFAULT_EVERYONE' is set to '${DefaultAccessPermission.WRITE}', but 'HD_PERMISSION_DEFAULT_LOGGED_IN' is set to '${DefaultAccessPermission.NONE}'. This gives everyone greater permissions than logged-in users which is not allowed.`, + ); + restore(); + }); + + it('when HD_PERMISSION_DEFAULT_EVERYONE is set to read, but HD_PERMISSION_DEFAULT_LOGGED_IN is set to none', async () => { + const restore = mockedEnv( + { + /* eslint-disable @typescript-eslint/naming-convention */ + HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), + HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), + HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.READ, + HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.NONE, + HD_GUEST_ACCESS: guestAccess, + /* eslint-enable @typescript-eslint/naming-convention */ + }, + { + clear: true, + }, + ); + expect(() => noteConfig()).toThrow( + `'HD_PERMISSION_DEFAULT_EVERYONE' is set to '${DefaultAccessPermission.READ}', but 'HD_PERMISSION_DEFAULT_LOGGED_IN' is set to '${DefaultAccessPermission.NONE}'. This gives everyone greater permissions than logged-in users which is not allowed.`, + ); + restore(); + }); }); }); diff --git a/src/config/note.config.ts b/src/config/note.config.ts index 9df716d18..57aa557cd 100644 --- a/src/config/note.config.ts +++ b/src/config/note.config.ts @@ -6,14 +6,26 @@ import { registerAs } from '@nestjs/config'; import * as Joi from 'joi'; +import { + DefaultAccessPermission, + getDefaultAccessPermissionOrdinal, +} from './default-access-permission.enum'; +import { GuestAccess } from './guest_access.enum'; import { buildErrorMessage, parseOptionalNumber, toArrayConfig } from './utils'; export interface NoteConfig { forbiddenNoteIds: string[]; maxDocumentLength: number; + guestAccess: GuestAccess; + permissions: { + default: { + everyone: DefaultAccessPermission; + loggedIn: DefaultAccessPermission; + }; + }; } -const schema = Joi.object({ +const schema = Joi.object({ forbiddenNoteIds: Joi.array() .items(Joi.string()) .optional() @@ -25,8 +37,52 @@ const schema = Joi.object({ .integer() .optional() .label('HD_MAX_DOCUMENT_LENGTH'), + guestAccess: Joi.string() + .valid(...Object.values(GuestAccess)) + .optional() + .default(GuestAccess.WRITE) + .label('HD_GUEST_ACCESS'), + permissions: { + default: { + everyone: Joi.string() + .valid(...Object.values(DefaultAccessPermission)) + .optional() + .default(DefaultAccessPermission.READ) + .label('HD_PERMISSION_DEFAULT_EVERYONE'), + loggedIn: Joi.string() + .valid(...Object.values(DefaultAccessPermission)) + .optional() + .default(DefaultAccessPermission.WRITE) + .label('HD_PERMISSION_DEFAULT_LOGGED_IN'), + }, + }, }); +function checkEveryoneConfigIsConsistent(config: NoteConfig): void { + const everyoneDefaultSet = + process.env.HD_PERMISSION_DEFAULT_EVERYONE !== undefined; + if (config.guestAccess === GuestAccess.DENY && everyoneDefaultSet) { + throw new Error( + `'HD_GUEST_ACCESS' is set to '${config.guestAccess}', but 'HD_PERMISSION_DEFAULT_EVERYONE' is also configured. Please remove 'HD_PERMISSION_DEFAULT_EVERYONE'.`, + ); + } +} + +function checkLoggedInUsersHaveHigherDefaultPermissionsThanGuests( + config: NoteConfig, +): void { + const everyone = config.permissions.default.everyone; + const loggedIn = config.permissions.default.loggedIn; + if ( + getDefaultAccessPermissionOrdinal(everyone) > + getDefaultAccessPermissionOrdinal(loggedIn) + ) { + throw new Error( + `'HD_PERMISSION_DEFAULT_EVERYONE' is set to '${everyone}', but 'HD_PERMISSION_DEFAULT_LOGGED_IN' is set to '${loggedIn}'. This gives everyone greater permissions than logged-in users which is not allowed.`, + ); + } +} + export default registerAs('noteConfig', () => { const noteConfig = schema.validate( { @@ -34,7 +90,14 @@ export default registerAs('noteConfig', () => { maxDocumentLength: parseOptionalNumber( process.env.HD_MAX_DOCUMENT_LENGTH, ), - }, + guestAccess: process.env.HD_GUEST_ACCESS, + permissions: { + default: { + everyone: process.env.HD_PERMISSION_DEFAULT_EVERYONE, + loggedIn: process.env.HD_PERMISSION_DEFAULT_LOGGED_IN, + }, + }, + } as NoteConfig, { abortEarly: false, presence: 'required', @@ -46,5 +109,8 @@ export default registerAs('noteConfig', () => { ); throw new Error(buildErrorMessage(errorMessages)); } - return noteConfig.value as NoteConfig; + const config = noteConfig.value; + checkEveryoneConfigIsConsistent(config); + checkLoggedInUsersHaveHigherDefaultPermissionsThanGuests(config); + return config; }); diff --git a/src/frontend-config/frontend-config.service.spec.ts b/src/frontend-config/frontend-config.service.spec.ts index a7826f3b6..1b3844120 100644 --- a/src/frontend-config/frontend-config.service.spec.ts +++ b/src/frontend-config/frontend-config.service.spec.ts @@ -10,6 +10,7 @@ import { URL } from 'url'; import { AppConfig } from '../config/app.config'; import { AuthConfig } from '../config/auth.config'; import { CustomizationConfig } from '../config/customization.config'; +import { DefaultAccessPermission } from '../config/default-access-permission.enum'; import { ExternalServicesConfig } from '../config/external-services.config'; import { GitlabScope, GitlabVersion } from '../config/gitlab.enum'; import { Loglevel } from '../config/loglevel.enum'; @@ -191,7 +192,14 @@ describe('FrontendConfigService', () => { return { forbiddenNoteIds: [], maxDocumentLength: 200, - }; + guestAccess: true, + permissions: { + default: { + everyone: DefaultAccessPermission.READ, + loggedIn: DefaultAccessPermission.WRITE, + }, + }, + } as NoteConfig; }), ], }), @@ -350,6 +358,13 @@ describe('FrontendConfigService', () => { const noteConfig: NoteConfig = { forbiddenNoteIds: [], maxDocumentLength: maxDocumentLength, + guestAccess: true, + permissions: { + default: { + everyone: DefaultAccessPermission.READ, + loggedIn: DefaultAccessPermission.WRITE, + }, + }, }; const module: TestingModule = await Test.createTestingModule({ imports: [ @@ -377,8 +392,7 @@ describe('FrontendConfigService', () => { const service = module.get(FrontendConfigService); const config = await service.getFrontendConfig(); expect(config.allowRegister).toEqual(enableRegister); - - expect(config.allowAnonymous).toEqual(false); + expect(config.allowAnonymous).toEqual(noteConfig.guestAccess); expect(config.branding.name).toEqual(customName); expect(config.branding.logo).toEqual( customLogo ? new URL(customLogo) : undefined, diff --git a/src/frontend-config/frontend-config.service.ts b/src/frontend-config/frontend-config.service.ts index 9f86cfc4b..fe815b8fe 100644 --- a/src/frontend-config/frontend-config.service.ts +++ b/src/frontend-config/frontend-config.service.ts @@ -46,8 +46,7 @@ export class FrontendConfigService { async getFrontendConfig(): Promise { return { - // ToDo: use actual value here - allowAnonymous: false, + allowAnonymous: this.noteConfig.guestAccess, allowRegister: this.authConfig.local.enableRegister, authProviders: this.getAuthProviders(), branding: this.getBranding(), diff --git a/src/notes/notes.service.ts b/src/notes/notes.service.ts index 4d89e93b2..6903f80bb 100644 --- a/src/notes/notes.service.ts +++ b/src/notes/notes.service.ts @@ -96,6 +96,26 @@ export class NotesService { HistoryEntry.create(owner, newNote as Note) as HistoryEntry, ]); } + + const everyonePermission = this.createGroupPermission( + newNote as Note, + await this.groupsService.getEveryoneGroup(), + owner === null + ? DefaultAccessPermission.WRITE + : this.noteConfig.permissions.default.everyone, + ); + + const loggedInPermission = this.createGroupPermission( + newNote as Note, + await this.groupsService.getLoggedInGroup(), + this.noteConfig.permissions.default.loggedIn, + ); + + newNote.groupPermissions = Promise.resolve([ + ...Optional.ofNullable(everyonePermission).wrapInArray(), + ...Optional.ofNullable(loggedInPermission).wrapInArray(), + ]); + try { return await this.noteRepository.save(newNote); } catch (e) { @@ -113,6 +133,20 @@ export class NotesService { } } + private createGroupPermission( + note: Note, + group: Group, + permission: DefaultAccessPermission, + ): NoteGroupPermission | null { + return permission === DefaultAccessPermission.NONE + ? null + : NoteGroupPermission.create( + group, + note, + permission === DefaultAccessPermission.WRITE, + ); + } + /** * @async * Get the current content of the note.