Whether somebody with no account may see something was being answered one feature at a time: a securesend_allow_guests setting, an enable flag per kiosk, and very nearly a third for guides - each with its own settings page, its own vocabulary and its own hand-written branch inside a route. Every one of them answered a permissions question, and this app already administers permissions per role. So a caller with no token is not nobody: they hold the seeded `guest` role. require_permission consults it when there is no identity, which is the entire mechanism. The role is seeded holding NOTHING, so an upgrade opens no door; a site opens one by ticking a box in the same grid it uses for every other role, and closes it the same way. Revocation is read on every request rather than cached, because taking guest access away is what somebody does the moment they realise what they opened, and it has to bite immediately. The redundant @jwt_required() above require_permission is gone from 184 routes. It was belt-and-braces before; now it is actively wrong, because it refuses a request with no token before the permission check can have an opinion, so a grant would appear to do nothing. What a guest may hold is bounded. Any .view or .read permission is grantable. A WRITE has to be blessed by whoever owns it, through the new get_guest_grantable_permissions hook (contract 0.21.0), and Secure Send is the only case that exists: sending a password to a vendor is the one write this product means to offer somebody who never signs in. Everything else is refused by the API and greyed out in the grid rather than warned about - a checkbox that quietly makes deleting an asset anonymous is not a warning anybody reads twice. A guest write records no creator: the trail says when and from where, never who, and that is the reason for the bar. GE-Enforce and Slides gained read permissions so they can be opened to guests without handing out the manage right. Their read routes accept either through require_any_permission, so nothing an existing manager could reach has moved. The navigation endpoint now returns only what the caller can actually reach. Without that a signed-out visitor gets a sidebar of the whole app where every link bounces off the login page, which reads as a broken site rather than as a closed door. A nav item may name several permissions; holding any one is enough, which is what GE-Enforce and Slides need now that a viewer and a manager hold different strings. Two things this found on the way: current_user is a LocalProxy, so `current_user is None` is False for a request with nobody behind it - harmless under a required JWT, an AttributeError the moment verification became optional. And an image pasted into a guide before its first save was never attached to it, so it was visible to editors and invisible to every reader; saving now attaches it, and `flask guides fix-images` repairs what is stored. The guides guest setting written earlier today is gone. Granting guides.view to the guest role replaces it, which is the point.
199 lines
7.6 KiB
Python
199 lines
7.6 KiB
Python
"""Personal API token management endpoints.
|
|
|
|
Any authenticated user may manage their OWN tokens; an admin may list or revoke
|
|
anyone's. Endpoints are jwt_required (a token must be bootstrapped from a real
|
|
login or an existing token). The full secret is returned ONCE, on create.
|
|
"""
|
|
|
|
from flask import Blueprint, request
|
|
from flask_jwt_extended import jwt_required, current_user
|
|
|
|
from shopdb.extensions import db
|
|
from shopdb.core.models import ApiToken, AuditLog
|
|
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
|
from shopdb.utils.authz import require_permission
|
|
from shopdb.utils.import_mode import parse_import_datetime
|
|
|
|
apitokens_bp = Blueprint('apitokens', __name__)
|
|
|
|
|
|
def _validate_scopes(scopes, owner):
|
|
"""Validate a scopes payload against the token OWNER. Return
|
|
(scopelist_or_none, error_response); the second is None when valid.
|
|
|
|
scopes may be None/absent (unscoped) or a list of permission names. Every
|
|
name must exist in the catalog AND be held by the owner (the scope ceiling):
|
|
a token can never grant more than its owner holds. Admins hold everything.
|
|
"""
|
|
if scopes is None:
|
|
return None, None
|
|
if not isinstance(scopes, list) or not all(isinstance(s, str) for s in scopes):
|
|
return None, error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'scopes must be a list of permission names')
|
|
unknown = ApiToken.unknown_scope_names(scopes)
|
|
if unknown:
|
|
return None, error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'Unknown permission names: ' + ', '.join(unknown))
|
|
# Scope ceiling: the owner must actually hold each scoped permission.
|
|
disallowed = [n for n in scopes if not owner.haspermission(n)]
|
|
if disallowed:
|
|
return None, error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'Permissions not held by the token owner: ' + ', '.join(disallowed))
|
|
return scopes, None
|
|
|
|
|
|
def _validate_resourcescopes(resourcescopes):
|
|
"""Validate a resourcescopes payload. Return (list_or_none, error_response).
|
|
|
|
resourcescopes is a DIFFERENT axis from scopes: it pins the token to a set
|
|
of resource names (today geenforce manifest scope names, e.g.
|
|
gea-shopfloor-display), not permission names, so there is no catalog to
|
|
check against - a plugin owns the meaning. None/absent = unrestricted.
|
|
"""
|
|
if resourcescopes is None:
|
|
return None, None
|
|
if not isinstance(resourcescopes, list) \
|
|
or not all(isinstance(s, str) for s in resourcescopes):
|
|
return None, error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'resourcescopes must be a list of resource-scope names')
|
|
return [s.strip() for s in resourcescopes if s.strip()], None
|
|
|
|
|
|
@apitokens_bp.route('', methods=['GET'])
|
|
@jwt_required()
|
|
def list_apitokens():
|
|
"""List the caller's own tokens. Admins may pass ?all=true for everyone's.
|
|
|
|
Never returns hashes or secrets.
|
|
"""
|
|
wants_all = request.args.get('all', 'false').lower() == 'true'
|
|
is_admin = current_user.hasrole('admin')
|
|
|
|
query = ApiToken.query
|
|
if wants_all and is_admin:
|
|
include_owner = True
|
|
else:
|
|
query = query.filter(ApiToken.userid == current_user.userid)
|
|
include_owner = False
|
|
|
|
query = query.order_by(ApiToken.createddate.desc())
|
|
tokens = [t.to_dict(include_owner=include_owner) for t in query.all()]
|
|
return success_response(tokens)
|
|
|
|
|
|
@apitokens_bp.route('', methods=['POST'])
|
|
@require_permission('apitokens.create')
|
|
def create_apitoken():
|
|
"""Create a token for the caller. Returns the full secret ONCE.
|
|
|
|
Optional scopes limit the token to a subset of the caller's own
|
|
permissions; absent/null means an unscoped token acting as the caller.
|
|
"""
|
|
data = request.get_json() or {}
|
|
|
|
name = (data.get('name') or '').strip()
|
|
if not name:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
|
|
|
|
expiresat = None
|
|
if data.get('expiresat'):
|
|
expiresat = parse_import_datetime(data.get('expiresat'))
|
|
if expiresat is None:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'expiresat is not a valid date/datetime')
|
|
|
|
scopelist, scope_error = _validate_scopes(data.get('scopes'), current_user)
|
|
if scope_error is not None:
|
|
return scope_error
|
|
|
|
resourcescopelist, resource_error = _validate_resourcescopes(
|
|
data.get('resourcescopes'))
|
|
if resource_error is not None:
|
|
return resource_error
|
|
|
|
secret = ApiToken.generate_secret()
|
|
token = ApiToken(
|
|
userid=current_user.userid,
|
|
name=name,
|
|
tokenprefix=ApiToken.prefix_of(secret),
|
|
tokenhash=ApiToken.hash_secret(secret),
|
|
expiresat=expiresat,
|
|
)
|
|
token.scopelist = scopelist
|
|
token.resourcescopelist = resourcescopelist
|
|
db.session.add(token)
|
|
db.session.flush()
|
|
|
|
AuditLog.log('created', 'ApiToken', entityid=token.tokenid, entityname=name)
|
|
db.session.commit()
|
|
|
|
result = token.to_dict()
|
|
# The secret appears here and NOWHERE else, ever. Not stored, not logged.
|
|
result['secret'] = secret
|
|
result['warning'] = ('Save this token now. It will not be shown again. '
|
|
'Store it somewhere safe.')
|
|
return success_response(result, message='Token created', http_code=201)
|
|
|
|
|
|
@apitokens_bp.route('/<int:tokenid>', methods=['PUT'])
|
|
@require_permission('apitokens.create')
|
|
def update_apitoken(tokenid: int):
|
|
"""Rename, rescope, or deactivate a token. Own token, or any if admin.
|
|
|
|
Scopes are validated against the token OWNER's permissions (the ceiling),
|
|
not the editor's - so an admin rescoping someone else's token still cannot
|
|
grant that owner more than the owner holds.
|
|
"""
|
|
token = db.session.get(ApiToken, tokenid)
|
|
if token is None:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Token not found', http_code=404)
|
|
|
|
if token.userid != current_user.userid and not current_user.hasrole('admin'):
|
|
return error_response(ErrorCodes.FORBIDDEN,
|
|
'You may only manage your own tokens', http_code=403)
|
|
|
|
data = request.get_json() or {}
|
|
if 'name' in data:
|
|
newname = (data.get('name') or '').strip()
|
|
if not newname:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'name cannot be empty')
|
|
token.name = newname
|
|
if 'isactive' in data:
|
|
token.isactive = bool(data['isactive'])
|
|
if 'scopes' in data:
|
|
scopelist, scope_error = _validate_scopes(data.get('scopes'), token.user)
|
|
if scope_error is not None:
|
|
return scope_error
|
|
token.scopelist = scopelist
|
|
if 'resourcescopes' in data:
|
|
resourcescopelist, resource_error = _validate_resourcescopes(
|
|
data.get('resourcescopes'))
|
|
if resource_error is not None:
|
|
return resource_error
|
|
token.resourcescopelist = resourcescopelist
|
|
|
|
db.session.commit()
|
|
return success_response(token.to_dict(), message='Token updated')
|
|
|
|
|
|
@apitokens_bp.route('/<int:tokenid>', methods=['DELETE'])
|
|
@require_permission('apitokens.create')
|
|
def revoke_apitoken(tokenid: int):
|
|
"""Revoke (deactivate) a token. Own token, or any if admin."""
|
|
token = db.session.get(ApiToken, tokenid)
|
|
if token is None:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Token not found', http_code=404)
|
|
|
|
if token.userid != current_user.userid and not current_user.hasrole('admin'):
|
|
return error_response(ErrorCodes.FORBIDDEN,
|
|
'You may only manage your own tokens', http_code=403)
|
|
|
|
token.isactive = False
|
|
AuditLog.log('deleted', 'ApiToken', entityid=token.tokenid, entityname=token.name)
|
|
db.session.commit()
|
|
return success_response(message='Token revoked')
|