printedparts stage 6: RBAC - declared permissions gate every mutation
get_permissions declares view/create/edit/delete/restock (seeded on install/enable and by flask seed permissions); every write route adds require_permission on top of jwt_required. New test proves authentication alone is not authorization: a role-less member gets 403 where an admin succeeds.
This commit is contained in:
@@ -17,6 +17,7 @@ from shopdb.api import (
|
|||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
get_pagination_params,
|
get_pagination_params,
|
||||||
paginate_query,
|
paginate_query,
|
||||||
|
require_permission,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..models import PrintedItem
|
from ..models import PrintedItem
|
||||||
@@ -95,6 +96,7 @@ def _mint_itemcode(item):
|
|||||||
|
|
||||||
@printedparts_bp.route('/items', methods=['POST'])
|
@printedparts_bp.route('/items', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@require_permission('printedparts.create')
|
||||||
def create_item():
|
def create_item():
|
||||||
"""Create a printed item; the itemcode is minted from the row id."""
|
"""Create a printed item; the itemcode is minted from the row id."""
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
@@ -124,6 +126,7 @@ def create_item():
|
|||||||
|
|
||||||
@printedparts_bp.route('/items/<int:item_id>', methods=['PUT'])
|
@printedparts_bp.route('/items/<int:item_id>', methods=['PUT'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@require_permission('printedparts.edit')
|
||||||
def update_item(item_id: int):
|
def update_item(item_id: int):
|
||||||
"""Update catalog fields. Quantity moves ONLY through the ledger."""
|
"""Update catalog fields. Quantity moves ONLY through the ledger."""
|
||||||
item = db.session.get(PrintedItem, item_id)
|
item = db.session.get(PrintedItem, item_id)
|
||||||
@@ -144,6 +147,7 @@ def update_item(item_id: int):
|
|||||||
|
|
||||||
@printedparts_bp.route('/items/<int:item_id>', methods=['DELETE'])
|
@printedparts_bp.route('/items/<int:item_id>', methods=['DELETE'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@require_permission('printedparts.delete')
|
||||||
def delete_item(item_id: int):
|
def delete_item(item_id: int):
|
||||||
"""Soft-retire an item; its ledger history stays."""
|
"""Soft-retire an item; its ledger history stays."""
|
||||||
item = db.session.get(PrintedItem, item_id)
|
item = db.session.get(PrintedItem, item_id)
|
||||||
@@ -159,6 +163,7 @@ def delete_item(item_id: int):
|
|||||||
|
|
||||||
@printedparts_bp.route('/items/<int:item_id>/image', methods=['POST'])
|
@printedparts_bp.route('/items/<int:item_id>/image', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@require_permission('printedparts.edit')
|
||||||
def upload_item_image(item_id: int):
|
def upload_item_image(item_id: int):
|
||||||
"""Upload (or replace) the photo for an item (multipart file=<image>)."""
|
"""Upload (or replace) the photo for an item (multipart file=<image>)."""
|
||||||
item = db.session.get(PrintedItem, item_id)
|
item = db.session.get(PrintedItem, item_id)
|
||||||
@@ -195,6 +200,7 @@ def serve_item_image(filename):
|
|||||||
|
|
||||||
@printedparts_bp.route('/items/<int:item_id>/image', methods=['DELETE'])
|
@printedparts_bp.route('/items/<int:item_id>/image', methods=['DELETE'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@require_permission('printedparts.delete')
|
||||||
def delete_item_image(item_id: int):
|
def delete_item_image(item_id: int):
|
||||||
"""Clear an item image; delete the file only if this plugin owns it."""
|
"""Clear an item image; delete the file only if this plugin owns it."""
|
||||||
item = db.session.get(PrintedItem, item_id)
|
item = db.session.get(PrintedItem, item_id)
|
||||||
@@ -238,6 +244,7 @@ def _ledger_write(item, transactiontype, quantitychange, sso, name, reason=None)
|
|||||||
|
|
||||||
@printedparts_bp.route('/items/<int:item_id>/restock', methods=['POST'])
|
@printedparts_bp.route('/items/<int:item_id>/restock', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@require_permission('printedparts.restock')
|
||||||
def restock_item(item_id: int):
|
def restock_item(item_id: int):
|
||||||
"""Add freshly printed stock. Body: {quantity, badge}."""
|
"""Add freshly printed stock. Body: {quantity, badge}."""
|
||||||
item = db.session.get(PrintedItem, item_id)
|
item = db.session.get(PrintedItem, item_id)
|
||||||
@@ -260,6 +267,7 @@ def restock_item(item_id: int):
|
|||||||
|
|
||||||
@printedparts_bp.route('/items/<int:item_id>/adjust', methods=['POST'])
|
@printedparts_bp.route('/items/<int:item_id>/adjust', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@require_permission('printedparts.restock')
|
||||||
def adjust_item(item_id: int):
|
def adjust_item(item_id: int):
|
||||||
"""Correct the count (damage, recount). Body: {quantitychange, reason, badge}."""
|
"""Correct the count (damage, recount). Body: {quantitychange, reason, badge}."""
|
||||||
item = db.session.get(PrintedItem, item_id)
|
item = db.session.get(PrintedItem, item_id)
|
||||||
|
|||||||
@@ -51,6 +51,17 @@ class PrintedpartsPlugin(BasePlugin):
|
|||||||
def init_app(self, app: Flask, db_instance) -> None:
|
def init_app(self, app: Flask, db_instance) -> None:
|
||||||
logger.info(f'Printedparts plugin initialized (v{self.meta.version})')
|
logger.info(f'Printedparts plugin initialized (v{self.meta.version})')
|
||||||
|
|
||||||
|
def get_permissions(self) -> List:
|
||||||
|
"""RBAC permissions this plugin owns (seeded on install/enable)."""
|
||||||
|
return [
|
||||||
|
('printedparts.view', 'View 3D printed parts', 'printedparts'),
|
||||||
|
('printedparts.create', 'Create printed parts', 'printedparts'),
|
||||||
|
('printedparts.edit', 'Edit printed parts', 'printedparts'),
|
||||||
|
('printedparts.delete', 'Retire printed parts', 'printedparts'),
|
||||||
|
('printedparts.restock', 'Restock and adjust stock counts',
|
||||||
|
'printedparts'),
|
||||||
|
]
|
||||||
|
|
||||||
def get_navigation_items(self) -> List[dict]:
|
def get_navigation_items(self) -> List[dict]:
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -119,3 +119,12 @@ def test_anonymous_cannot_mutate(client, item):
|
|||||||
json={'itemname': 'X'}).status_code == 401
|
json={'itemname': 'X'}).status_code == 401
|
||||||
assert client.post(f'/api/printedparts/items/{item}/restock',
|
assert client.post(f'/api/printedparts/items/{item}/restock',
|
||||||
json={'quantity': 1, 'badge': '1'}).status_code == 401
|
json={'quantity': 1, 'badge': '1'}).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
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'},
|
||||||
|
headers=member_headers).status_code == 403
|
||||||
|
assert client.post(f'/api/printedparts/items/{item}/restock',
|
||||||
|
json={'quantity': 1, 'badge': '1'},
|
||||||
|
headers=member_headers).status_code == 403
|
||||||
|
|||||||
Reference in New Issue
Block a user