Three credential and authz findings, all the same shape: a second path to a
decision, with weaker rules than the path built for it.
PUT /api/apitokens/<id> {"isactive": true} un-revoked a token, and the ORIGINAL
secret worked again - so "revoked" meant "paused until somebody flips it back",
for a credential whose secret is assumed to be out by the act of revoking it.
Revocation is one way now. Deactivating through the same route still works;
bringing one back does not, and the refusal says to mint a new token.
PUT /api/users/<self> accepted a `password` field with no current-password
proof and no length rule, while /api/auth/change-password requires both. When
two routes reach the same state the weaker one is the real rule, so the stronger
one was decoration. Changing your own password through the users route is
refused and points at the right endpoint. An ADMIN may still set one - that is
how a temporary password is issued - with the same 8-character floor, and
mustchangepassword is now set, because a password somebody else chose is
temporary by definition.
tools.secrets was declared guest-grantable, so the role grid offered it and
stored it, and the secure-send route never consulted the guest role at all: it
decides on securesend_allow_guests, which fails closed and carries the rate
limit. Ticking the box changed nothing in either direction, so the screen and
the behaviour disagreed. The declaration is gone; tools.view stays, because it
really does gate the nav entry.
Removing it needed care: update_role refuses the WHOLE request when a submitted
permission is not grantable, and the grid resubmits what the role already holds,
so a site whose guest role held tools.secrets could no longer save that role at
all - the deadlock fixed in 723a4ff, re-created from the other end. Migration
7d41 revokes the grant from the guest role, and nothing is lost because it
granted nothing.
Eight tests. The first two verified by planting: remove either guard and three
fail.
Scope: product
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')
|