Add personal API tokens; wire measuring tools into remaining surfaces
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:
@@ -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',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
120
shopdb/core/api/apitokens.py
Normal file
120
shopdb/core/api/apitokens.py
Normal 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')
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
89
shopdb/core/models/apitoken.py
Normal file
89
shopdb/core/models/apitoken.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
108
shopdb/utils/apitoken_auth.py
Normal file
108
shopdb/utils/apitoken_auth.py
Normal 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}'
|
||||
Reference in New Issue
Block a user