Files
shopdb-flask/shopdb/utils/authz.py
Cam Proudlock a9d0dfff4a Initial import: shopdb-flask multi-site asset platform
Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin
framework so sister GE Aerospace sites can adopt it. Product version
0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests.

Key entry points:
- docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook
  (own-site and subpath deployment methods)
- docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify
- docs/IMPORT-API.md - schema-agnostic legacy data import contract
- scripts/site_imports/wjf/ - West Jefferson reference import loader
- docs/adr/ - twelve accepted architecture decision records
2026-07-14 07:58:51 -04:00

107 lines
4.1 KiB
Python

"""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('/<int:asset_id>', 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