Add optional permission scopes to API tokens
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:
@@ -11,11 +11,40 @@ 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.authz import require_permission
|
||||
from shopdb.utils.import_mode import parse_import_datetime
|
||||
|
||||
apitokens_bp = Blueprint('apitokens', __name__)
|
||||
|
||||
|
||||
def _validate_scopes(scopes, owner):
|
||||
"""Validate a scopes payload against the token OWNER. Return
|
||||
(scopelist_or_none, error_response); the second is None when valid.
|
||||
|
||||
scopes may be None/absent (unscoped) or a list of permission names. Every
|
||||
name must exist in the catalog AND be held by the owner (the scope ceiling):
|
||||
a token can never grant more than its owner holds. Admins hold everything.
|
||||
"""
|
||||
if scopes is None:
|
||||
return None, None
|
||||
if not isinstance(scopes, list) or not all(isinstance(s, str) for s in scopes):
|
||||
return None, error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'scopes must be a list of permission names')
|
||||
unknown = ApiToken.unknown_scope_names(scopes)
|
||||
if unknown:
|
||||
return None, error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Unknown permission names: ' + ', '.join(unknown))
|
||||
# Scope ceiling: the owner must actually hold each scoped permission.
|
||||
disallowed = [n for n in scopes if not owner.haspermission(n)]
|
||||
if disallowed:
|
||||
return None, error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Permissions not held by the token owner: ' + ', '.join(disallowed))
|
||||
return scopes, None
|
||||
|
||||
|
||||
@apitokens_bp.route('', methods=['GET'])
|
||||
@jwt_required()
|
||||
def list_apitokens():
|
||||
@@ -40,8 +69,13 @@ def list_apitokens():
|
||||
|
||||
@apitokens_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('apitokens.create')
|
||||
def create_apitoken():
|
||||
"""Create a token for the caller. Returns the full secret ONCE."""
|
||||
"""Create a token for the caller. Returns the full secret ONCE.
|
||||
|
||||
Optional scopes limit the token to a subset of the caller's own
|
||||
permissions; absent/null means an unscoped token acting as the caller.
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
|
||||
name = (data.get('name') or '').strip()
|
||||
@@ -55,6 +89,10 @@ def create_apitoken():
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'expiresat is not a valid date/datetime')
|
||||
|
||||
scopelist, scope_error = _validate_scopes(data.get('scopes'), current_user)
|
||||
if scope_error is not None:
|
||||
return scope_error
|
||||
|
||||
secret = ApiToken.generate_secret()
|
||||
token = ApiToken(
|
||||
userid=current_user.userid,
|
||||
@@ -63,6 +101,7 @@ def create_apitoken():
|
||||
tokenhash=ApiToken.hash_secret(secret),
|
||||
expiresat=expiresat,
|
||||
)
|
||||
token.scopelist = scopelist
|
||||
db.session.add(token)
|
||||
db.session.flush()
|
||||
|
||||
@@ -79,8 +118,14 @@ def create_apitoken():
|
||||
|
||||
@apitokens_bp.route('/<int:tokenid>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('apitokens.create')
|
||||
def update_apitoken(tokenid: int):
|
||||
"""Rename or deactivate a token. Own token, or any if admin."""
|
||||
"""Rename, rescope, or deactivate a token. Own token, or any if admin.
|
||||
|
||||
Scopes are validated against the token OWNER's permissions (the ceiling),
|
||||
not the editor's - so an admin rescoping someone else's token still cannot
|
||||
grant that owner more than the owner holds.
|
||||
"""
|
||||
token = db.session.get(ApiToken, tokenid)
|
||||
if token is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Token not found', http_code=404)
|
||||
@@ -97,6 +142,11 @@ def update_apitoken(tokenid: int):
|
||||
token.name = newname
|
||||
if 'isactive' in data:
|
||||
token.isactive = bool(data['isactive'])
|
||||
if 'scopes' in data:
|
||||
scopelist, scope_error = _validate_scopes(data.get('scopes'), token.user)
|
||||
if scope_error is not None:
|
||||
return scope_error
|
||||
token.scopelist = scopelist
|
||||
|
||||
db.session.commit()
|
||||
return success_response(token.to_dict(), message='Token updated')
|
||||
@@ -104,6 +154,7 @@ def update_apitoken(tokenid: int):
|
||||
|
||||
@apitokens_bp.route('/<int:tokenid>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('apitokens.create')
|
||||
def revoke_apitoken(tokenid: int):
|
||||
"""Revoke (deactivate) a token. Own token, or any if admin."""
|
||||
token = db.session.get(ApiToken, tokenid)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -103,6 +103,8 @@ class Permission(db.Model):
|
||||
('users.delete', 'Delete users', 'admin'),
|
||||
# Audit
|
||||
('audit.view', 'View audit logs', 'admin'),
|
||||
# API tokens
|
||||
('apitokens.create', 'Create and manage API tokens', 'apitokens'),
|
||||
]
|
||||
|
||||
def __repr__(self):
|
||||
|
||||
Reference in New Issue
Block a user