printedparts: catalog access is printedparts.view-gated
Browsing the catalog (item list, detail, file listings) now requires authentication plus the view permission, and the /printedparts pages and the label print page require login. Still deliberately open: the kiosk endpoints per the decision record, the image serve and file download (img tags and anchor downloads cannot carry a JWT), and the reports (product-wide jwt-optional convention). Grant printedparts.view to the roles that should see the catalog.
This commit is contained in:
@@ -371,6 +371,20 @@ Two more field requests, and the plugin's FIRST incremental migration:
|
||||
ACTIVE member of each selected role (role.users backref), deduped with
|
||||
the user picks and free-text; settings page gains a role picker.
|
||||
|
||||
## Stage 16a (extension) - view permission on the catalog
|
||||
|
||||
The catalog started with open reads (the product's jwt-optional list
|
||||
convention). Field decision: browsing and managing the parts catalog is
|
||||
staff-only, so the reads (list, detail, file listings) move behind
|
||||
`@jwt_required()` + `require_permission('printedparts.view')`, the
|
||||
`/printedparts` routes and the label print page gain `requiresAuth`, and
|
||||
the view permission becomes meaningful in the role grid.
|
||||
Deliberately still open: the kiosk endpoints (decision record), the image
|
||||
serve and file download (fetched by `<img>` tags and anchor clicks, which
|
||||
cannot carry a JWT header), and the reports (product-wide jwt-optional
|
||||
convention). Grant `printedparts.view` to the roles that should see the
|
||||
catalog - admins bypass as always.
|
||||
|
||||
## Stage 16 (extension) - kiosk touch fixes from first hands-on use
|
||||
|
||||
First real touchscreen session found two problems worth their own stage:
|
||||
|
||||
@@ -118,10 +118,12 @@ const routes = [
|
||||
meta: { plugin: 'usb' }
|
||||
},
|
||||
{
|
||||
// Unlike the other print pages this one requires login: it lists the
|
||||
// whole catalog, which is printedparts.view-gated at the API.
|
||||
path: '/print/printedparts-labels',
|
||||
name: 'print-printedparts-labels',
|
||||
component: () => import('../views/print/PrintedPartsLabels.vue'),
|
||||
meta: { plugin: 'printedparts' }
|
||||
meta: { requiresAuth: true, plugin: 'printedparts' }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
|
||||
@@ -12,7 +12,7 @@ export default [
|
||||
path: 'printedparts',
|
||||
name: 'printedparts',
|
||||
component: () => import('../../views/printedparts/PrintedItemsList.vue'),
|
||||
meta: { plugin: 'printedparts' }
|
||||
meta: { requiresAuth: true, plugin: 'printedparts' }
|
||||
},
|
||||
{
|
||||
path: 'printedparts/new',
|
||||
@@ -24,7 +24,7 @@ export default [
|
||||
path: 'printedparts/:id',
|
||||
name: 'printedparts-detail',
|
||||
component: () => import('../../views/printedparts/PrintedItemDetail.vue'),
|
||||
meta: { plugin: 'printedparts' }
|
||||
meta: { requiresAuth: true, plugin: 'printedparts' }
|
||||
},
|
||||
{
|
||||
path: 'printedparts/:id/edit',
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""Printedparts plugin API routes.
|
||||
|
||||
Reads are open (jwt optional) like every list surface; mutations arrive in
|
||||
later stages with permission gates. The kiosk endpoints (unauthenticated by
|
||||
explicit decision - see the proposal) also land later.
|
||||
Access model: browsing the catalog (items, detail, file listings) requires
|
||||
the printedparts.view permission; every mutation carries its own permission.
|
||||
Deliberately open: the kiosk endpoints (decision record in the proposal),
|
||||
the image serve and file download (fetched by <img> tags and anchor
|
||||
downloads, which cannot carry a JWT header), and the reports (jwt-optional
|
||||
like every other report in the product).
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request
|
||||
@@ -26,7 +29,8 @@ printedparts_bp = Blueprint('printedparts', __name__)
|
||||
|
||||
|
||||
@printedparts_bp.route('/items', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
@jwt_required()
|
||||
@require_permission('printedparts.view')
|
||||
def list_items():
|
||||
"""List printed items, paginated; search + low-stock filter."""
|
||||
page, per_page = get_pagination_params(request)
|
||||
@@ -51,7 +55,8 @@ def list_items():
|
||||
|
||||
|
||||
@printedparts_bp.route('/items/<int:item_id>', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
@jwt_required()
|
||||
@require_permission('printedparts.view')
|
||||
def get_item(item_id: int):
|
||||
"""Get one printed item with its recent transactions."""
|
||||
item = db.session.get(PrintedItem, item_id)
|
||||
@@ -578,7 +583,8 @@ def _uploader_name():
|
||||
|
||||
|
||||
@printedparts_bp.route('/items/<int:item_id>/files', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
@jwt_required()
|
||||
@require_permission('printedparts.view')
|
||||
def list_item_files(item_id: int):
|
||||
"""Revision history, newest first."""
|
||||
files = (PrintedItemFile.query.filter_by(printeditemid=item_id)
|
||||
|
||||
@@ -121,6 +121,15 @@ def test_anonymous_cannot_mutate(client, item):
|
||||
json={'quantity': 1, 'badge': '1'}).status_code == 401
|
||||
|
||||
|
||||
def test_catalog_reads_require_view_permission(client, member_headers, item):
|
||||
"""Browsing the catalog is printedparts.view-gated; the kiosk stays open."""
|
||||
assert client.get('/api/printedparts/items').status_code == 401
|
||||
assert client.get(f'/api/printedparts/items/{item}').status_code == 401
|
||||
assert client.get('/api/printedparts/items',
|
||||
headers=member_headers).status_code == 403
|
||||
assert client.get('/api/printedparts/kiosk/item/3DP-9001').status_code == 200
|
||||
|
||||
|
||||
def test_member_without_permission_gets_403(client, member_headers, item):
|
||||
"""Authentication alone is not authorization: a role-less user is denied."""
|
||||
assert client.post('/api/printedparts/items', json={'itemname': 'X'},
|
||||
@@ -248,12 +257,14 @@ def test_retire_hides_and_restore_returns(client, auth_headers, item):
|
||||
assert client.delete(f'/api/printedparts/items/{item}',
|
||||
headers=auth_headers).status_code == 200
|
||||
|
||||
listed = client.get('/api/printedparts/items').get_json()['data']
|
||||
listed = client.get('/api/printedparts/items',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert all(row['printeditemid'] != item for row in listed)
|
||||
kiosk = client.get('/api/printedparts/kiosk/item/3DP-9001')
|
||||
assert kiosk.status_code == 404
|
||||
|
||||
including = client.get('/api/printedparts/items?active=false')
|
||||
including = client.get('/api/printedparts/items?active=false',
|
||||
headers=auth_headers)
|
||||
assert any(row['printeditemid'] == item
|
||||
for row in including.get_json()['data'])
|
||||
|
||||
@@ -286,7 +297,8 @@ def test_file_revisions_append_and_download(client, auth_headers, item, tmp_path
|
||||
content_type='multipart/form-data')
|
||||
assert bad.status_code == 400
|
||||
|
||||
listing = client.get(f'/api/printedparts/items/{item}/files').get_json()['data']
|
||||
listing = client.get(f'/api/printedparts/items/{item}/files',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert [f['revision'] for f in listing] == [2, 1]
|
||||
|
||||
fileid = listing[1]['fileid']
|
||||
|
||||
Reference in New Issue
Block a user