guest role; guide section ticking

This commit is contained in:
2026-08-27 13:02:35 -04:00
parent d38a599292
commit 4da6c6aa36
45 changed files with 830 additions and 243 deletions

View File

@@ -86,7 +86,6 @@ def list_apitokens():
@apitokens_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('apitokens.create')
def create_apitoken():
"""Create a token for the caller. Returns the full secret ONCE.
@@ -141,7 +140,6 @@ def create_apitoken():
@apitokens_bp.route('/<int:tokenid>', methods=['PUT'])
@jwt_required()
@require_permission('apitokens.create')
def update_apitoken(tokenid: int):
"""Rename, rescope, or deactivate a token. Own token, or any if admin.
@@ -183,7 +181,6 @@ def update_apitoken(tokenid: int):
@apitokens_bp.route('/<int:tokenid>', methods=['DELETE'])
@jwt_required()
@require_permission('apitokens.create')
def revoke_apitoken(tokenid: int):
"""Revoke (deactivate) a token. Own token, or any if admin."""

View File

@@ -180,7 +180,6 @@ def get_application(app_id: int):
@applications_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('applications.create')
def create_application():
"""Create a new application."""
@@ -224,7 +223,6 @@ def create_application():
@applications_bp.route('/<int:app_id>', methods=['PUT'])
@jwt_required()
@require_permission('applications.edit')
def update_application(app_id: int):
"""Update an application."""
@@ -270,7 +268,6 @@ def update_application(app_id: int):
@applications_bp.route('/<int:app_id>', methods=['DELETE'])
@jwt_required()
@require_permission('applications.delete')
def delete_application(app_id: int):
"""Delete (deactivate) an application."""
@@ -303,7 +300,6 @@ def list_versions(app_id: int):
@applications_bp.route('/<int:app_id>/versions', methods=['POST'])
@jwt_required()
@require_permission('applications.create')
def create_version(app_id: int):
"""Create a new version for an application."""
@@ -397,7 +393,6 @@ def list_machine_applications(machine_id: int):
@applications_bp.route('/machines/<int:machine_id>', methods=['POST'])
@jwt_required()
@require_permission('applications.create')
def install_application(machine_id: int):
"""Install an application on a computer."""
@@ -449,7 +444,6 @@ def install_application(machine_id: int):
@applications_bp.route('/machines/<int:machine_id>/<int:app_id>', methods=['DELETE'])
@jwt_required()
@require_permission('applications.delete')
def uninstall_application(machine_id: int, app_id: int):
"""Uninstall an application from a computer."""
@@ -474,7 +468,6 @@ def uninstall_application(machine_id: int, app_id: int):
@applications_bp.route('/machines/<int:machine_id>/<int:app_id>', methods=['PUT'])
@jwt_required()
@require_permission('applications.edit')
def update_installed_app(machine_id: int, app_id: int):
"""Update installed application (e.g., change version)."""
@@ -516,7 +509,6 @@ def update_installed_app(machine_id: int, app_id: int):
# =============================================================================
@applications_bp.route('/<int:app_id>/image', methods=['POST'])
@jwt_required()
@require_permission('applications.edit')
def upload_application_image(app_id: int):
"""Upload (or replace) an application's image.
@@ -555,7 +547,6 @@ def serve_application_image(filename):
@applications_bp.route('/<int:app_id>/image', methods=['DELETE'])
@jwt_required()
@require_permission('applications.edit')
def delete_application_image(app_id: int):
"""Remove an application's image and clear the field."""
@@ -574,7 +565,6 @@ def delete_application_image(app_id: int):
@applications_bp.route('/<int:app_id>/package', methods=['POST'])
@jwt_required()
@require_permission('applications.edit')
def upload_application_package(app_id: int):
"""Upload (or replace) the installer for an application.
@@ -627,7 +617,6 @@ def upload_application_package(app_id: int):
@applications_bp.route('/package/<path:filename>', methods=['GET'])
@jwt_required()
@require_permission('applications.view')
def serve_application_package(filename):
"""Download an uploaded installer.
@@ -640,7 +629,6 @@ def serve_application_package(filename):
@applications_bp.route('/<int:app_id>/package', methods=['DELETE'])
@jwt_required()
@require_permission('applications.edit')
def delete_application_package(app_id: int):
"""Remove an uploaded installer and clear the path it set."""

View File

