Files
shopdb-flask/shopdb/core/api/apitokens.py
cproudlock 151d7c4f44 Anyone not signed in holds the guest role
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.
2026-08-27 12:51:58 -04:00

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')