From 8d5cccc4e3e83b69780ad6e05e8c8f53ba72fbf0 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Fri, 25 Sep 2026 00:43:50 +1000 Subject: [PATCH] Add central access guard for set-scoped routes --- app/services/access.py | 110 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 app/services/access.py diff --git a/app/services/access.py b/app/services/access.py new file mode 100644 index 0000000..34faf36 --- /dev/null +++ b/app/services/access.py @@ -0,0 +1,110 @@ +""" +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.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