slides: gate management on slides.manage permission (grantable to non-admin curator)
The lobby-display and screensaver slide manager was admin-only. Add a shared slides.manage permission so a curator can manage both surfaces without full admin. Admins keep access via the require_permission admin bypass. Backend: - plugins/slides/api/routes.py: all 5 management routes require slides.manage - plugins/slides/plugin.py: declare it via get_permissions(); nav item carries the permission so the frontend can gate visibility - shopdb/core/api/auth.py: login response now returns the user's permissions (matches /me) so the frontend authStore has them on fresh login Frontend: - stores/auth.js: hasPermission(name) getter (admin true, else granted list) - router/index.js: guard supports requiresPermission - views/AppLayout.vue: hide nav items whose permission the user lacks - plugins/slides/frontend/routes.js: slide manager gated requiresPermission Tests: no-perm user 403, curator role with the perm 200 (+ login advertises it), admin 200 via bypass. Deploy: run `flask seed permissions` to create the row, then grant it to a role in Settings > Users & Roles.
This commit is contained in:
@@ -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('/')
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
Image files live on disk at instance/slides/<surface>/; 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('/<surface>', 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('/<surface>/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('/<surface>/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('/<surface>/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('/<surface>/<int:slideid>', 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')
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user