124 lines
3.8 KiB
Python
124 lines
3.8 KiB
Python
"""
|
|
app/api/decorators.py
|
|
---------------------
|
|
Request-level guards for all /api/v1/ endpoints.
|
|
|
|
@jwt_required
|
|
Validates the Bearer token in the Authorization header.
|
|
On success, sets flask.g.api_user to the authenticated User instance
|
|
so any route can access it without a second DB query.
|
|
|
|
@api_role_required(*roles)
|
|
Must be applied AFTER @jwt_required.
|
|
Rejects callers whose role is not in the allowed set.
|
|
|
|
Usage
|
|
-----
|
|
@bp.route('/inspections')
|
|
@jwt_required
|
|
def list_inspections():
|
|
user = g.api_user
|
|
...
|
|
|
|
@bp.route('/admin/users')
|
|
@jwt_required
|
|
@api_role_required('admin')
|
|
def admin_only():
|
|
...
|
|
"""
|
|
|
|
import logging
|
|
from functools import wraps
|
|
|
|
from flask import request, g, abort
|
|
|
|
from app.api.jwt_utils import decode_access_token
|
|
from app.api.errors import api_error
|
|
from app import db
|
|
from app.models.user import User
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def jwt_required(f):
|
|
"""
|
|
Validate the JWT Bearer token and load the user into flask.g.api_user.
|
|
|
|
Returns 401 if:
|
|
- Authorization header is missing or malformed
|
|
- Token is expired or invalid
|
|
- User referenced by the token no longer exists
|
|
- User account has been disabled (active=False)
|
|
"""
|
|
@wraps(f)
|
|
def decorated(*args, **kwargs):
|
|
auth_header = request.headers.get('Authorization', '')
|
|
if not auth_header.startswith('Bearer '):
|
|
return api_error('Missing or malformed Authorization header', 401)
|
|
|
|
raw_token = auth_header[len('Bearer '):]
|
|
payload = decode_access_token(raw_token)
|
|
|
|
if payload is None:
|
|
return api_error('Access token is invalid or expired', 401)
|
|
|
|
# MT-21: a token signed for another tenant verifies fine here (shared
|
|
# SECRET_KEY), so check the tenant claim before 'sub' is resolved
|
|
# against the bound database. No-op in single-tenant mode.
|
|
from app.tenancy.session_binding import current_tenant_id
|
|
tenant_id = current_tenant_id()
|
|
if tenant_id is not None:
|
|
token_tid = payload.get('tid')
|
|
if token_tid != tenant_id:
|
|
logger.warning(
|
|
'API tenant mismatch | token_tid=%s resolved=%s endpoint=%s',
|
|
token_tid, tenant_id, request.endpoint,
|
|
)
|
|
return api_error('Access token is not valid for this workspace', 401)
|
|
|
|
user_id = int(payload.get('sub', 0))
|
|
user = db.session.get(User, user_id)
|
|
|
|
if user is None:
|
|
return api_error('User not found', 401)
|
|
|
|
if not user.active:
|
|
return api_error('Account is disabled', 401)
|
|
|
|
# Make the user available to the route without re-querying
|
|
g.api_user = user
|
|
return f(*args, **kwargs)
|
|
|
|
return decorated
|
|
|
|
|
|
def api_role_required(*roles):
|
|
"""
|
|
Restrict an endpoint to users whose role is in the provided list.
|
|
|
|
Must be stacked BELOW @jwt_required so that g.api_user is already set.
|
|
|
|
Example
|
|
-------
|
|
@jwt_required
|
|
@api_role_required('admin', 'supervisor')
|
|
def supervisor_only_route():
|
|
...
|
|
"""
|
|
def decorator(f):
|
|
@wraps(f)
|
|
def decorated(*args, **kwargs):
|
|
user = getattr(g, 'api_user', None)
|
|
if user is None:
|
|
# Defensive: jwt_required should always run first
|
|
return api_error('Authentication required', 401)
|
|
if user.role not in roles:
|
|
logger.warning(
|
|
'API role denied | user=%s role=%s required=%s endpoint=%s',
|
|
user.username, user.role, roles, request.endpoint,
|
|
)
|
|
return api_error('Insufficient permissions', 403)
|
|
return f(*args, **kwargs)
|
|
return decorated
|
|
return decorator
|