""" Central access control for set-scoped routes. Every route that touches a set resolves it from a URL parameter — set_id, instruction_id or file_id. Rather than bolting a permission check onto each one (and forgetting the next one somebody adds), this installs a single before_request hook that resolves the set behind the current request and refuses it if the user isn't allowed. Fail-closed by design: a set-scoped endpoint that isn't classified is treated as needing manage rights, so a new route is restrictive until it is deliberately listed here. Created by: Jess Rogerson (yelling commands at Claude.AI) """ from flask import abort, request, flash, redirect, url_for from flask_login import current_user # URL parameters that identify a set, directly or via a child record. SET_PARAM = 'set_id' INSTRUCTION_PARAM = 'instruction_id' EXTRA_FILE_PARAM = 'file_id' # GET endpoints that still require manage rights — they render an editing # surface, so showing them to a viewer would only lead to a refusal later. MANAGE_ON_GET = { 'sets.edit_set', 'sets.debug_set_image', 'instructions.upload', 'instructions.organise', 'instructions.debug_paths', 'extra_files.upload', } # Endpoints that only need view rights despite being POSTs. VIEW_ON_POST = set() # Blueprints whose set-scoped routes this guard applies to. GUARDED_BLUEPRINTS = {'sets', 'instructions', 'extra_files', 'moc'} def _resolve_set(): """Return the Set behind this request, or None if it isn't set-scoped.""" from app.models.set import Set from app.models.instruction import Instruction from app.models.extra_file import ExtraFile args = request.view_args or {} if SET_PARAM in args: return Set.query.get(args[SET_PARAM]) if INSTRUCTION_PARAM in args: instruction = Instruction.query.get(args[INSTRUCTION_PARAM]) return instruction.set if instruction else None if EXTRA_FILE_PARAM in args: extra = ExtraFile.query.get(args[EXTRA_FILE_PARAM]) return extra.lego_set if extra else None return None def _needs_manage(endpoint, method): if endpoint in MANAGE_ON_GET: return True if endpoint in VIEW_ON_POST: return False return method not in ('GET', 'HEAD', 'OPTIONS') def install_access_guard(app): """Register the before_request hook on `app`.""" @app.before_request def enforce_set_access(): endpoint = request.endpoint if not endpoint or '.' not in endpoint: return None blueprint = endpoint.split('.', 1)[0] if blueprint not in GUARDED_BLUEPRINTS: return None args = request.view_args or {} if not any(k in args for k in (SET_PARAM, INSTRUCTION_PARAM, EXTRA_FILE_PARAM)): return None # Not logged in: let Flask-Login's own redirect happen. if not current_user.is_authenticated: return None lego_set = _resolve_set() if lego_set is None: return None # the view's own get_or_404 will handle it if _needs_manage(endpoint, request.method): if not lego_set.can_manage(current_user): # Don't confirm the set exists to someone who can't see it. if not lego_set.can_view(current_user): abort(404) flash('Only the owner of this set can make that change.', 'danger') return redirect(url_for('sets.view_set', set_id=lego_set.id)) return None if not lego_set.can_view(current_user): abort(404) return None