From d1357defc43389ed59ae095f97dd8f2b9ef88564 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 17 Jul 2026 09:19:30 -0400 Subject: [PATCH] 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. --- docs/PLUGIN-LAB-PRINTEDPARTS.md | 14 ++++++++++++++ frontend/src/router/index.js | 4 +++- frontend/src/router/routes/printedparts.js | 4 ++-- plugins/printedparts/api/routes.py | 18 ++++++++++++------ tests/test_plugins/test_printedparts_ledger.py | 18 +++++++++++++++--- 5 files changed, 46 insertions(+), 12 deletions(-) diff --git a/docs/PLUGIN-LAB-PRINTEDPARTS.md b/docs/PLUGIN-LAB-PRINTEDPARTS.md index d6e868b..2922d6b 100644 --- a/docs/PLUGIN-LAB-PRINTEDPARTS.md +++ b/docs/PLUGIN-LAB-PRINTEDPARTS.md @@ -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 `` 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: diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index fadf850..2405699 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -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: '/', diff --git a/frontend/src/router/routes/printedparts.js b/frontend/src/router/routes/printedparts.js index f769ad3..8b19b25 100644 --- a/frontend/src/router/routes/printedparts.js +++ b/frontend/src/router/routes/printedparts.js @@ -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', diff --git a/plugins/printedparts/api/routes.py b/plugins/printedparts/api/routes.py index 4c07931..18c3072 100644 --- a/plugins/printedparts/api/routes.py +++ b/plugins/printedparts/api/routes.py @@ -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 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/', 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//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) diff --git a/tests/test_plugins/test_printedparts_ledger.py b/tests/test_plugins/test_printedparts_ledger.py index e0c06ac..48f3aa3 100644 --- a/tests/test_plugins/test_printedparts_ledger.py +++ b/tests/test_plugins/test_printedparts_ledger.py @@ -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']