A token may carry a scopes list: it then grants only those permissions, intersected with what the owner holds at use time, with the admin role bypass suspended and role-gated routes denied - a scoped token from an admin account is genuinely limited. Scope ceiling enforced at create/update too (only permissions the owner holds; 400 lists violations) and the picker only offers what you hold. Token management itself now requires the new apitokens.create permission (admin by default, grantable via roles). Unscoped tokens keep the exact prior act-as-owner behavior; imports need an unscoped admin token. Migration 7d22. 756 tests pass; live-verified scoped 201/403 matrix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
126 lines
5.3 KiB
Python
126 lines
5.3 KiB
Python
"""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. By default the token acts as its
|
|
owning user, so the existing role/permission decorators authorize it unchanged.
|
|
A token MAY optionally carry a scopes list (see scopes/scopelist): a scoped
|
|
token grants only the listed permissions, intersected with what the owner holds,
|
|
and suspends the admin bypass. See shopdb/utils/authz.py for the enforcement.
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
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)
|
|
# JSON array of permission-name strings. NULL = unscoped (full owner
|
|
# authority). A scoped token grants ONLY these, intersected with what the
|
|
# owner holds, and suspends the admin bypass. See scopelist below.
|
|
scopes = db.Column(db.Text, 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()
|
|
|
|
@property
|
|
def scopelist(self):
|
|
"""Parsed scope names as a list, or None when the token is unscoped."""
|
|
if self.scopes is None:
|
|
return None
|
|
try:
|
|
value = json.loads(self.scopes)
|
|
except (ValueError, TypeError):
|
|
return None
|
|
return value if isinstance(value, list) else None
|
|
|
|
@scopelist.setter
|
|
def scopelist(self, names):
|
|
"""Store a scope list, or None to clear scoping. Does NOT validate the
|
|
names; callers reject bad input first via unknown_scope_names."""
|
|
if names is None:
|
|
self.scopes = None
|
|
else:
|
|
self.scopes = json.dumps(list(names))
|
|
|
|
@staticmethod
|
|
def unknown_scope_names(names) -> list:
|
|
"""Return the subset of names that are not in the permission catalog."""
|
|
from shopdb.core.models.user import Permission
|
|
known = {name for name, _desc, _cat in Permission.PERMISSIONS}
|
|
return [n for n in names if n not in known]
|
|
|
|
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,
|
|
'scopes': self.scopelist,
|
|
'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
|