Add optional permission scopes to API tokens
All checks were successful
CI / backend (push) Successful in 1m19s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s

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>
This commit is contained in:
cproudlock
2026-07-12 08:58:31 -04:00
parent 688ff6646d
commit 848a8fb34f
13 changed files with 728 additions and 45 deletions

View File

@@ -2,11 +2,15 @@
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.
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
@@ -45,6 +49,10 @@ class ApiToken(BaseModel):
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'))
@@ -70,6 +78,33 @@ class ApiToken(BaseModel):
"""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 = {
@@ -80,6 +115,7 @@ class ApiToken(BaseModel):
'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,