From 6b12e0efc3aedd94cd35c494652a58620481ab94 Mon Sep 17 00:00:00 2001 From: Alvaro GJ Date: Thu, 29 Jan 2026 20:47:03 +0100 Subject: [PATCH] feat(permissions): add write:tags access control for tag edits --- .../admin/admin-groups-edit-permissions.vue | 7 +++++ .../admin/admin-groups-edit-rules.vue | 1 + .../editor/editor-modal-properties.vue | 11 +++++++- client/store/page.js | 3 +++ server/core/auth.js | 3 +++ server/helpers/error.js | 4 +++ server/models/pages.js | 27 ++++++++++++++++++- 7 files changed, 54 insertions(+), 2 deletions(-) diff --git a/client/components/admin/admin-groups-edit-permissions.vue b/client/components/admin/admin-groups-edit-permissions.vue index 91b1b684..a29506fc 100644 --- a/client/components/admin/admin-groups-edit-permissions.vue +++ b/client/components/admin/admin-groups-edit-permissions.vue @@ -58,6 +58,13 @@ export default { restrictedForSystem: true, disabled: false }, + { + permission: 'write:tags', + hint: 'Can add, edit and remove page tags, as specified in the Page Rules', + warning: false, + restrictedForSystem: true, + disabled: false + }, { permission: 'manage:pages', hint: 'Can move existing pages as specified in the Page Rules', diff --git a/client/components/admin/admin-groups-edit-rules.vue b/client/components/admin/admin-groups-edit-rules.vue index f52d9265..aa08cbce 100644 --- a/client/components/admin/admin-groups-edit-rules.vue +++ b/client/components/admin/admin-groups-edit-rules.vue @@ -215,6 +215,7 @@ export default { roles: [ { text: 'Read Pages', value: 'read:pages', icon: 'mdi-file-eye-outline' }, { text: 'Create + Edit Pages', value: 'write:pages', icon: 'mdi-file-plus-outline' }, + { text: 'Manage Tags', value: 'write:tags', icon: 'mdi-tag-text-outline' }, { text: 'Rename / Move Pages', value: 'manage:pages', icon: 'mdi-file-document-edit-outline' }, { text: 'Delete Pages', value: 'delete:pages', icon: 'mdi-file-remove-outline' }, { text: 'View Pages Source', value: 'read:source', icon: 'mdi-code-tags' }, diff --git a/client/components/editor/editor-modal-properties.vue b/client/components/editor/editor-modal-properties.vue index a6ed1af3..cf9c3960 100644 --- a/client/components/editor/editor-modal-properties.vue +++ b/client/components/editor/editor-modal-properties.vue @@ -73,7 +73,7 @@ v-chip( v-for='tag of tags' :key='`tag-` + tag' - close + :close='hasTagsPermission' label color='teal' text-color='teal lighten-5' @@ -84,6 +84,7 @@ outlined v-model='newTag' :hint='$t(`editor:props.tagsHint`)' + :disabled='!hasTagsPermission' :items='newTagSuggestions' :loading='$apollo.queries.newTagSuggestions.loading' persistent-hint @@ -301,6 +302,7 @@ export default { scriptCss: sync('page/scriptCss'), hasScriptPermission: get('page/effectivePermissions@pages.script'), hasStylePermission: get('page/effectivePermissions@pages.style'), + hasTagsPermission: get('page/effectivePermissions@tags.write'), pageSelectorMode () { return (this.mode === 'create') ? 'create' : 'move' } @@ -314,6 +316,10 @@ export default { } }, newTag (newValue, oldValue) { + if (!this.hasTagsPermission) { + this.newTag = null + return + } const tagClean = _.trim(newValue || '').toLowerCase() if (tagClean && tagClean.length > 0) { if (!_.includes(this.tags, tagClean)) { @@ -345,6 +351,9 @@ export default { }, methods: { removeTag (tag) { + if (!this.hasTagsPermission) { + return + } this.tags = _.without(this.tags, tag) }, close() { diff --git a/client/store/page.js b/client/store/page.js index 92979647..247f74e7 100644 --- a/client/store/page.js +++ b/client/store/page.js @@ -37,6 +37,9 @@ const state = { script: false, style: false }, + tags: { + write: false + }, system: { manage: false } diff --git a/server/core/auth.js b/server/core/auth.js index fb30c970..36e9b055 100644 --- a/server/core/auth.js +++ b/server/core/auth.js @@ -471,6 +471,9 @@ module.exports = { script: WIKI.auth.checkAccess(req.user, ['write:scripts'], page), style: WIKI.auth.checkAccess(req.user, ['write:styles'], page) }, + tags: { + write: WIKI.auth.checkAccess(req.user, ['write:tags'], page) + }, system: { manage: WIKI.auth.checkAccess(req.user, ['manage:system'], page) } diff --git a/server/helpers/error.js b/server/helpers/error.js index f9a81779..d5b51574 100644 --- a/server/helpers/error.js +++ b/server/helpers/error.js @@ -193,6 +193,10 @@ module.exports = { message: 'You are not authorized to restore this page version.', code: 6011 }), + PageTagsUpdateForbidden: CustomError('PageTagsUpdateForbidden', { + message: 'You are not authorized to modify tags on this page.', + code: 6014 + }), PageUpdateForbidden: CustomError('PageUpdateForbidden', { message: 'You are not authorized to update this page.', code: 6009 diff --git a/server/models/pages.js b/server/models/pages.js index bb5b6585..9d5981fd 100644 --- a/server/models/pages.js +++ b/server/models/pages.js @@ -326,6 +326,13 @@ module.exports = class Page extends Model { // -> Save Tags if (opts.tags && opts.tags.length > 0) { + if (!WIKI.auth.checkAccess(opts.user, ['write:tags'], { + locale: opts.locale, + path: opts.path, + tags: [] + })) { + throw new WIKI.Error.PageTagsUpdateForbidden() + } await WIKI.models.tags.associateTags({ tags: opts.tags, page }) } @@ -387,6 +394,24 @@ module.exports = class Page extends Model { throw new WIKI.Error.PageEmptyContent() } + // -> Check for tag updates + const currentTagModels = await ogPage.$relatedQuery('tags').select('tag') + const currentTags = currentTagModels.map(tag => tag.tag) + const requestedTags = Array.isArray(opts.tags) ? opts.tags : currentTags + const normalizeTags = tags => _.uniq(tags + .map(tag => _.trim(tag).toLowerCase()) + .filter(tag => tag.length > 0)) + .sort() + if (!WIKI.auth.checkAccess(opts.user, ['write:tags'], { + locale: ogPage.localeCode, + path: ogPage.path, + tags: currentTagModels + })) { + if (!_.isEqual(normalizeTags(currentTags), normalizeTags(requestedTags))) { + throw new WIKI.Error.PageTagsUpdateForbidden() + } + } + // -> Create version snapshot await WIKI.models.pageHistory.addVersion({ ...ogPage, @@ -440,7 +465,7 @@ module.exports = class Page extends Model { let page = await WIKI.models.pages.getPageFromDb(ogPage.id) // -> Save Tags - await WIKI.models.tags.associateTags({ tags: opts.tags, page }) + await WIKI.models.tags.associateTags({ tags: requestedTags, page }) // -> Render page to HTML await WIKI.models.pages.renderPage(page)