210 lines
8.3 KiB
Python
210 lines
8.3 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:
|
|
# Revocation is ONE WAY. A revoked token's secret is out there - that
|
|
# is why it was revoked - and re-activating the row made the same
|
|
# secret work again, so "revoked" meant "paused until somebody flips it
|
|
# back". Deactivating through this route is still allowed; bringing one
|
|
# back is not, and the answer says to mint a new one.
|
|
wanted = bool(data['isactive'])
|
|
if wanted and not token.isactive:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'A revoked token cannot be re-activated - its secret is '
|
|
'already out. Create a new token instead.')
|
|
token.isactive = wanted
|
|
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')
|