265 lines
11 KiB
Python
265 lines
11 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.
|
|
|
|
THE GUEST ROLE
|
|
|
|
Somebody with no token is not nobody: they hold the `guest` role. If that role
|
|
has been granted the permission a route asks for, the request proceeds; if it
|
|
has not - and by default it holds nothing at all - the answer is 401, exactly
|
|
as before.
|
|
|
|
This replaced a growing pile of per-feature booleans (`securesend_allow_guests`,
|
|
one per kiosk, one nearly written for guides). Every one of them answered the
|
|
same question in its own vocabulary, in its own settings page, checked by its
|
|
own hand-written branch inside a route. What a signed-out visitor may see is a
|
|
permissions question, and this app already administers permissions per role.
|
|
|
|
So: a site opens a door by ticking a box in the role grid, and a plugin needs
|
|
no code at all to participate. A feature FLAG is still a flag - whether a kiosk
|
|
screen exists at this site is not the same question as who may read it.
|
|
|
|
Usage:
|
|
|
|
@assets_bp.route('/<int:asset_id>', methods=['DELETE'])
|
|
@require_permission('assets.delete')
|
|
def delete_asset(asset_id):
|
|
...
|
|
|
|
A separate @jwt_required() above these is redundant - they verify the request
|
|
themselves - and it is actively wrong now: it refuses a request with no token
|
|
before the guest role can be consulted, so a permission granted to guests would
|
|
appear to do nothing.
|
|
"""
|
|
|
|
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 currentuser():
|
|
"""The signed-in user, or None.
|
|
|
|
`current_user` is a LocalProxy: the proxy object exists whether or not
|
|
anybody is behind it, so `current_user is None` is False even for a request
|
|
with no token. Under a required JWT that never showed, because the request
|
|
was refused before reaching here. With optional verification it shows
|
|
immediately, as "'NoneType' object has no attribute 'haspermission'".
|
|
"""
|
|
try:
|
|
return current_user._get_current_object()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def guestholds(permission_name: str) -> bool:
|
|
"""Whether the guest role has been granted this permission.
|
|
|
|
Read every time, not cached. Revoking guest access is what somebody does the
|
|
moment they realise what they opened up, and it has to bite on the very next
|
|
request - a cache keyed to the app context outlives a request whenever one
|
|
is pushed around several (which is exactly how the test client behaves, and
|
|
how the revoke test caught it). One small indexed query on the rare path is
|
|
worth more than that risk.
|
|
|
|
A site with no guest role yet - an older database, seeded before this
|
|
existed - answers no, which is the behaviour it already had.
|
|
"""
|
|
from shopdb.core.models import Role
|
|
try:
|
|
role = Role.guest()
|
|
except Exception:
|
|
# A database that cannot answer must not turn into an open door.
|
|
return False
|
|
if role is None:
|
|
return False
|
|
return any(p.name == permission_name for p in role.permissions)
|
|
|
|
|
|
def caller_holds(permission_name: str) -> bool:
|
|
"""Whether THIS caller holds a permission, without refusing the request.
|
|
|
|
require_permission answers the same question by returning 401/403. This
|
|
answers it in the body, for the case where a route serves everyone but
|
|
should only OFFER something to the callers who can use it.
|
|
|
|
The endpoints services panel is the example that prompted it: it emitted
|
|
Start/Stop buttons for every reader, because the emission never consulted
|
|
the caller. Clicking them 403s, so nothing was exploitable, but showing a
|
|
read-only viewer controls over a shop-floor PC is the wrong signal - and it
|
|
gets worse the moment a site grants that panel to guests.
|
|
|
|
Guests are included on purpose: a signed-out visitor holds whatever the
|
|
guest role was granted, so the answer must come from the same place
|
|
require_permission gets it.
|
|
|
|
Scoped personal tokens are included for the same reason, and this is the
|
|
half that was missing: the answer has to be about the CALLER, and a scoped
|
|
token is a narrower caller than its owner. Reading the owner's permissions
|
|
let a token scoped to endpoints.command queue a remote restart, because the
|
|
elevated check asked the owner rather than the token - the one bypass
|
|
require_permission exists to prevent, reintroduced by a route that did its
|
|
own check.
|
|
"""
|
|
user = currentuser()
|
|
if user is None:
|
|
return guestholds(permission_name)
|
|
try:
|
|
if not user.haspermission(permission_name):
|
|
return False
|
|
patscopes = get_jwt().get('patscopes')
|
|
except Exception:
|
|
return False
|
|
if patscopes is not None:
|
|
return permission_name in patscopes
|
|
return True
|
|
|
|
|
|
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):
|
|
# Optional, so a request with no token reaches the guest check
|
|
# below rather than being refused by the JWT layer before this
|
|
# decorator can have an opinion. A request that DOES carry a token
|
|
# is still verified, and a bad one still fails here.
|
|
verify_jwt_in_request(optional=True)
|
|
user = currentuser()
|
|
if user is None:
|
|
if guestholds(permission_name):
|
|
return view_func(*args, **kwargs)
|
|
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 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 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)
|
|
# Named so a test can sweep the URL map and prove every read is gated;
|
|
# a decorator is invisible from the outside otherwise.
|
|
wrapper._requires_permission = (permission_name,)
|
|
return wrapper
|
|
return decorator
|
|
|
|
|
|
def require_any_permission(*permission_names):
|
|
"""Gate a route behind ANY ONE of several permissions. Admin bypasses.
|
|
|
|
For a read that two different rights should reach. GE-Enforce and Slides
|
|
are the cases: a GET was gated on the MANAGE permission, because managing
|
|
was the only thing anybody did there. Splitting a read permission out would
|
|
have locked out every existing manager, so a read now accepts either, and
|
|
the read permission is the one a site can hand to a guest.
|
|
|
|
A scoped PAT must carry one of the names, and its owner must hold that same
|
|
one - the intersection rule of require_permission, applied per name.
|
|
"""
|
|
def decorator(view_func):
|
|
@wraps(view_func)
|
|
def wrapper(*args, **kwargs):
|
|
verify_jwt_in_request(optional=True)
|
|
user = currentuser()
|
|
if user is None:
|
|
if any(guestholds(name) for name in permission_names):
|
|
return view_func(*args, **kwargs)
|
|
return error_response(
|
|
ErrorCodes.UNAUTHORIZED,
|
|
'Authentication required',
|
|
http_code=401
|
|
)
|
|
patscopes = get_jwt().get('patscopes')
|
|
if patscopes is not None:
|
|
allowed = any(name in patscopes and user.haspermission(name)
|
|
for name in permission_names)
|
|
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 any(user.haspermission(name) for name in permission_names):
|
|
return error_response(
|
|
ErrorCodes.FORBIDDEN,
|
|
'You do not have permission to perform this action',
|
|
http_code=403
|
|
)
|
|
return view_func(*args, **kwargs)
|
|
wrapper._requires_permission = tuple(permission_names)
|
|
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()
|
|
user = currentuser()
|
|
if 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 user.hasrole(rolename):
|
|
return error_response(
|
|
ErrorCodes.FORBIDDEN,
|
|
f'{rolename.capitalize()} access required',
|
|
http_code=403
|
|
)
|
|
return view_func(*args, **kwargs)
|
|
# A role gate is a gate too, as far as the read sweep is concerned.
|
|
wrapper._requires_permission = ('role:' + rolename,)
|
|
return wrapper
|
|
return decorator
|