Add personal API tokens; wire measuring tools into remaining surfaces
Some checks failed
CI / naming (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / backend (push) Has been cancelled

API tokens: any user mints named, optionally-expiring tokens
(shopdb_pat_..., sha256-stored, secret shown once) at Settings > API
Tokens; a before-request shim swaps a valid PAT for a request-scoped
JWT of its owner, so the entire existing auth/authz/import-mode stack
works unchanged and revoked/expired tokens 401 cleanly. Built for
long-running scripts - the legacy import no longer dies when a login
JWT expires. Migration 7d21_apitokens; create/revoke audit-logged.

Audited integration gaps fixed: Asset.to_dict serializes measuring
tools (typedata + pluginid - relationship links to tools resolve); map
subtype filter/colors and MapEditor include them; dashboard totals
count them; warranty links use a new by-asset route; the measuringtools
ADR-010 hooks are real (corrected presentation token, implemented
map-overlay endpoint); the login avatar resolves through the
employee-photo helper.

737 tests pass; naming green; frontend builds; both features verified
live end-to-end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-12 08:33:02 -04:00
parent 64a5abdb08
commit da86b3ae0c
31 changed files with 1197 additions and 38 deletions

View File

@@ -86,6 +86,11 @@ def create_app(config_name: str = None) -> Flask:
# Register core blueprints
register_blueprints(app)
# Personal API token auth shim: recognize `Bearer shopdb_pat_...` before
# any JWT decode and mint a request-scoped JWT for the token's owner.
from .utils.apitoken_auth import install_apitoken_auth
install_apitoken_auth(app)
# Register CLI commands
register_cli_commands(app)
@@ -128,6 +133,7 @@ CORE_BLUEPRINT_NAMES = (
'customfields',
'setup',
'pluginui',
'apitokens',
)

View File

@@ -22,6 +22,7 @@ from .users import users_bp
from .customfields import customfields_bp
from .setup import setup_bp
from .pluginui import pluginui_bp
from .apitokens import apitokens_bp
__all__ = [
'auth_bp',
@@ -46,4 +47,5 @@ __all__ = [
'customfields_bp',
'setup_bp',
'pluginui_bp',
'apitokens_bp',
]

View File

@@ -0,0 +1,120 @@
"""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.import_mode import parse_import_datetime
apitokens_bp = Blueprint('apitokens', __name__)
@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'])
@jwt_required()
def create_apitoken():
"""Create a token for the caller. Returns the full secret ONCE."""
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')
secret = ApiToken.generate_secret()
token = ApiToken(
userid=current_user.userid,
name=name,
tokenprefix=ApiToken.prefix_of(secret),
tokenhash=ApiToken.hash_secret(secret),
expiresat=expiresat,
)
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'])
@jwt_required()
def update_apitoken(tokenid: int):
"""Rename or 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)
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'])
db.session.commit()
return success_response(token.to_dict(), message='Token updated')
@apitokens_bp.route('/<int:tokenid>', methods=['DELETE'])
@jwt_required()
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')

View File

@@ -913,6 +913,14 @@ def get_assets_map():
)
except (ImportError, AttributeError):
pass
try:
from plugins.measuringtools.models import MeasuringTool
eager_options.append(
subqueryload(Asset.measuringtool)
.joinedload(MeasuringTool.measuringtooltype)
)
except (ImportError, AttributeError):
pass
query = Asset.query.options(*eager_options).filter(
Asset.isactive == True,
@@ -941,7 +949,10 @@ def get_assets_map():
# Filter by subtype (depends on asset type) - case-insensitive matching
if subtype_id := request.args.get('subtype'):
subtype_id = int(subtype_id)
asset_type_lower = selected_assettype.lower() if selected_assettype else ''
# Normalize the underscore DB form (measuring_tool, network_device) to
# the space form the branches below compare against.
asset_type_lower = (
selected_assettype.lower().replace('_', ' ') if selected_assettype else '')
if asset_type_lower == 'machine':
try:
from plugins.machines.models import Machine
@@ -974,6 +985,15 @@ def get_assets_map():
)
except ImportError:
pass
elif asset_type_lower == 'measuring tool':
try:
from plugins.measuringtools.models import MeasuringTool
query = query.join(
MeasuringTool, MeasuringTool.assetid == Asset.assetid).filter(
MeasuringTool.measuringtooltypeid == subtype_id
)
except ImportError:
pass
# Filter by business unit
if bu_id := request.args.get('businessunitid'):
@@ -1100,6 +1120,14 @@ def get_assets_map():
except ImportError:
subtypes['Printer'] = []
try:
from plugins.measuringtools.models import MeasuringToolType
measuringtool_types = MeasuringToolType.query.filter(
MeasuringToolType.isactive == True).order_by(MeasuringToolType.name).all()
subtypes['Measuring Tool'] = [{'id': mt.measuringtooltypeid, 'name': mt.name, 'color': mt.color} for mt in measuringtool_types]
except ImportError:
subtypes['Measuring Tool'] = []
return success_response({
'assets': data,
'total': len(data),

View File

@@ -15,6 +15,7 @@ _TYPE_CATEGORY = {
'computer': 'PC',
'printer': 'Printer',
'network_device': 'Network',
'measuring_tool': 'Measuring Tool',
}
@@ -45,7 +46,8 @@ def get_dashboard():
pc_count = _count_by_type('computer')
network_count = _count_by_type('network_device')
printer_count = _count_by_type('printer')
total = machine_count + pc_count + network_count + printer_count
measuringtool_count = _count_by_type('measuring_tool')
total = machine_count + pc_count + network_count + printer_count + measuringtool_count
# Count by status
status_counts = db.session.query(
@@ -70,6 +72,7 @@ def get_dashboard():
'totalpc': pc_count,
'totalnetwork': network_count,
'totalprinter': printer_count,
'totalmeasuringtool': measuringtool_count,
'activeassets': status_dict.get('In Use', 0),
'inrepair': status_dict.get('In Repair', 0),
# Structured data
@@ -78,6 +81,7 @@ def get_dashboard():
'pcs': pc_count,
'networkdevices': network_count,
'printers': printer_count,
'measuringtools': measuringtool_count,
'total': total
},
'bystatus': status_dict,

View File

@@ -17,6 +17,7 @@ from .supportteam import SupportTeam, SupportTeamContact
from .setting import Setting
from .auditlog import AuditLog
from .customfield import CustomField, CustomFieldValue
from .apitoken import ApiToken
__all__ = [
# Base
@@ -62,4 +63,6 @@ __all__ = [
# Custom fields
'CustomField',
'CustomFieldValue',
# Personal API tokens
'ApiToken',
]

View File

@@ -0,0 +1,89 @@
"""Personal API token model.
A personal API token (PAT) lets a script or integration authenticate as a
user without the hourly-expiring login JWT. The secret is shown ONCE at
creation; only its sha256 hash is stored. The token acts as its owning user,
so the existing role/permission decorators authorize it unchanged.
"""
import hashlib
import secrets
from datetime import datetime, timezone
from shopdb.extensions import db
from .base import BaseModel
def _utcnow():
# naive UTC to match the other DB DateTime columns (stored without tzinfo)
return datetime.now(timezone.utc).replace(tzinfo=None)
# Wire label on the full secret. Scripts send "Authorization: Bearer <secret>".
TOKEN_SECRET_PREFIX = 'shopdb_pat_'
# Hex chars of randomness after the label (secrets.token_hex(20) => 40 hex).
_TOKEN_RANDOM_BYTES = 20
# How many leading random-hex chars we keep in the clear for display/lookup.
_TOKEN_PREFIX_LEN = 8
class ApiToken(BaseModel):
"""Personal API token. Stores only the hash of the secret."""
__tablename__ = 'apitokens'
tokenid = db.Column(db.Integer, primary_key=True)
# The token acts as this user; NOT NULL so authz always has a principal.
userid = db.Column(db.Integer, db.ForeignKey('users.userid'),
nullable=False, index=True)
# What the token is for (e.g. "legacy import runner").
name = db.Column(db.String(100), nullable=False)
# First few random-hex chars, kept clear so a user can tell tokens apart.
tokenprefix = db.Column(db.String(16), nullable=True, index=True)
# sha256 hex of the full secret. Unique so a hash lookup finds one row.
tokenhash = db.Column(db.String(64), unique=True, nullable=False, index=True)
# Null expiresat means the token never expires.
expiresat = db.Column(db.DateTime, nullable=True)
# Last time the token authenticated a request (throttled write).
lastusedat = db.Column(db.DateTime, nullable=True)
user = db.relationship('User', backref=db.backref('apitokens', lazy='dynamic'))
@staticmethod
def generate_secret() -> str:
"""Return a fresh full secret: shopdb_pat_<40 hex>. Never stored."""
return TOKEN_SECRET_PREFIX + secrets.token_hex(_TOKEN_RANDOM_BYTES)
@staticmethod
def hash_secret(secret: str) -> str:
"""sha256 hex of the full secret. The token has 160 bits of entropy,
so a plain hash lookup (not a slow password hash) is appropriate."""
return hashlib.sha256(secret.encode('utf-8')).hexdigest()
@staticmethod
def prefix_of(secret: str) -> str:
"""The clear display prefix (leading random-hex chars) of a secret."""
randompart = secret[len(TOKEN_SECRET_PREFIX):]
return randompart[:_TOKEN_PREFIX_LEN]
@property
def is_expired(self) -> bool:
"""True when expiresat is set and in the past."""
return self.expiresat is not None and self.expiresat < _utcnow()
def to_dict(self, include_owner: bool = False) -> dict:
"""Serialize for the API. NEVER includes the hash or the secret."""
result = {
'tokenid': self.tokenid,
'userid': self.userid,
'name': self.name,
'tokenprefix': self.tokenprefix,
'displayprefix': f'{TOKEN_SECRET_PREFIX}{self.tokenprefix or ""}',
'expiresat': self.expiresat.isoformat() + 'Z' if self.expiresat else None,
'lastusedat': self.lastusedat.isoformat() + 'Z' if self.lastusedat else None,
'isactive': self.isactive,
'isexpired': self.is_expired,
'createddate': self.createddate.isoformat() + 'Z' if self.createddate else None,
}
if include_owner:
result['username'] = self.user.username if self.user else None
return result

View File

@@ -234,6 +234,8 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
result['pluginid'] = self.network_device.networkdeviceid
elif hasattr(self, 'printer') and self.printer:
result['pluginid'] = self.printer.printerid
elif hasattr(self, 'measuringtool') and self.measuringtool:
result['pluginid'] = self.measuringtool.measuringtoolid
# Include inherited location if this asset has no location data
if include_inherited_location:
@@ -271,4 +273,7 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
# Check for printer extension
if hasattr(self, 'printer') and self.printer:
return self.printer.to_dict()
# Check for measuring-tool extension
if hasattr(self, 'measuringtool') and self.measuringtool:
return self.measuringtool.to_dict()
return None

View File

@@ -0,0 +1,108 @@
"""Personal API token (PAT) authentication shim.
A request may send `Authorization: Bearer shopdb_pat_...`. This is recognized
BEFORE any JWT decode: a before_request hook validates the PAT (hash lookup,
active, not expired, active owner) and, on success, mints a short internal
request-scoped JWT for the token's user and swaps it into the request's
Authorization header.
Why mint a JWT instead of only stashing the user on g: every write route in
this app stacks a mandatory @jwt_required() ABOVE @require_permission /
@require_role. That mandatory decorator decodes the Authorization header
itself, so the ONLY way a PAT reaches the whole existing auth+authz stack
(jwt_required, require_permission, require_role, import_mode, current_user,
get_jwt_identity) unchanged is to present a genuine JWT downstream. The minted
token lives only in this request's environ and is never returned to the client.
Result: a PAT authenticates any route a login JWT would, acting as its owner,
with zero changes to the authz decorators or import-mode helpers.
"""
from datetime import datetime, timezone
from flask import g, request
from flask_jwt_extended import create_access_token
from shopdb.extensions import db
from shopdb.core.models.apitoken import ApiToken, TOKEN_SECRET_PREFIX
from shopdb.utils.responses import error_response, ErrorCodes
# Only rewrite lastusedat when it is older than this, to avoid a DB write on
# every single request a busy integration makes.
_LASTUSED_THROTTLE_SECONDS = 60
def _utcnow():
return datetime.now(timezone.utc).replace(tzinfo=None)
def _extract_pat_secret():
"""Return the PAT secret from the Authorization header, or None."""
header = request.headers.get('Authorization', '')
parts = header.split()
if len(parts) == 2 and parts[0] == 'Bearer' \
and parts[1].startswith(TOKEN_SECRET_PREFIX):
return parts[1]
return None
def _resolve_pat(secret):
"""Validate a PAT secret. Return (token, user) or None."""
from shopdb.core.models import User
token = ApiToken.query.filter_by(
tokenhash=ApiToken.hash_secret(secret), isactive=True).first()
if token is None or token.is_expired:
return None
user = db.session.get(User, token.userid)
if user is None or not user.isactive:
return None
return token, user
def _touch_lastused(token):
"""Throttled lastusedat write. Independent commit; nothing else is pending
this early in the request, so it cannot clobber route work."""
now = _utcnow()
if token.lastusedat is None \
or (now - token.lastusedat).total_seconds() > _LASTUSED_THROTTLE_SECONDS:
token.lastusedat = now
db.session.commit()
def install_apitoken_auth(app):
"""Register the before_request PAT shim on the app."""
@app.before_request
def _apitoken_before_request():
secret = _extract_pat_secret()
if secret is None:
return
resolved = _resolve_pat(secret)
if resolved is None:
# The caller clearly meant to use a PAT (shopdb_pat_ prefix) but it
# is unknown, revoked, or expired. Reject with a clear 401 instead
# of letting the JWT decoder emit a confusing 422 on the non-JWT.
return error_response(
ErrorCodes.UNAUTHORIZED,
'Invalid, revoked, or expired API token',
http_code=401)
token, user = resolved
# Read claim inputs before the (possible) commit expires the instance.
claims = {
'username': user.username,
'roles': [role.rolename for role in user.roles],
}
# Expose the token/user for audit and introspection if a handler wants it.
g.apitokenid = token.tokenid
g.apitokenuser = user
_touch_lastused(token)
# Mint a request-scoped JWT for the owner and swap it into the header
# so the whole downstream auth stack authenticates as that user.
access_token = create_access_token(
identity=str(user.userid), additional_claims=claims)
request.environ['HTTP_AUTHORIZATION'] = f'Bearer {access_token}'