@@ -60,7 +60,6 @@ def get_asset_type(type_id: int):
@assets_bp.route('/types', methods=['POST'])
@jwt_required()
@require_permission('assets.create')
def create_asset_type():
"""Create a new asset type."""
@@ -92,7 +91,6 @@ def create_asset_type():
@assets_bp.route('/types/<int:type_id>', methods=['PUT'])
@jwt_required()
@require_permission('assets.edit')
def update_asset_type(type_id: int):
"""Update an asset type's display fields (color/icon/description). The name,
@@ -148,7 +146,6 @@ def get_asset_status(status_id: int):
@assets_bp.route('/statuses', methods=['POST'])
@jwt_required()
@require_permission('assets.create')
def create_asset_status():
"""Create a new asset status."""
@@ -177,7 +174,6 @@ def create_asset_status():
@assets_bp.route('/statuses/<int:status_id>', methods=['PUT'])
@jwt_required()
@require_permission('assets.edit')
def update_asset_status(status_id: int):
"""Update an asset status."""
@@ -206,7 +202,6 @@ def update_asset_status(status_id: int):
@assets_bp.route('/statuses/<int:status_id>', methods=['DELETE'])
@jwt_required()
@require_permission('assets.delete')
def delete_asset_status(status_id: int):
"""Delete an asset status. Refused if any asset still uses it."""
@@ -241,7 +236,6 @@ def list_relationship_types():
@assets_bp.route('/relationshiptypes', methods=['POST'])
@jwt_required()
@require_permission('assets.create')
def create_relationship_type():
"""Create a new asset relationship type."""
@@ -288,7 +282,6 @@ def _rel_type_dict(t):
@assets_bp.route('/relationshiptypes/<int:type_id>', methods=['PUT'])
@jwt_required()
@require_permission('assets.edit')
def update_relationship_type(type_id: int):
"""Update a relationship type."""
@@ -308,7 +301,6 @@ def update_relationship_type(type_id: int):
@assets_bp.route('/relationshiptypes/<int:type_id>', methods=['DELETE'])
@jwt_required()
@require_permission('assets.delete')
def delete_relationship_type(type_id: int):
"""Delete a relationship type. Refused if any relationship still uses it."""
@@ -437,7 +429,6 @@ def get_asset(asset_id: int):
@assets_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('assets.create')
def create_asset():
"""Create a new asset."""
@@ -489,7 +480,6 @@ def create_asset():
@assets_bp.route('/<int:asset_id>', methods=['PUT'])
@jwt_required()
@require_permission('assets.edit')
def update_asset(asset_id: int):
"""Update an asset."""
@@ -542,7 +532,6 @@ def update_asset(asset_id: int):
@assets_bp.route('/<int:asset_id>', methods=['DELETE'])
@jwt_required()
@require_permission('assets.delete')
def delete_asset(asset_id: int):
"""Delete (soft delete) an asset."""
@@ -733,7 +722,6 @@ def get_asset_relationships(asset_id: int):
@assets_bp.route('/relationships', methods=['POST'])
@jwt_required()
@require_permission('assets.create')
def create_asset_relationship():
"""Create a relationship between two assets.
@@ -884,7 +872,6 @@ def create_asset_relationship():
@assets_bp.route('/relationships/<int:rel_id>', methods=['DELETE'])
@jwt_required()
@require_permission('assets.delete')
def delete_asset_relationship(rel_id: int):
"""Delete an asset relationship.

View File

@@ -52,7 +52,6 @@ def _resolve_full_names(ssos):
@auditlogs_bp.route('', methods=['GET'])
@jwt_required()
@require_permission('audit.view')
def list_auditlogs():
"""
@@ -136,7 +135,6 @@ def list_auditlogs():
@auditlogs_bp.route('/entity/<entitytype>/<int:entityid>', methods=['GET'])
@jwt_required()
@require_permission('audit.view')
def get_entity_history(entitytype: str, entityid: int):
"""Get audit history for a specific entity."""
@@ -149,7 +147,6 @@ def get_entity_history(entitytype: str, entityid: int):
@auditlogs_bp.route('/stats', methods=['GET'])
@jwt_required()
@require_permission('audit.view')
def get_stats():
"""Get audit log statistics."""
@@ -198,7 +195,6 @@ def db_func_count_by(column):
@auditlogs_bp.route('/retention', methods=['GET'])
@jwt_required()
@require_permission('audit.view')
def get_retention():
"""Size of the log and what the configured retention would remove.
@@ -212,7 +208,6 @@ def get_retention():
@auditlogs_bp.route('/purge', methods=['POST'])
@jwt_required()
@require_permission('audit.purge')
def purge_auditlogs():
"""Delete entries older than the retention window.

View File

@@ -187,6 +187,26 @@ def get_navigation():
# Sort by position
all_items.sort(key=lambda x: x.get('position', 99))
# A signed-out visitor is shown only what the guest role can actually
# reach. Without this the sidebar offers them the whole app and every link
# bounces off a 401, which reads as a broken site rather than a closed
# door. An item with no declared permission is treated as members-only: a
# plugin opts into being guest-visible by naming the permission its page
# needs, which is the same string the API already gates on.
from flask_jwt_extended import verify_jwt_in_request
from shopdb.utils.authz import currentuser, guestholds
verify_jwt_in_request(optional=True)
def guestmayreach(item):
declared = item.get('permission')
if not declared:
return False
names = declared if isinstance(declared, list) else [declared]
return any(guestholds(name) for name in names)
if currentuser() is None:
all_items = [item for item in all_items if guestmayreach(item)]
return success_response(all_items)

