"""Authorization decorators for role and permission gating. Authentication (is the caller logged in?) is handled by Flask-JWT-Extended's @jwt_required. Authorization (is the caller ALLOWED to do this?) is handled here. The two are separate concerns; a route needs both on any state-changing action. These decorators call verify_jwt_in_request() themselves, so they work whether 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. Usage: @assets_bp.route('/', methods=['DELETE']) @jwt_required() @require_permission('assets.delete') def delete_asset(asset_id): ... """ from functools import wraps from flask_jwt_extended import verify_jwt_in_request, current_user, get_jwt from shopdb.utils.responses import error_response, ErrorCodes def require_permission(permission_name: str): """Gate a route behind a single permission. Admin role bypasses. A scoped personal API token (patscopes claim present) does NOT bypass: it grants only the permissions in its scope list, intersected with what the owner actually holds. See shopdb/utils/apitoken_auth.py. """ 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: return error_response( ErrorCodes.UNAUTHORIZED, 'Authentication required', http_code=401 ) patscopes = get_jwt().get('patscopes') if patscopes is not None: # Scoped PAT: allowed only when this permission is in the scope # list AND the owner actually holds it. haspermission still # returns True for an admin owner (who legitimately holds # 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)) 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 current_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_role(rolename: str): """Gate a route behind a single role (e.g. 'admin'). A scoped personal API token (patscopes claim present) is ALWAYS denied here: scopes gate individual permissions, not roles, so role-gated admin surfaces require an unscoped token. See shopdb/utils/apitoken_auth.py. """ def decorator(view_func): @wraps(view_func) def wrapper(*args, **kwargs): verify_jwt_in_request() if current_user is None: return error_response( ErrorCodes.UNAUTHORIZED, 'Authentication required', http_code=401 ) if get_jwt().get('patscopes') is not None: return error_response( ErrorCodes.FORBIDDEN, 'Scoped API tokens cannot access role-gated endpoints', http_code=403 ) if not current_user.hasrole(rolename): return error_response( ErrorCodes.FORBIDDEN, f'{rolename.capitalize()} access required', http_code=403 ) return view_func(*args, **kwargs) return wrapper return decorator