From a291ac56ca4059ec9ad51cc9c5735d748dd847df Mon Sep 17 00:00:00 2001 From: Daniel Bingham Date: Sun, 6 Oct 2024 14:28:34 -0400 Subject: [PATCH] Issue #257 -- Entity:Action permissions for PaperController. --- database/initialization-scripts/schema.sql | 52 ++++++++ packages/backend/index.js | 1 + .../backend/services/PermissionService.js | 43 +++++++ .../server/controllers/PaperController.js | 117 ++++++++++-------- 4 files changed, 158 insertions(+), 55 deletions(-) create mode 100644 packages/backend/services/PermissionService.js diff --git a/database/initialization-scripts/schema.sql b/database/initialization-scripts/schema.sql index 6d8b94d..9ab0333 100644 --- a/database/initialization-scripts/schema.sql +++ b/database/initialization-scripts/schema.sql @@ -593,3 +593,55 @@ CREATE TABLE response_versions ( PRIMARY KEY(response_id, version) ); +/****************************************************************************** + * Permissions + ******************************************************************************/ + + +CREATE TABLE permissions ( + user_id bigint REFERENCES users(id), + entity varchar(512), + action varchar(512), + + paper_id bigint REFERENCES papers(id) DEFAULT NULL, + paper_version_id uuid REFERENCES paper_versions(id) DEFAULT NULL, + event_id bigint REFERENCES paper_events(id) DEFAULT NULL, + review_id bigint REFERENCES reviews(id) DEFAULT NULL, + paper_comment_id bigint REFERENCES paper_comments(id) DEFAULT NULL, + submission_id bigint REFERENCES journal_submissions(id) DEFAULT NULL, + journal_id bigint REFERENCES journals(id) DEFAULT NULL +); + +CREATE TYPE role_type AS ENUM('public', 'author', 'editor', 'reviewer'); +CREATE TABLE roles ( + id bigserial PRIMARY KEY, + name varchar(1024), + short_description varchar(1024), + type role_type, + is_owner boolean, + + description text, + journal_id bigint REFERENCES journals(id) DEFAULT NULL, + paper_id bigint REFERENCES papers(id) DEFAULT NULL +); +INSERT INTO roles (name, type, description) VALUES ('public', 'public', 'The general public.'); + +CREATE TABLE role_permissions ( + role_id bigint REFERENCES roles(id) DEFAULT NULL, + permission permission_type, + + paper_id bigint REFERENCES papers(id) DEFAULT null, + version int DEFAULT null, + event_id bigint REFERENCES paper_events(id) DEFAULT NULL, + review_id bigint REFERENCES reviews(id) DEFAULT null, + paper_comment_id bigint REFERENCES paper_comments(id) DEFAULT NULL, + submission_id bigint REFERENCES journal_submissions(id) DEFAULT NULL, + journal_id bigint REFERENCES journals(id) DEFAULT NULL +); +INSERT INTO role_permissions (role_id, permission) + SELECT roles.id, 'Papers:create' FROM roles WHERE roles.name = 'public'; + +CREATE TABLE user_roles ( + role_id bigint REFERENCS roles(id) DEFAULT NULL, + user_id bigint REFERENCES users(id) DEFAULT NULL +); diff --git a/packages/backend/index.js b/packages/backend/index.js index b96d848..62f346a 100644 --- a/packages/backend/index.js +++ b/packages/backend/index.js @@ -30,6 +30,7 @@ exports.OpenAlexService = require('./services/OpenAlexService') exports.PageMetadataService = require('./services/PageMetadataService') exports.PaperEventService = require('./services/PaperEventService') exports.PaperService = require('./services/PaperService') +exports.PermissionService = require('./services/PermissionsService') exports.ReputationGenerationService = require('./services/ReputationGenerationService') exports.ReputationPermissionService = require('./services/ReputationPermissionService') exports.S3FileService = require('./services/S3FileService') diff --git a/packages/backend/services/PermissionService.js b/packages/backend/services/PermissionService.js new file mode 100644 index 0000000..d6627f6 --- /dev/null +++ b/packages/backend/services/PermissionService.js @@ -0,0 +1,43 @@ +/****************************************************************************** + * + * JournalHub -- Universal Scholarly Publishing + * Copyright (C) 2022 - 2024 Daniel Bingham + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + ******************************************************************************/ +module.exports = class PermissionService { + constructor(core) { + this.core = core + } + + /** + * Can `user` perform `action` on `entity` identified by `context. + * + * @returns {boolean} True if the `user` can perform `action` on `entity` + * identified by `context`, false otherwise. + */ + async can(user, action, entity, context) { + + } + + async let(user, action, entity, context) { + + } + + async has(user, role, context) { + + } + +} diff --git a/web-application/server/controllers/PaperController.js b/web-application/server/controllers/PaperController.js index 33d4f7d..42da085 100644 --- a/web-application/server/controllers/PaperController.js +++ b/web-application/server/controllers/PaperController.js @@ -45,9 +45,10 @@ module.exports = class PaperController { this.journalSubmissionDAO = new backend.JournalSubmissionDAO(core) this.submissionService = new backend.SubmissionService(core) - this.PaperService = new backend.PaperService(core) + this.paperService = new backend.PaperService(core) this.paperEventService = new backend.PaperEventService(core) this.notificationService = new backend.NotificationService(core) + this.permissionService = new backend.PermissionService(core) } @@ -178,17 +179,17 @@ module.exports = class PaperController { // Preprints the session user can review. if ( query.type == 'preprint') { - visibleIds = await this.PaperService.getPreprints() + visibleIds = await this.paperService.getPreprints() // Retrieves all of a } else if (session.user && query.type == 'drafts' ) { - visibleIds = await this.PaperService.getDrafts(session.user.id) + visibleIds = await this.paperService.getDrafts(session.user.id) } else if ( session.user && query.type == 'private-drafts' ) { - visibleIds = await this.PaperService.getPrivateDrafts(session.user.id) + visibleIds = await this.paperService.getPrivateDrafts(session.user.id) } else if ( session.user && query.type == 'user-submissions' ) { - visibleIds = await this.PaperService.getUserSubmissions(session.user.id) + visibleIds = await this.paperService.getUserSubmissions(session.user.id) } else if (session.user && query.type == 'review-submissions' ) { - visibleIds = await this.PaperService.getVisibleDraftSubmissions(session.user.id) + visibleIds = await this.paperService.getVisibleDraftSubmissions(session.user.id) } else if ( session.user && query.type == 'assigned-review' ) { const assignedResults = await this.database.query(` SELECT journal_submissions.paper_id @@ -491,7 +492,8 @@ module.exports = class PaperController { * Permissions Checking and Input Validation * * 1. User is logged in. - * 2. User is an author and owner of the paper being submitted. + * 2. User must have 'create' permissions on 'paper'. + * 3. User is an author and owner of the paper being submitted. * * Data validation: * @@ -510,7 +512,14 @@ module.exports = class PaperController { const user = request.session.user - // 2. User is an author and owner of the paper being submitted. + // 2. User must have 'create' permissions on 'paper'. + const canCreate = await this.permissionService.can(user, 'create', 'Paper') + if ( ! canCreate ) { + throw new ControllerError(403, 'not-authorized', + `User(${user.id}) attempted to create a paper without permissions.`) + } + + // 3. User is an author and owner of the paper being submitted. if ( ! paper.authors.find((a) => a.userId == user.id && a.owner) ) { throw new ControllerError(403, 'not-authorized:not-owner', `User(${user.id}) submitted a paper with out being an owner of that paper!`) @@ -658,29 +667,30 @@ module.exports = class PaperController { * @returns {Promise} Resolves to void. */ async getPaper(request, response) { - const results = await this.paperDAO.selectPapers('WHERE papers.id=$1', [request.params.id]) - /************************************************************* * Permissions Checking and Input Validation * - * 1. If the paper is a draft, user must be logged in and have review - * privileges on that draft. + * 1. User must have 'view' permissions on 'paper'. + * * * **********************************************************/ + const currentUser = request.session.user - if ( ! results.dictionary[request.params.id] ) { - throw new ControllerError(404, 'not-found', `Paper(${request.params.id}) not found.`) + // 1. User must have 'view' permissions on 'paper'. + const canView = await this.permissionService.can(currentUser, 'view', 'Paper', { paperId: request.params.id }) + if ( ! canView ) { + throw new ControllerError(403, 'not-authorized', + `User attempted to access a Paper they were not authorized to view.`) } - const paper = results.dictionary[request.params.id] - if ( paper.isDraft ) { - if ( ! request.session.user && ! paper.showPreprint ) { - throw new ControllerError(403, 'not-authenticated', `Unauthenticated user attempting to view draft.`) - } - // TODO update visibility permissions + const results = await this.paperDAO.selectPapers('WHERE papers.id=$1', [request.params.id]) + + if ( ! results.dictionary[request.params.id] ) { + throw new ControllerError(404, 'not-found', `Paper(${request.params.id}) not found.`) } + const paper = results.dictionary[request.params.id] /************************************************************ * Permissions Checking Complete @@ -694,18 +704,6 @@ module.exports = class PaperController { }) } - /** - * PUT /paper/:id - * - * Replace an existing paper wholesale with the provided JSON. - * - * NOTE: Intentionally left unimplemented until we have a need for it, or - * have time to decide how to secure it. - */ - async putPaper(request, response) { - throw new ControllerError(501, 'not-implemented', `Attempt to put a paper, when PUT /paper/:id is unimplemented.`) - } - /** * PATCH /paper/:id * @@ -722,21 +720,21 @@ module.exports = class PaperController { * @returns {Promise} Resolves to void. */ async patchPaper(request, response) { - const paper = request.body - // We want to use the params.id over any id in the body. - paper.id = request.params.id - /************************************************************* * Permissions Checking and Input Validation * * 1. User must be logged in. - * 2. Paper(:paper_id) must exist. - * 3. User must be an owning author on Paper(:paper_id). + * 2. User must have 'edit' on Paper(:paperId) + * 3. Paper(:paper_id) must exist. * 4. Paper(:paper_id) must be a draft. * 5. Only title and isDraft may be patched. * * **********************************************************/ + const paper = request.body + // We want to use the params.id over any id in the body. + paper.id = request.params.id + // 1. User must be logged in. if ( ! request.session.user ) { throw new ControllerError(401, 'not-authenticated', `Unauthenticated user attempting to patch paper(${paper.id}).`) @@ -744,24 +742,25 @@ module.exports = class PaperController { const user = request.session.user + // 2. User must have 'edit' on Paper(:paperId) + const canEdit = await this.permissionService.can(user, 'edit', 'Paper', { paperId: paper.id }) + if ( ! canEdit ) { + throw new ControllerError(403, 'not-authorized', + `User(${user.id}) attempted to edit Paper(${paper.id}) without permissions.`) + } + const existingResults = await this.paperDAO.selectPapers('WHERE papers.id=$1', [ paper.id ]) const existing = existingResults.dictionary[paper.id] - // 2. Paper(:paper_id) must exist. + // 3. Paper(:paper_id) must exist. if ( ! existing ) { throw new ControllerError(404, 'not-found', `Attempt to patch a paper(${paper.id}) that doesn't exist!`) } - // 3. User must be an owning author on the Paper(:paper_id) - if ( ! existing.authors.find((a) => a.userId == user.id && a.owner) ) { - throw new ControllerError(403, 'not-authorized:not-owner', - `Non-owner user(${user.id}) attempting to PATCH paper(${paper.id}).`) - } - // 4. Paper(:paper_id) must be a draft. if ( ! existing.isDraft ) { throw new ControllerError(403, `not-authorized:published`, - `User(${user.id}) attempting to PATCH a published paper.`) + `User(${user.id}) attempting to PATCH published Paper(${paper.id}).`) } @@ -831,34 +830,38 @@ module.exports = class PaperController { * @returns {Promise} Resolves to void. */ async deletePaper(request, response) { - const paperId = request.params.id - /************************************************************* * Permissions Checking and Input Validation * * 1. User must be logged in. - * 2. Paper(:paper_id) must exist. - * 3. User must be an owning author on Paper(:paper_id). + * 2. User must have 'delete' on Paper(:paperId) + * 3. Paper(:paper_id) must exist. * 4. Paper(:paper_id) must be a draft. * * **********************************************************/ + const paperId = request.params.id // 1. User must be logged in. if ( ! request.session.user ) { - throw new ControllerError(403, 'not-authorized', `Unauthenticated user attempting to delete paper(${request.params.id}).`) + throw new ControllerError(403, 'not-authorized', `Unauthenticated user attempting to delete paper(${paperId}).`) } const user = request.session.user + + // 2. User must have 'delete' on Paper(:paperId) + const canDelete = await this.permissionService.can(user, 'delete', 'Paper', { paperId: paperId }) + if ( ! canDelete ) { + throw new ControllerError(403, 'not-authorized', + `User(${user.id}) attempted to DELETE Paper(${paperId}) without permissions.`) + } const existingResults = await this.database.query(` - SELECT paper_authors.user_id, paper_authors.owner, papers.is_draft as "isDraft" + SELECT papers.is_draft as "isDraft" FROM papers - JOIN paper_authors on papers.id = paper_authors.paper_id - WHERE papers.id = $1 AND paper_authors.user_id = $2 AND owner = true + WHERE papers.id = $1 `, [ paperId, user.id]) // 2. Paper(:paper_id) must exist. - // 3. User must be an owning author on Paper(:paper_id) if ( existingResults.rows.length <= 0 ) { throw new ControllerError(403, 'not-owner', `Non-owner user(${user.id}) attempting to delete paper(${request.params.id}).`) @@ -908,6 +911,10 @@ module.exports = class PaperController { const user = request.session.user + const visibleSubmissionsResults = await this.database.query(` + SELECT submission_id FROM permissions WHERE entity='Submission' AND action='view' AND paper_id = $1 + `, [ paperId ]) + const paperAuthorResults = await this.database.query(` SELECT paper_authors.user_id FROM papers