View File

@@ -115,7 +115,6 @@ def apply_transform(asset, transform):
@mappositions_bp.route('/transform', methods=['POST'])
@jwt_required()
@require_permission('assets.edit')
def transform_positions():
"""Move every placed marker on a level by a transform read off landmarks.
@@ -225,7 +224,6 @@ def transform_positions():
# =============================================================================
@mappositions_bp.route('/positions', methods=['POST'])
@jwt_required()
@require_permission('assets.edit')
def set_positions():
"""Set positions for many assets at once.
@@ -301,7 +299,6 @@ def set_positions():
@mappositions_bp.route('/verify', methods=['POST'])
@jwt_required()
@require_permission('assets.edit')
def verify_positions():
"""Mark markers as reviewed against the current drawing without moving them.
@@ -330,7 +327,6 @@ def verify_positions():
# =============================================================================
@mappositions_bp.route('/snapshots', methods=['GET'])
@jwt_required()
@require_permission('assets.view')
def list_snapshots():
"""Snapshots, newest first. Metadata only - the positions are large."""
@@ -341,7 +337,6 @@ def list_snapshots():
@mappositions_bp.route('/snapshots/<int:snapshotid>/restore', methods=['POST'])
@jwt_required()
@require_permission('assets.edit')
def restore_snapshot(snapshotid):
"""Put the positions in this snapshot back.

View File

@@ -525,7 +525,6 @@ def pc_relationships():
@reports_bp.route('/email', methods=['POST'])
@jwt_required()
@require_permission('reports.export')
def email_report():
"""Email a report's data as an HTML table (on-demand delivery).

View File

@@ -277,7 +277,6 @@ def get_setting(key: str):
@settings_bp.route('/<key>', methods=['PUT'])
@jwt_required()
@require_permission('settings.edit')
def update_setting(key: str):
"""Update a setting value."""
@@ -336,7 +335,6 @@ def update_setting(key: str):
@settings_bp.route('/test-email', methods=['POST'])
@jwt_required()
@require_permission('settings.edit')
def test_email():
"""Send a test email to verify SMTP configuration.
@@ -374,7 +372,6 @@ def test_email():
@settings_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('settings.edit')
def create_setting():
"""Create a new setting (admin only)."""
@@ -986,7 +983,6 @@ def build_default_settings():
@settings_bp.route('/seed', methods=['POST'])
@jwt_required()
@require_permission('settings.edit')
def seed_default_settings():
"""Seed default settings if they don't exist."""

View File

@@ -6,7 +6,8 @@ from werkzeug.security import generate_password_hash
from shopdb.extensions import db
from shopdb.core.models import (
User, Role, Permission, AuditLog, full_permission_catalog)
User, Role, Permission, AuditLog, full_permission_catalog,
guest_grantable_permissions)
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_role
@@ -266,6 +267,9 @@ def list_permissions():
def list_roles():
"""List all roles with their permissions."""
roles = Role.query.order_by(Role.rolename).all()
# What a guest MAY be granted, so the grid can grey out the rest rather
# than letting somebody tick a box the API will refuse.
guestgrantable = sorted(guest_grantable_permissions())
return success_response([{
'roleid': r.roleid,
'rolename': r.rolename,
@@ -273,7 +277,9 @@ def list_roles():
'color': r.color,
'usercount': r.users.count(),
'permissions': [p.name for p in r.permissions],
'isadmin': r.rolename == 'admin'
'isadmin': r.rolename == 'admin',
'isguest': r.rolename == Role.GUEST,
'grantable': guestgrantable if r.rolename == Role.GUEST else None,
} for r in roles])
@@ -344,6 +350,20 @@ def update_role(roleid: int):
# Update permissions
if 'permissions' in data and role.rolename != 'admin':
# The guest role is anyone with no account, so what it may hold is
# bounded: reads, plus the handful of writes a plugin has explicitly
# declared safe to do anonymously. Refused at the door rather than
# warned about - a checkbox that quietly makes deleting an asset
# anonymous is not a warning anybody reads twice.
if role.rolename == Role.GUEST:
allowed = guest_grantable_permissions()
refused = sorted(set(data['permissions']) - allowed)
if refused:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'The guest role cannot hold {}. A guest may be granted any '
'view permission, and only writes a plugin has declared '
'safe to do without an account.'.format(', '.join(refused)))
old_perms = [p.name for p in role.permissions]
perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all()
new_perms = [p.name for p in perms]
@@ -377,6 +397,14 @@ def delete_role(roleid: int):
if role.rolename == 'admin':
return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot delete the admin role')
# Deleting the guest role does not close the door, it removes the only
# place the door is described. Revoke its permissions instead.
if role.rolename == Role.GUEST:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Cannot delete the guest role. To close guest access, clear its '
'permissions.')
if role.users.count() > 0:
return error_response(ErrorCodes.VALIDATION_ERROR, f'Role is assigned to {role.users.count()} user(s)')