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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user