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
|
ACTIVE member of each selected role (role.users backref), deduped with
|
||||||
the user picks and free-text; settings page gains a role picker.
|
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
|
## Stage 16 (extension) - kiosk touch fixes from first hands-on use
|
||||||
|
|
||||||
First real touchscreen session found two problems worth their own stage:
|
First real touchscreen session found two problems worth their own stage:
|
||||||
|
|||||||
@@ -118,10 +118,12 @@ const routes = [
|
|||||||
meta: { plugin: 'usb' }
|
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',
|
path: '/print/printedparts-labels',
|
||||||
name: 'print-printedparts-labels',
|
name: 'print-printedparts-labels',
|
||||||
component: () => import('../views/print/PrintedPartsLabels.vue'),
|
component: () => import('../views/print/PrintedPartsLabels.vue'),
|
||||||
meta: { plugin: 'printedparts' }
|
meta: { requiresAuth: true, plugin: 'printedparts' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default [
|
|||||||
path: 'printedparts',
|
path: 'printedparts',
|
||||||
name: 'printedparts',
|
name: 'printedparts',
|
||||||
component: () => import('../../views/printedparts/PrintedItemsList.vue'),
|
component: () => import('../../views/printedparts/PrintedItemsList.vue'),
|
||||||
meta: { plugin: 'printedparts' }
|
meta: { requiresAuth: true, plugin: 'printedparts' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'printedparts/new',
|
path: 'printedparts/new',
|
||||||
@@ -24,7 +24,7 @@ export default [
|
|||||||
path: 'printedparts/:id',
|
path: 'printedparts/:id',
|
||||||
name: 'printedparts-detail',
|
name: 'printedparts-detail',
|
||||||
component: () => import('../../views/printedparts/PrintedItemDetail.vue'),
|
component: () => import('../../views/printedparts/PrintedItemDetail.vue'),
|
||||||
meta: { plugin: 'printedparts' }
|
meta: { requiresAuth: true, plugin: 'printedparts' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'printedparts/:id/edit',
|
path: 'printedparts/:id/edit',
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"""Printedparts plugin API routes.
|
"""Printedparts plugin API routes.
|
||||||
|
|
||||||
Reads are open (jwt optional) like every list surface; mutations arrive in
|
Access model: browsing the catalog (items, detail, file listings) requires
|
||||||
later stages with permission gates. The kiosk endpoints (unauthenticated by
|
the printedparts.view permission; every mutation carries its own permission.
|
||||||
explicit decision - see the proposal) also land later.
|
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
|
from flask import Blueprint, request
|
||||||
@@ -26,7 +29,8 @@ printedparts_bp = Blueprint('printedparts', __name__)
|
|||||||
|
|
||||||
|
|
||||||
@printedparts_bp.route('/items', methods=['GET'])
|
@printedparts_bp.route('/items', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@jwt_required()
|
||||||
|
@require_permission('printedparts.view')
|
||||||
def list_items():
|
def list_items():
|
||||||
"""List printed items, paginated; search + low-stock filter."""
|
"""List printed items, paginated; search + low-stock filter."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -51,7 +55,8 @@ def list_items():
|
|||||||
|
|
||||||
|
|
||||||
@printedparts_bp.route('/items/<int:item_id>', methods=['GET'])
|
@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):
|
def get_item(item_id: int):
|
||||||
"""Get one printed item with its recent transactions."""
|
"""Get one printed item with its recent transactions."""
|
||||||
item = db.session.get(PrintedItem, item_id)
|
item = db.session.get(PrintedItem, item_id)
|
||||||
@@ -578,7 +583,8 @@ def _uploader_name():
|
|||||||
|
|
||||||
|
|
||||||
@printedparts_bp.route('/items/<int:item_id>/files', methods=['GET'])
|
@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):
|
def list_item_files(item_id: int):
|
||||||
"""Revision history, newest first."""
|
"""Revision history, newest first."""
|
||||||
files = (PrintedItemFile.query.filter_by(printeditemid=item_id)
|
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
|
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):
|
def test_member_without_permission_gets_403(client, member_headers, item):
|
||||||
"""Authentication alone is not authorization: a role-less user is denied."""
|
"""Authentication alone is not authorization: a role-less user is denied."""
|
||||||
assert client.post('/api/printedparts/items', json={'itemname': 'X'},
|
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}',
|
assert client.delete(f'/api/printedparts/items/{item}',
|
||||||
headers=auth_headers).status_code == 200
|
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)
|
assert all(row['printeditemid'] != item for row in listed)
|
||||||
kiosk = client.get('/api/printedparts/kiosk/item/3DP-9001')
|
kiosk = client.get('/api/printedparts/kiosk/item/3DP-9001')
|
||||||
assert kiosk.status_code == 404
|
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
|
assert any(row['printeditemid'] == item
|
||||||
for row in including.get_json()['data'])
|
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')
|
content_type='multipart/form-data')
|
||||||
assert bad.status_code == 400
|
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]
|
assert [f['revision'] for f in listing] == [2, 1]
|
||||||
|
|
||||||
fileid = listing[1]['fileid']
|
fileid = listing[1]['fileid']
|
||||||
|
|||||||
Reference in New Issue
Block a user