from functools import wraps from flask import flash, redirect, url_for, request from flask_login import current_user from urllib.parse import urlparse # ── Open-redirect guard ─────────────────────────────────────────────────────── def safe_redirect_url(url: str | None, fallback: str | None = None) -> str: """Return *url* only if it is a safe relative URL on this host. Rejects any URL that carries a network location (netloc) or an explicit scheme, preventing open-redirect attacks where a crafted link contains next=https://evil.com. Parameters ---------- url : The candidate redirect target (may be None). fallback : Returned when *url* is absent or unsafe. Defaults to the dashboard index. """ if fallback is None: fallback = url_for('dashboard.index') if not url: return fallback parsed = urlparse(url) if parsed.netloc or parsed.scheme: return fallback return url def return_url(fallback: str) -> str: """Where to go back to after a list-page action, preserving its filters. Reads the `next` value the page carried through the action — POST body first (forms), then query string (links) — and validates it with safe_redirect_url, so a crafted `next` can never redirect off-site. The problem this solves: a delete or an edit launched from a filtered list used to redirect to the bare index, throwing away the filters the user had set. Every list-page action now round-trips the list URL instead. `next` is deliberately the FULL list URL (page number and all), not a reconstructed set of arguments — that keeps this helper working when a new filter is added to either list page without anyone having to remember to thread it through here. """ from flask import request return safe_redirect_url( request.form.get('next') or request.args.get('next'), fallback=fallback, ) def admin_required(f): @wraps(f) def decorated_function(*args, **kwargs): if not current_user.is_authenticated or current_user.role != 'admin': flash('Administrator access required.', 'danger') return redirect(url_for('dashboard.index')) return f(*args, **kwargs) return decorated_function def supervisor_required(f): """Grants access to admin and director roles. The decorator is intentionally kept as 'supervisor_required' so that all existing route decorators (@supervisor_required) continue to work without any changes to the route files. The access list now reflects the renamed Director role instead of the retired Supervisor role. """ @wraps(f) def decorated_function(*args, **kwargs): if not current_user.is_authenticated or current_user.role not in ['admin', 'director']: flash('Director access required.', 'danger') return redirect(url_for('dashboard.index')) return f(*args, **kwargs) return decorated_function def project_manager_required(f): """Grants access to admin, director, project_manager, and auditor roles. Auditor mirrors Project Manager for all baseline access, so it is included here alongside project_manager. """ @wraps(f) def decorated_function(*args, **kwargs): if not current_user.is_authenticated or current_user.role not in [ 'admin', 'director', 'project_manager', 'auditor' ]: flash('Project Manager access required.', 'danger') return redirect(url_for('dashboard.index')) return f(*args, **kwargs) return decorated_function def issue_manager_required(f): """Grants access to admin, director, and auditor roles. Used for issue-management powers that go beyond the Project Manager baseline (verification and the verification queue). Deliberately does NOT include project_manager, and does NOT grant issue deletion — delete stays on @supervisor_required (admin/director only). """ @wraps(f) def decorated_function(*args, **kwargs): if not current_user.is_authenticated or current_user.role not in [ 'admin', 'director', 'auditor' ]: flash('Issue management access required.', 'danger') return redirect(url_for('dashboard.index')) return f(*args, **kwargs) return decorated_function def customer_required(f): """Restricts access to customer-role users only. Internal staff (admin, director, inspector, project_manager) should never be routed through customer-scoped views — use their own routes. """ @wraps(f) def decorated_function(*args, **kwargs): if not current_user.is_authenticated or current_user.role != 'customer': flash('Customer portal access required.', 'danger') return redirect(url_for('dashboard.index')) return f(*args, **kwargs) return decorated_function