126 lines
5.0 KiB
Python
126 lines
5.0 KiB
Python
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
|