diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 8bcb0a9..2040ca3 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -167,6 +167,11 @@ router.beforeEach(async (to, from, next) => { if (to.meta.requiresAdmin && !authStore.isAdmin) { return next('/') } + // A route may gate on a named permission instead of full admin (e.g. the + // slide manager -> slides.manage). Admins pass via hasPermission. + if (to.meta.requiresPermission && !authStore.hasPermission(to.meta.requiresPermission)) { + return next('/') + } if (to.meta.guest && authStore.isAuthenticated) { return next('/') } diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index 69a54a7..2f0a96d 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -13,6 +13,13 @@ export const useAuthStore = defineStore('auth', { roles: (state) => state.user?.roles || [], hasRole: (state) => (role) => state.user?.roles?.includes(role) || false, isAdmin: (state) => state.user?.roles?.includes('admin') || false, + // True if the user holds a named permission. Admins hold every permission + // (mirrors the backend require_permission admin bypass), so they pass + // regardless of the permissions list. Non-admins check their granted list. + hasPermission: (state) => (name) => + state.user?.roles?.includes('admin') + || state.user?.permissions?.includes(name) + || false, // True when an admin-set temporary password must be changed before use. mustChangePassword: (state) => !!state.user?.mustchangepassword, // Full name from the employee directory (falls back to username/SSO). diff --git a/frontend/src/views/AppLayout.vue b/frontend/src/views/AppLayout.vue index 05a0021..6ae28bc 100644 --- a/frontend/src/views/AppLayout.vue +++ b/frontend/src/views/AppLayout.vue @@ -185,8 +185,14 @@ const defaultNav = [ ] function buildNavItems(items) { + // Drop items the user cannot reach: a nav item may carry a `permission` + // (e.g. Slides -> slides.manage); hide it from anyone who lacks it so the + // link does not dead-end at the router guard. Admins hold every permission. + const visible = items.filter(item => + !item.permission || authStore.hasPermission(item.permission)) + // Sort by position - const sorted = [...items].sort((a, b) => (a.position || 99) - (b.position || 99)) + const sorted = [...visible].sort((a, b) => (a.position || 99) - (b.position || 99)) // Assign section headers based on position ranges const result = [] diff --git a/plugins/slides/api/routes.py b/plugins/slides/api/routes.py index d61ef70..40c5ccd 100644 --- a/plugins/slides/api/routes.py +++ b/plugins/slides/api/routes.py @@ -2,7 +2,8 @@ Image files live on disk at instance/slides//; TvSlide rows hold play order + per-slide seconds. Feed + image routes are PUBLIC (kiosks/screensaver -have no auth); management routes are admin-only. +have no auth); management routes require the slides.manage permission (admins +have it by default). """ import os @@ -12,7 +13,8 @@ from flask import Blueprint, request, current_app, jsonify, send_from_directory from flask_jwt_extended import jwt_required from werkzeug.utils import secure_filename -from shopdb.api import db, success_response, error_response, ErrorCodes, require_role +from shopdb.api import (db, success_response, error_response, ErrorCodes, + require_permission) from ..models import TvSlide @@ -88,7 +90,7 @@ def serve_image(surface, filename): @slides_bp.route('/', methods=['GET']) @jwt_required() -@require_role('admin') +@require_permission('slides.manage') def list_slides(surface): if not _valid_surface(surface): return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface') @@ -106,7 +108,7 @@ def list_slides(surface): @slides_bp.route('//upload', methods=['POST']) @jwt_required() -@require_role('admin') +@require_permission('slides.manage') def upload_slides(surface): if not _valid_surface(surface): return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface') @@ -151,7 +153,7 @@ def upload_slides(surface): @slides_bp.route('//order', methods=['POST']) @jwt_required() -@require_role('admin') +@require_permission('slides.manage') def reorder_slides(surface): if not _valid_surface(surface): return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface') @@ -167,7 +169,7 @@ def reorder_slides(surface): @slides_bp.route('//delete', methods=['POST']) @jwt_required() -@require_role('admin') +@require_permission('slides.manage') def delete_slides(surface): if not _valid_surface(surface): return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface') @@ -193,7 +195,7 @@ def delete_slides(surface): @slides_bp.route('//', methods=['PATCH']) @jwt_required() -@require_role('admin') +@require_permission('slides.manage') def update_slide(surface, slideid): if not _valid_surface(surface): return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface') diff --git a/plugins/slides/frontend/routes.js b/plugins/slides/frontend/routes.js index 2c1d031..1fcc22e 100644 --- a/plugins/slides/frontend/routes.js +++ b/plugins/slides/frontend/routes.js @@ -4,7 +4,7 @@ export default [ path: 'settings/slides', name: 'slide-manager', component: () => import('./views/SlideManager.vue'), - meta: { requiresAuth: true, requiresAdmin: true, plugin: 'slides' } + meta: { requiresAuth: true, requiresPermission: 'slides.manage', plugin: 'slides' } } ] diff --git a/plugins/slides/plugin.py b/plugins/slides/plugin.py index 41442f6..5a59200 100644 --- a/plugins/slides/plugin.py +++ b/plugins/slides/plugin.py @@ -58,14 +58,25 @@ class SlidesPlugin(BasePlugin): """Slide playlist metadata (image files live on disk).""" return [TvSlide] + def get_permissions(self) -> List: + """RBAC permission for curating slides (both surfaces). Grantable to a + non-admin role so a curator can manage the lobby TV + screensaver + without full admin. Admins hold it by default.""" + return [ + ('slides.manage', + 'Manage lobby display and screensaver slides', 'slides'), + ] + def get_navigation_items(self) -> List[Dict]: - """Sidebar entry for the slide manager (admin).""" + """Sidebar entry for the slide manager. Gated on slides.manage so only a + curator (or admin, who holds it by default) sees the link.""" return [ { 'name': 'Slides', 'icon': 'image', 'route': '/settings/slides', 'position': 7, + 'permission': 'slides.manage', }, ] diff --git a/shopdb/core/api/auth.py b/shopdb/core/api/auth.py index f3d3657..977e26e 100644 --- a/shopdb/core/api/auth.py +++ b/shopdb/core/api/auth.py @@ -152,6 +152,7 @@ def login(): 'firstname': user.firstname, 'lastname': user.lastname, 'roles': [r.rolename for r in user.roles], + 'permissions': user.getpermissions(), 'mustchangepassword': bool(user.mustchangepassword) } }) diff --git a/tests/test_core/test_slides.py b/tests/test_core/test_slides.py index 1a6a04b..e73235b 100644 --- a/tests/test_core/test_slides.py +++ b/tests/test_core/test_slides.py @@ -23,3 +23,67 @@ def test_slides_feed_unknown_surface_falls_back(client, db): resp = client.get('/api/slides/feed?surface=../etc') assert resp.status_code == 200 assert resp.get_json()['surface'] == 'lobby' + + +# --------------------------------------------------------------------------- +# Management routes are gated on the slides.manage permission (both surfaces). +# A curator role holding it works without full admin; auth alone does not. +# --------------------------------------------------------------------------- + +def _headers_for(client, username, password): + resp = client.post('/api/auth/login', + json={'username': username, 'password': password}) + assert resp.status_code == 200, f'Login failed: {resp.get_json()}' + return {'Authorization': f"Bearer {resp.get_json()['data']['access_token']}"} + + +def test_slides_manage_requires_permission(client, db): + """Authenticated but permission-less user cannot list slides (403).""" + from werkzeug.security import generate_password_hash + from shopdb.core.models import User + + user = User(username='plainuser', email='plain@test.local', + passwordhash=generate_password_hash('testpass')) + db.session.add(user) + db.session.commit() + + resp = client.get('/api/slides/lobby', + headers=_headers_for(client, 'plainuser', 'testpass')) + assert resp.status_code == 403 + + +def test_slides_manage_curator_allowed(client, db): + """A non-admin role holding slides.manage can reach the management routes.""" + from werkzeug.security import generate_password_hash + from shopdb.core.models import User, Role, Permission + + perm = Permission(name='slides.manage', + description='Manage lobby display and screensaver slides', + category='slides') + role = Role(rolename='slidecurator', description='Slide curator') + role.permissions.append(perm) + db.session.add_all([perm, role]) + db.session.flush() + + user = User(username='curator', email='curator@test.local', + passwordhash=generate_password_hash('testpass')) + user.roles.append(role) + db.session.add(user) + db.session.commit() + + headers = _headers_for(client, 'curator', 'testpass') + + # The login payload advertises the permission to the frontend authStore. + login = client.post('/api/auth/login', + json={'username': 'curator', 'password': 'testpass'}) + assert 'slides.manage' in login.get_json()['data']['user']['permissions'] + + # And the management route is reachable (200, not 403). + resp = client.get('/api/slides/lobby', headers=headers) + assert resp.status_code == 200 + + +def test_slides_manage_admin_allowed(client, db, auth_headers): + """Admin keeps access via the require_permission admin bypass.""" + resp = client.get('/api/slides/lobby', headers=auth_headers) + assert resp.status_code == 200