guest role; guide section ticking
This commit is contained in:
@@ -57,7 +57,7 @@ from .plugins import plugin_manager
|
||||
# permission / empty / position. BREAKING for any plugin still using the old
|
||||
# shape, which is why it is recorded here: the change itself shipped earlier
|
||||
# without a bump, and a contract that changes silently is not a contract.
|
||||
__contract_version__ = '0.20.0'
|
||||
__contract_version__ = '0.21.0'
|
||||
|
||||
# Product release version (see ADR-007). The product version and the
|
||||
# plugin-contract version above are distinct series with independent
|
||||
|
||||
@@ -68,7 +68,7 @@ from shopdb.utils.responses import (
|
||||
from shopdb.utils.pagination import get_pagination_params, paginate_query
|
||||
|
||||
# Authorization decorators for gating plugin write routes
|
||||
from shopdb.utils.authz import require_permission, require_role
|
||||
from shopdb.utils.authz import require_permission, require_any_permission, require_role
|
||||
|
||||
# Service-token authorization for unattended plugin endpoints (collector,
|
||||
# GE-Enforce fetch, ...): checks a scoped managed token without exposing token
|
||||
@@ -298,6 +298,7 @@ __all__ = [
|
||||
'paginate_query',
|
||||
# Authorization decorators
|
||||
'require_permission',
|
||||
'require_any_permission',
|
||||
'require_role',
|
||||
'service_token_authorized',
|
||||
'authorized_service_token',
|
||||
|
||||
@@ -784,13 +784,20 @@ def fix_controls_direction():
|
||||
@seed_cli.command('permissions')
|
||||
@with_appcontext
|
||||
def seed_permissions():
|
||||
"""Seed predefined permissions."""
|
||||
"""Seed predefined permissions, and the guest role that can hold them."""
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Permission
|
||||
from shopdb.core.models import Permission, Role
|
||||
|
||||
created = Permission.seed()
|
||||
# Seeded here rather than in `seed admin` so an existing site picks it up
|
||||
# on the upgrade it already runs. It holds nothing until somebody grants
|
||||
# it something, so seeding it changes no behaviour.
|
||||
guest = Role.seedguest()
|
||||
db.session.commit()
|
||||
click.echo(click.style(f"{created} permissions created.", fg='green'))
|
||||
if guest:
|
||||
click.echo(click.style(
|
||||
"guest role created (holds no permissions).", fg='green'))
|
||||
|
||||
|
||||
@seed_cli.command('settings')
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)')
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ from .mapsnapshot import MapPositionSnapshot
|
||||
from .operatingsystem import OperatingSystem
|
||||
from .relationship import AssetRelationship, RelationshipType, RelationshipTypePropagation
|
||||
from .communication import Communication, CommunicationType
|
||||
from .user import User, Role, Permission, full_permission_catalog
|
||||
from .user import (User, Role, Permission, full_permission_catalog,
|
||||
guest_grantable_permissions)
|
||||
from .application import Application, AppVersion
|
||||
from .supportteam import SupportTeam, SupportTeamContact
|
||||
from .setting import Setting
|
||||
@@ -57,6 +58,7 @@ __all__ = [
|
||||
'Role',
|
||||
'Permission',
|
||||
'full_permission_catalog',
|
||||
'guest_grantable_permissions',
|
||||
# Applications
|
||||
'Application',
|
||||
'AppVersion',
|
||||
|
||||
@@ -152,6 +152,53 @@ def full_permission_catalog(include_disabled=False):
|
||||
return catalog
|
||||
|
||||
|
||||
def guest_grantable_permissions():
|
||||
"""Which permissions the guest role may be granted.
|
||||
|
||||
Reading is the safe default and covers almost everything a site wants to
|
||||
open up: any `.view` or `.read` permission may be handed to guests.
|
||||
|
||||
A write is different - it lets somebody with no account change this
|
||||
database, and the audit trail can then say when and from where but never
|
||||
who. So a write has to be BLESSED by whoever owns it, through the plugin's
|
||||
get_guest_grantable_permissions(). Secure Send is the case that exists:
|
||||
creating a one-view link for somebody is the whole point of the page, and
|
||||
the page has always been usable by people with no account here.
|
||||
|
||||
Everything else is refused at the door rather than warned about, because a
|
||||
checkbox that quietly makes deleting an asset anonymous is not a warning
|
||||
anybody reads twice.
|
||||
"""
|
||||
from flask import current_app, has_app_context
|
||||
|
||||
catalog = full_permission_catalog()
|
||||
grantable = {name for name, _, _ in catalog
|
||||
if name.endswith('.view') or name.endswith('.read')}
|
||||
|
||||
if not has_app_context():
|
||||
return grantable
|
||||
pm = current_app.extensions.get('plugin_manager')
|
||||
if not pm:
|
||||
return grantable
|
||||
|
||||
known = {name for name, _, _ in catalog}
|
||||
for name, plugin in pm.get_all_plugins().items():
|
||||
if not pm.registry.is_enabled(name):
|
||||
continue
|
||||
try:
|
||||
blessed = plugin.get_guest_grantable_permissions() or []
|
||||
except Exception:
|
||||
if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
|
||||
raise
|
||||
current_app.logger.exception(
|
||||
'Plugin %s get_guest_grantable_permissions failed', name)
|
||||
continue
|
||||
# A plugin may only bless permissions that exist and that it owns the
|
||||
# naming of; a typo must not silently widen anything.
|
||||
grantable |= {p for p in blessed if p in known}
|
||||
return grantable
|
||||
|
||||
|
||||
class Role(BaseModel):
|
||||
"""User role model."""
|
||||
__tablename__ = 'roles'
|
||||
@@ -171,6 +218,16 @@ class Role(BaseModel):
|
||||
def __repr__(self):
|
||||
return f"<Role {self.rolename}>"
|
||||
|
||||
# The role a caller with no account is treated as holding.
|
||||
#
|
||||
# Guest access used to be a per-feature boolean setting - one for the
|
||||
# Secure Send page, one for each kiosk, one proposed for guides - which
|
||||
# meant every new question about what a signed-out visitor may see was
|
||||
# answered by writing more code. It is a permissions question, and this app
|
||||
# already administers permissions per role, so a guest is now simply a role
|
||||
# holding no permissions until somebody grants it some.
|
||||
GUEST = 'guest'
|
||||
|
||||
def haspermission(self, permission_name: str) -> bool:
|
||||
"""Check if role has a specific permission."""
|
||||
# Admin role has all permissions
|
||||
@@ -178,6 +235,31 @@ class Role(BaseModel):
|
||||
return True
|
||||
return any(p.name == permission_name for p in self.permissions)
|
||||
|
||||
@classmethod
|
||||
def guest(cls):
|
||||
"""The guest role, or None on a site that has not been seeded yet."""
|
||||
return cls.query.filter_by(rolename=cls.GUEST).first()
|
||||
|
||||
@classmethod
|
||||
def seedguest(cls):
|
||||
"""Create the guest role if it is missing. Returns True if created.
|
||||
|
||||
Seeded with NO permissions, which is the same behaviour every site has
|
||||
today: an unauthenticated request is refused. What a guest may do is a
|
||||
decision each site makes in the role grid, not one it inherits from an
|
||||
upgrade.
|
||||
"""
|
||||
if cls.query.filter_by(rolename=cls.GUEST).first() is not None:
|
||||
return False
|
||||
db.session.add(cls(
|
||||
rolename=cls.GUEST,
|
||||
description=('Anyone who is not signed in. Holds nothing by '
|
||||
'default; grant it a permission to open that up to '
|
||||
'people with no account.'),
|
||||
color='#6C757D',
|
||||
))
|
||||
return True
|
||||
|
||||
def getpermissionnames(self) -> list:
|
||||
"""Get list of permission names."""
|
||||
if self.rolename == 'admin':
|
||||
|
||||
@@ -104,6 +104,23 @@ class BasePlugin(ABC):
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_guest_grantable_permissions(self) -> List[str]:
|
||||
"""Permissions this plugin allows a site to grant to the GUEST role.
|
||||
|
||||
A guest is anyone with no account. Any `.view` or `.read` permission is
|
||||
grantable without asking a plugin - reading is the safe default. This
|
||||
hook is for the exception: a WRITE that is genuinely meant to be usable
|
||||
by somebody who never signs in.
|
||||
|
||||
Naming one here does not open it. It makes the checkbox available in
|
||||
the role grid; a site still has to tick it. Return [] (the default) and
|
||||
none of this plugin's writes can ever be made anonymous.
|
||||
|
||||
The bar: could this action be attributed to nobody, forever, and still
|
||||
be defensible in the audit log? If not, leave it out.
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_settings_defaults(self) -> List[Dict]:
|
||||
"""Declare the Setting rows this plugin owns.
|
||||
|
||||
|
||||
@@ -10,13 +10,34 @@ or not a separate @jwt_required is also present. The admin role bypasses every
|
||||
permission check (see User.haspermission), so an admin never needs individual
|
||||
permissions granted.
|
||||
|
||||
THE GUEST ROLE
|
||||
|
||||
Somebody with no token is not nobody: they hold the `guest` role. If that role
|
||||
has been granted the permission a route asks for, the request proceeds; if it
|
||||
has not - and by default it holds nothing at all - the answer is 401, exactly
|
||||
as before.
|
||||
|
||||
This replaced a growing pile of per-feature booleans (`securesend_allow_guests`,
|
||||
one per kiosk, one nearly written for guides). Every one of them answered the
|
||||
same question in its own vocabulary, in its own settings page, checked by its
|
||||
own hand-written branch inside a route. What a signed-out visitor may see is a
|
||||
permissions question, and this app already administers permissions per role.
|
||||
|
||||
So: a site opens a door by ticking a box in the role grid, and a plugin needs
|
||||
no code at all to participate. A feature FLAG is still a flag - whether a kiosk
|
||||
screen exists at this site is not the same question as who may read it.
|
||||
|
||||
Usage:
|
||||
|
||||
@assets_bp.route('/<int:asset_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('assets.delete')
|
||||
def delete_asset(asset_id):
|
||||
...
|
||||
|
||||
A separate @jwt_required() above these is redundant - they verify the request
|
||||
themselves - and it is actively wrong now: it refuses a request with no token
|
||||
before the guest role can be consulted, so a permission granted to guests would
|
||||
appear to do nothing.
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
@@ -26,6 +47,45 @@ from flask_jwt_extended import verify_jwt_in_request, current_user, get_jwt
|
||||
from shopdb.utils.responses import error_response, ErrorCodes
|
||||
|
||||
|
||||
def currentuser():
|
||||
"""The signed-in user, or None.
|
||||
|
||||
`current_user` is a LocalProxy: the proxy object exists whether or not
|
||||
anybody is behind it, so `current_user is None` is False even for a request
|
||||
with no token. Under a required JWT that never showed, because the request
|
||||
was refused before reaching here. With optional verification it shows
|
||||
immediately, as "'NoneType' object has no attribute 'haspermission'".
|
||||
"""
|
||||
try:
|
||||
return current_user._get_current_object()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def guestholds(permission_name: str) -> bool:
|
||||
"""Whether the guest role has been granted this permission.
|
||||
|
||||
Read every time, not cached. Revoking guest access is what somebody does the
|
||||
moment they realise what they opened up, and it has to bite on the very next
|
||||
request - a cache keyed to the app context outlives a request whenever one
|
||||
is pushed around several (which is exactly how the test client behaves, and
|
||||
how the revoke test caught it). One small indexed query on the rare path is
|
||||
worth more than that risk.
|
||||
|
||||
A site with no guest role yet - an older database, seeded before this
|
||||
existed - answers no, which is the behaviour it already had.
|
||||
"""
|
||||
from shopdb.core.models import Role
|
||||
try:
|
||||
role = Role.guest()
|
||||
except Exception:
|
||||
# A database that cannot answer must not turn into an open door.
|
||||
return False
|
||||
if role is None:
|
||||
return False
|
||||
return any(p.name == permission_name for p in role.permissions)
|
||||
|
||||
|
||||
def require_permission(permission_name: str):
|
||||
"""Gate a route behind a single permission. Admin role bypasses.
|
||||
|
||||
@@ -36,10 +96,15 @@ def require_permission(permission_name: str):
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapper(*args, **kwargs):
|
||||
# verify jwt first so current_user is loaded (idempotent if the
|
||||
# route also has @jwt_required)
|
||||
verify_jwt_in_request()
|
||||
if current_user is None:
|
||||
# Optional, so a request with no token reaches the guest check
|
||||
# below rather than being refused by the JWT layer before this
|
||||
# decorator can have an opinion. A request that DOES carry a token
|
||||
# is still verified, and a bad one still fails here.
|
||||
verify_jwt_in_request(optional=True)
|
||||
user = currentuser()
|
||||
if user is None:
|
||||
if guestholds(permission_name):
|
||||
return view_func(*args, **kwargs)
|
||||
return error_response(
|
||||
ErrorCodes.UNAUTHORIZED,
|
||||
'Authentication required',
|
||||
@@ -53,7 +118,7 @@ def require_permission(permission_name: str):
|
||||
# everything), so the scope list is the real limiter - the
|
||||
# admin bypass is suspended.
|
||||
allowed = (permission_name in patscopes
|
||||
and current_user.haspermission(permission_name))
|
||||
and user.haspermission(permission_name))
|
||||
if not allowed:
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
@@ -61,7 +126,54 @@ def require_permission(permission_name: str):
|
||||
http_code=403
|
||||
)
|
||||
return view_func(*args, **kwargs)
|
||||
if not current_user.haspermission(permission_name):
|
||||
if not user.haspermission(permission_name):
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
'You do not have permission to perform this action',
|
||||
http_code=403
|
||||
)
|
||||
return view_func(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def require_any_permission(*permission_names):
|
||||
"""Gate a route behind ANY ONE of several permissions. Admin bypasses.
|
||||
|
||||
For a read that two different rights should reach. GE-Enforce and Slides
|
||||
are the cases: a GET was gated on the MANAGE permission, because managing
|
||||
was the only thing anybody did there. Splitting a read permission out would
|
||||
have locked out every existing manager, so a read now accepts either, and
|
||||
the read permission is the one a site can hand to a guest.
|
||||
|
||||
A scoped PAT must carry one of the names, and its owner must hold that same
|
||||
one - the intersection rule of require_permission, applied per name.
|
||||
"""
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapper(*args, **kwargs):
|
||||
verify_jwt_in_request(optional=True)
|
||||
user = currentuser()
|
||||
if user is None:
|
||||
if any(guestholds(name) for name in permission_names):
|
||||
return view_func(*args, **kwargs)
|
||||
return error_response(
|
||||
ErrorCodes.UNAUTHORIZED,
|
||||
'Authentication required',
|
||||
http_code=401
|
||||
)
|
||||
patscopes = get_jwt().get('patscopes')
|
||||
if patscopes is not None:
|
||||
allowed = any(name in patscopes and user.haspermission(name)
|
||||
for name in permission_names)
|
||||
if not allowed:
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
'This API token is not scoped for this action',
|
||||
http_code=403
|
||||
)
|
||||
return view_func(*args, **kwargs)
|
||||
if not any(user.haspermission(name) for name in permission_names):
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
'You do not have permission to perform this action',
|
||||
@@ -83,7 +195,8 @@ def require_role(rolename: str):
|
||||
@wraps(view_func)
|
||||
def wrapper(*args, **kwargs):
|
||||
verify_jwt_in_request()
|
||||
if current_user is None:
|
||||
user = currentuser()
|
||||
if user is None:
|
||||
return error_response(
|
||||
ErrorCodes.UNAUTHORIZED,
|
||||
'Authentication required',
|
||||
@@ -95,7 +208,7 @@ def require_role(rolename: str):
|
||||
'Scoped API tokens cannot access role-gated endpoints',
|
||||
http_code=403
|
||||
)
|
||||
if not current_user.hasrole(rolename):
|
||||
if not user.hasrole(rolename):
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
f'{rolename.capitalize()} access required',
|
||||
|
||||
Reference in New Issue
Block a user