printedparts: catalog access is printedparts.view-gated
Some checks failed
CI / backend (push) Successful in 1m43s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 8s

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:
cproudlock
2026-07-17 09:19:30 -04:00
parent 96e48e0f50
commit d1357defc4
5 changed files with 46 additions and 12 deletions

View File

@@ -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:

View File

@@ -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: '/',

View File

@@ -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',

View File

@@ -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)

View File

@@ -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']