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
This commit is contained in:
106
shopdb/utils/authz.py
Normal file
106
shopdb/utils/authz.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user