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

@@ -29,6 +29,25 @@ ADR-007 and ADR-002.
with a create modal that reveals the secret once (copy button) and an admin
All Tokens section. Docs: `docs/IMPORT-API.md` and `docs/CONFIG.md` updated to
recommend a PAT for imports. Core feature; no plugin-contract change.
- Optional permission scopes on personal API tokens. A token MAY carry a scopes
list (permission names, migration `7d22_apitokens_scopes` adds the nullable
`apitokens.scopes` JSON column); NULL keeps the original behavior (acts as its
owner). A scoped token grants ONLY the listed permissions, intersected with
what the owner actually holds at use time, and SUSPENDS the admin-role bypass,
so a scoped token minted by an admin is genuinely limited: it is denied on
role-gated (`require_role`) endpoints and gets no import mode. The shim mints
the request JWT with a `patscopes` claim that `require_permission`,
`require_role`, and `import_mode_active` read; normal login JWTs carry no such
claim and are unaffected (zero regression). Scopes are validated at write time
against the token OWNER's permissions (the scope ceiling - a token can never
grant more than its owner holds; when an admin edits another user's token the
ceiling is that owner's permissions), rejecting unknown or unheld names 400.
Minting/managing tokens now requires the new `apitokens.create` permission
(category `apitokens`; admins hold it by default, grantable via the roles UI)
rather than being open to any authenticated user. The Settings > API Tokens
create/edit modals gain a "Restrict permissions" section (a category-grouped
checkbox grid limited to the permissions the creator holds) and the token
lists show a full-access / N-permissions access chip.
- Vendor-model photos on asset detail heroes: computers and printers now
surface the linked model's `imageurl` in their extension payloads (the
field machines already exposed), and the machine, PC, printer, network

View File

@@ -231,6 +231,15 @@ is stored; the secret is shown once at creation. This is the recommended
credential for long-running imports (see `docs/IMPORT-API.md`). There is no env
var to configure; PATs are managed entirely through the API/UI.
Creating or managing a PAT requires the `apitokens.create` permission (admins
hold it by default; grant it to other roles from Settings > Users & Roles). By
default a PAT is unscoped and acts with the full authority of its owner. A PAT
may optionally carry a scopes list (a subset of the owner's permissions, capped
at what the owner actually holds): a scoped token grants ONLY those permissions,
intersected with the owner's live permissions at use time, and suspends the
admin bypass, so it is denied on role-gated (admin-only) endpoints and on import
mode. Use an unscoped token for admin-only work and imports.
### identifiers (dynamic)
One boolean key per asset identifier per asset type, keyed

View File

@@ -55,6 +55,13 @@ mode) as its owning admin, exactly as a login JWT would, but without the hourly
expiry. Revoke it from the same Settings page (or `DELETE /api/apitokens/<id>`)
when the import is done.
Use an **unscoped** token for imports. A token may optionally carry a scopes
list that limits it to specific permissions; a scoped token suspends the admin
bypass and is denied on role-gated endpoints AND on import mode, so it cannot
run an import. Leave the "Restrict permissions" option off (the default) so the
token acts with the full authority of its admin owner. Minting a token itself
requires the `apitokens.create` permission (admins have it by default).
A short-lived login JWT still works for quick one-off calls if you prefer.
### Import mode: the `X-Import-Mode` header

View File

@@ -10,7 +10,9 @@
Personal access tokens let scripts and integrations authenticate as you
without an hourly-expiring login session. Send the token as
<code>Authorization: Bearer shopdb_pat_...</code>. Ideal for long-running
imports that would otherwise die when the login JWT expires.
imports that would otherwise die when the login JWT expires. A token may
be restricted to a subset of your permissions; a restricted token cannot
reach admin-only (role-gated) endpoints or import mode.
</p>
<div v-if="loading" class="loading">Loading...</div>
@@ -22,6 +24,7 @@
<tr>
<th>Name</th>
<th>Token</th>
<th>Access</th>
<th>Created</th>
<th>Expires</th>
<th>Last Used</th>
@@ -33,6 +36,7 @@
<tr v-for="token in myTokens" :key="token.tokenid">
<td>{{ token.name }}</td>
<td><code>{{ token.displayprefix }}...</code></td>
<td><span class="badge" :class="scopeBadgeClass(token)">{{ scopeSummary(token) }}</span></td>
<td>{{ formatDate(token.createddate) }}</td>
<td>{{ token.expiresat ? formatDate(token.expiresat) : 'Never' }}</td>
<td>{{ token.lastusedat ? formatDate(token.lastusedat) : 'Never' }}</td>
@@ -42,12 +46,14 @@
<span v-else class="badge badge-success">Active</span>
</td>
<td class="actions">
<button v-if="token.isactive" class="btn btn-secondary btn-sm"
@click="openEdit(token)">Edit</button>
<button v-if="token.isactive" class="btn btn-danger btn-sm"
@click="confirmRevoke(token)">Revoke</button>
</td>
</tr>
<tr v-if="myTokens.length === 0">
<td colspan="7" style="text-align: center; color: var(--text-light);">
<td colspan="8" style="text-align: center; color: var(--text-light);">
No tokens yet
</td>
</tr>
@@ -67,6 +73,7 @@
<th>Owner</th>
<th>Name</th>
<th>Token</th>
<th>Access</th>
<th>Expires</th>
<th>Last Used</th>
<th>Status</th>
@@ -78,6 +85,7 @@
<td>{{ token.username || '-' }}</td>
<td>{{ token.name }}</td>
<td><code>{{ token.displayprefix }}...</code></td>
<td><span class="badge" :class="scopeBadgeClass(token)">{{ scopeSummary(token) }}</span></td>
<td>{{ token.expiresat ? formatDate(token.expiresat) : 'Never' }}</td>
<td>{{ token.lastusedat ? formatDate(token.lastusedat) : 'Never' }}</td>
<td>
@@ -91,7 +99,7 @@
</td>
</tr>
<tr v-if="allTokens.length === 0">
<td colspan="7" style="text-align: center; color: var(--text-light);">
<td colspan="8" style="text-align: center; color: var(--text-light);">
No tokens
</td>
</tr>
@@ -102,7 +110,7 @@
<!-- Create modal -->
<div v-if="showCreate" class="modal-overlay" @click.self="closeCreate">
<div class="modal">
<div class="modal modal-lg">
<div class="modal-header"><h3>New API Token</h3></div>
<form @submit.prevent="createToken">
<div class="modal-body">
@@ -116,6 +124,43 @@
<input id="tokenexpiry" v-model="form.expiresat" type="date" class="form-control" />
<small class="form-hint">Leave blank for a token that never expires.</small>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" v-model="form.restrict" />
Restrict permissions
</label>
<small class="form-hint">
Default is full access (the token acts as you). Restrict to grant
only the checked permissions. A restricted token cannot use
admin-only endpoints or import mode.
</small>
</div>
<div v-if="form.restrict" class="form-group">
<label>Allowed permissions</label>
<div class="permissions-grid">
<div v-for="(perms, category) in availableGrouped" :key="category"
class="permission-category">
<div class="category-header">
<label class="checkbox-label">
<input type="checkbox"
:checked="isCategoryFullySelected(category)"
:indeterminate.prop="isCategoryPartiallySelected(category)"
@change="toggleCategory(category, $event.target.checked)" />
<strong>{{ formatCategory(category) }}</strong>
</label>
</div>
<div class="category-perms">
<label v-for="p in perms" :key="p.name" class="checkbox-label perm-item">
<input type="checkbox" :value="p.name" v-model="form.scopes" />
{{ p.description }}
</label>
</div>
</div>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
@@ -128,6 +173,57 @@
</div>
</div>
<!-- Edit modal (scopes) -->
<div v-if="editing" class="modal-overlay" @click.self="closeEdit">
<div class="modal modal-lg">
<div class="modal-header"><h3>Edit token access</h3></div>
<form @submit.prevent="saveEdit">
<div class="modal-body">
<p class="tokens-intro">Editing <strong>{{ editing.name }}</strong>.</p>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" v-model="editForm.restrict" />
Restrict permissions
</label>
<small class="form-hint">
Uncheck for full access (acts as the owner).
</small>
</div>
<div v-if="editForm.restrict" class="form-group">
<label>Allowed permissions</label>
<div class="permissions-grid">
<div v-for="(perms, category) in availableGrouped" :key="category"
class="permission-category">
<div class="category-header">
<label class="checkbox-label">
<input type="checkbox"
:checked="isEditCategoryFullySelected(category)"
:indeterminate.prop="isEditCategoryPartiallySelected(category)"
@change="toggleEditCategory(category, $event.target.checked)" />
<strong>{{ formatCategory(category) }}</strong>
</label>
</div>
<div class="category-perms">
<label v-for="p in perms" :key="p.name" class="checkbox-label perm-item">
<input type="checkbox" :value="p.name" v-model="editForm.scopes" />
{{ p.description }}
</label>
</div>
</div>
</div>
</div>
<div v-if="editError" class="error-message">{{ editError }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeEdit">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</form>
</div>
</div>
<!-- Secret reveal modal (shown once) -->
<div v-if="newSecret" class="modal-overlay" @click.self="dismissSecret">
<div class="modal">
@@ -169,7 +265,7 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { apitokensApi } from '../../api'
import { apitokensApi, usersApi, authApi } from '../../api'
import { useAuthStore } from '../../stores/auth'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
@@ -183,22 +279,48 @@ const myTokens = ref([])
const allTokens = ref([])
const loading = ref(true)
// Permission catalog grouped by category, plus the permissions the current
// user actually holds (the create/edit grid never offers more than these).
const permissionsGrouped = ref({})
const myPermissions = ref([])
const showCreate = ref(false)
const saving = ref(false)
const error = ref('')
const form = ref({ name: '', expiresat: '' })
const form = ref({ name: '', expiresat: '', restrict: false, scopes: [] })
const editing = ref(null)
const editError = ref('')
const editForm = ref({ restrict: false, scopes: [] })
const newSecret = ref('')
const copied = ref(false)
const toRevoke = ref(null)
// Grid limited to permissions the current user holds. Admins see everything.
const availableGrouped = computed(() => {
const held = new Set(myPermissions.value)
const result = {}
for (const [category, perms] of Object.entries(permissionsGrouped.value)) {
const usable = isAdmin.value ? perms : perms.filter(p => held.has(p.name))
if (usable.length) result[category] = usable
}
return result
})
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const response = await apitokensApi.list()
myTokens.value = response.data.data || []
const [tokensRes, permsRes, meRes] = await Promise.all([
apitokensApi.list(),
usersApi.permissions.list(),
authApi.me()
])
myTokens.value = tokensRes.data.data || []
permissionsGrouped.value = permsRes.data.data.grouped || {}
myPermissions.value = meRes.data.data.permissions || []
if (isAdmin.value) {
const all = await apitokensApi.list({ all: true })
allTokens.value = all.data.data || []
@@ -215,8 +337,64 @@ function formatDate(value) {
return new Date(value).toLocaleDateString()
}
function formatCategory(category) {
return category.charAt(0).toUpperCase() + category.slice(1)
}
function scopeSummary(token) {
if (!token.scopes) return 'Full access'
const count = token.scopes.length
return count === 1 ? '1 permission' : `${count} permissions`
}
function scopeBadgeClass(token) {
return token.scopes ? 'badge-warning' : 'badge-success'
}
// --- Create grid helpers ---
function isCategoryFullySelected(category) {
const perms = availableGrouped.value[category] || []
return perms.length > 0 && perms.every(p => form.value.scopes.includes(p.name))
}
function isCategoryPartiallySelected(category) {
const perms = availableGrouped.value[category] || []
const selected = perms.filter(p => form.value.scopes.includes(p.name))
return selected.length > 0 && selected.length < perms.length
}
function toggleCategory(category, checked) {
const names = (availableGrouped.value[category] || []).map(p => p.name)
if (checked) {
for (const name of names) {
if (!form.value.scopes.includes(name)) form.value.scopes.push(name)
}
} else {
form.value.scopes = form.value.scopes.filter(n => !names.includes(n))
}
}
// --- Edit grid helpers ---
function isEditCategoryFullySelected(category) {
const perms = availableGrouped.value[category] || []
return perms.length > 0 && perms.every(p => editForm.value.scopes.includes(p.name))
}
function isEditCategoryPartiallySelected(category) {
const perms = availableGrouped.value[category] || []
const selected = perms.filter(p => editForm.value.scopes.includes(p.name))
return selected.length > 0 && selected.length < perms.length
}
function toggleEditCategory(category, checked) {
const names = (availableGrouped.value[category] || []).map(p => p.name)
if (checked) {
for (const name of names) {
if (!editForm.value.scopes.includes(name)) editForm.value.scopes.push(name)
}
} else {
editForm.value.scopes = editForm.value.scopes.filter(n => !names.includes(n))
}
}
function openCreate() {
form.value = { name: '', expiresat: '' }
form.value = { name: '', expiresat: '', restrict: false, scopes: [] }
error.value = ''
showCreate.value = true
}
@@ -225,10 +403,15 @@ function closeCreate() { showCreate.value = false }
async function createToken() {
error.value = ''
if (form.value.restrict && form.value.scopes.length === 0) {
error.value = 'Select at least one permission, or turn off Restrict permissions.'
return
}
saving.value = true
try {
const payload = { name: form.value.name }
if (form.value.expiresat) payload.expiresat = form.value.expiresat
if (form.value.restrict) payload.scopes = form.value.scopes
const response = await apitokensApi.create(payload)
showCreate.value = false
newSecret.value = response.data.data.secret
@@ -241,6 +424,36 @@ async function createToken() {
}
}
function openEdit(token) {
editing.value = token
editError.value = ''
editForm.value = {
restrict: !!token.scopes,
scopes: token.scopes ? [...token.scopes] : []
}
}
function closeEdit() { editing.value = null }
async function saveEdit() {
editError.value = ''
if (editForm.value.restrict && editForm.value.scopes.length === 0) {
editError.value = 'Select at least one permission, or turn off Restrict permissions.'
return
}
saving.value = true
try {
const payload = { scopes: editForm.value.restrict ? editForm.value.scopes : null }
await apitokensApi.update(editing.value.tokenid, payload)
editing.value = null
loadData()
} catch (err) {
editError.value = apiError(err, 'Failed to update token')
} finally {
saving.value = false
}
}
async function copySecret() {
try {
await navigator.clipboard.writeText(newSecret.value)
@@ -304,4 +517,35 @@ async function revokeToken() {
word-break: break-all;
font-size: 0.95rem;
}
.permissions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
max-height: 300px;
overflow-y: auto;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
}
.permission-category {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.5rem;
}
.category-header {
border-bottom: 1px solid var(--border);
padding-bottom: 0.5rem;
margin-bottom: 0.5rem;
}
.category-perms {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.perm-item {
font-size: 0.85rem;
padding: 0.25rem 0;
}
</style>

View File

@@ -0,0 +1,49 @@
"""API token permission scopes (apitokens.scopes)
Adds a nullable scopes column to apitokens: a JSON array of permission-name
strings. NULL means the token is unscoped and acts with the full authority of
its owner (the original behavior). A scoped token grants ONLY the listed
permissions, intersected with what the owner actually holds, and suspends the
admin-role bypass so a scoped token minted by an admin is genuinely limited.
Idempotent guard so it is safe on a partially-migrated box; real downgrade.
Revision ID: 7d22_apitokens_scopes
Revises: 7d21_apitokens
Create Date: 2026-07-12
"""
from alembic import op
import sqlalchemy as sa
revision = '7d22_apitokens_scopes'
down_revision = '7d21_apitokens'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'apitokens' not in insp.get_table_names():
return
columns = {c['name'] for c in insp.get_columns('apitokens')}
if 'scopes' in columns:
return
op.add_column('apitokens', sa.Column('scopes', sa.Text(), nullable=True))
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'apitokens' not in insp.get_table_names():
return
columns = {c['name'] for c in insp.get_columns('apitokens')}
if 'scopes' not in columns:
return
op.drop_column('apitokens', 'scopes')

View File

@@ -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)

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,

View File

@@ -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):

View File

@@ -95,6 +95,12 @@ def install_apitoken_auth(app):
'username': user.username,
'roles': [role.rolename for role in user.roles],
}
# A scoped token carries a patscopes claim; authz reads it to grant ONLY
# the listed permissions and to deny role gates + import mode. Unscoped
# tokens carry no such claim and mint exactly as a login JWT would.
scopelist = token.scopelist
if scopelist is not None:
claims['patscopes'] = scopelist
# Expose the token/user for audit and introspection if a handler wants it.
g.apitokenid = token.tokenid
g.apitokenuser = user

View File

@@ -21,13 +21,18 @@ Usage:
from functools import wraps
from flask_jwt_extended import verify_jwt_in_request, current_user
from flask_jwt_extended import verify_jwt_in_request, current_user, get_jwt
from shopdb.utils.responses import error_response, ErrorCodes
def require_permission(permission_name: str):
"""Gate a route behind a single permission. Admin role bypasses."""
"""Gate a route behind a single permission. Admin role bypasses.
A scoped personal API token (patscopes claim present) does NOT bypass: it
grants only the permissions in its scope list, intersected with what the
owner actually holds. See shopdb/utils/apitoken_auth.py.
"""
def decorator(view_func):
@wraps(view_func)
def wrapper(*args, **kwargs):
@@ -40,6 +45,22 @@ def require_permission(permission_name: str):
'Authentication required',
http_code=401
)
patscopes = get_jwt().get('patscopes')
if patscopes is not None:
# Scoped PAT: allowed only when this permission is in the scope
# list AND the owner actually holds it. haspermission still
# returns True for an admin owner (who legitimately holds
# everything), so the scope list is the real limiter - the
# admin bypass is suspended.
allowed = (permission_name in patscopes
and current_user.haspermission(permission_name))
if not allowed:
return error_response(
ErrorCodes.FORBIDDEN,
'This API token is not scoped for this action',
http_code=403
)
return view_func(*args, **kwargs)
if not current_user.haspermission(permission_name):
return error_response(
ErrorCodes.FORBIDDEN,
@@ -52,7 +73,12 @@ def require_permission(permission_name: str):
def require_role(rolename: str):
"""Gate a route behind a single role (e.g. 'admin')."""
"""Gate a route behind a single role (e.g. 'admin').
A scoped personal API token (patscopes claim present) is ALWAYS denied here:
scopes gate individual permissions, not roles, so role-gated admin surfaces
require an unscoped token. See shopdb/utils/apitoken_auth.py.
"""
def decorator(view_func):
@wraps(view_func)
def wrapper(*args, **kwargs):
@@ -63,6 +89,12 @@ def require_role(rolename: str):
'Authentication required',
http_code=401
)
if get_jwt().get('patscopes') is not None:
return error_response(
ErrorCodes.FORBIDDEN,
'Scoped API tokens cannot access role-gated endpoints',
http_code=403
)
if not current_user.hasrole(rolename):
return error_response(
ErrorCodes.FORBIDDEN,

View File

@@ -16,7 +16,7 @@ regular users. See docs/IMPORT-API.md for the operator manual.
from datetime import datetime, timezone
from flask import request
from flask_jwt_extended import verify_jwt_in_request, current_user
from flask_jwt_extended import verify_jwt_in_request, current_user, get_jwt
# Request header a migration client sets to opt a request into import mode.
@@ -48,7 +48,14 @@ def import_mode_active():
return False
verify_jwt_in_request(optional=True)
user = current_user
return bool(user is not None and user.hasrole('admin'))
if user is None or not user.hasrole('admin'):
return False
# A scoped PAT never gets import mode: import mode is an admin-role
# capability and a scoped token suspends the admin bypass. get_jwt is safe
# here - an admin user means a valid JWT was decoded above.
if get_jwt().get('patscopes') is not None:
return False
return True
def parse_import_datetime(value):

View File

@@ -10,7 +10,9 @@ admin.
from datetime import datetime, timedelta, timezone
from shopdb.core.models import ApiToken, Vendor
from werkzeug.security import generate_password_hash
from shopdb.core.models import ApiToken, Application, Vendor
from shopdb.extensions import db as _db
@@ -18,10 +20,12 @@ def _naive_utcnow():
return datetime.now(timezone.utc).replace(tzinfo=None)
def _create_token(client, headers, name='test token', expiresat=None):
def _create_token(client, headers, name='test token', expiresat=None, scopes=None):
body = {'name': name}
if expiresat is not None:
body['expiresat'] = expiresat
if scopes is not None:
body['scopes'] = scopes
response = client.post('/api/apitokens', json=body, headers=headers)
return response
@@ -30,6 +34,31 @@ def _pat_headers(secret):
return {'Authorization': f'Bearer {secret}'}
def _user_with_perms(client, db, username, permnames):
"""Create a non-admin user holding permnames, return (user, login headers).
Seeds the catalog so the named Permission rows exist, links them to a fresh
role, and logs the user in. Used to prove the apitokens.create gate and the
owner scope ceiling for non-admin owners.
"""
from shopdb.core.models import User, Role, Permission
Permission.seed()
role = Role(rolename=username + 'role')
db.session.add(role)
db.session.flush()
role.permissions = Permission.query.filter(
Permission.name.in_(permnames)).all()
user = User(username=username, email=username + '@test.local',
passwordhash=generate_password_hash('testpass'))
user.roles.append(role)
db.session.add(user)
db.session.commit()
login = client.post('/api/auth/login',
json={'username': username, 'password': 'testpass'})
headers = {'Authorization': f"Bearer {login.get_json()['data']['access_token']}"}
return user, headers
def test_create_returns_secret_once_and_stores_hash(client, db, auth_headers):
response = _create_token(client, auth_headers, name='import runner')
assert response.status_code == 201
@@ -57,10 +86,12 @@ def test_pat_authenticates_permission_write_as_owner(client, db, admin_user,
assert Vendor.query.filter_by(vendor='PAT Vendor').first() is not None
def test_pat_403_when_owner_lacks_permission(client, db, member_user,
member_headers):
"""A PAT owned by a role-less member is forbidden from an admin write."""
secret = _create_token(client, member_headers).get_json()['data']['secret']
def test_pat_403_when_owner_lacks_permission(client, db):
"""A PAT owned by a non-admin (who can mint tokens but is not admin) is
forbidden from an admin-role write."""
_user, headers = _user_with_perms(client, db, 'tokenmaker',
['apitokens.create'])
secret = _create_token(client, headers).get_json()['data']['secret']
response = client.post('/api/vendors', json={'vendor': 'Nope'},
headers=_pat_headers(secret))
@@ -110,32 +141,45 @@ def test_lastusedat_updates_on_use(client, db, admin_user, auth_headers):
def test_member_cannot_revoke_other_users_token(client, db, admin_user,
auth_headers, member_headers):
"""A role-less member cannot revoke a token owned by a different user."""
auth_headers):
"""A token-capable non-admin cannot revoke a token owned by someone else."""
secret = _create_token(client, auth_headers).get_json()['data']['secret']
tokenid = ApiToken.query.filter_by(
tokenhash=ApiToken.hash_secret(secret)).first().tokenid
response = client.delete(f'/api/apitokens/{tokenid}', headers=member_headers)
_user, headers = _user_with_perms(client, db, 'tokenmaker',
['apitokens.create'])
response = client.delete(f'/api/apitokens/{tokenid}', headers=headers)
assert response.status_code == 403
# Still active.
assert _db.session.get(ApiToken, tokenid).isactive is True
def test_member_can_manage_own_token(client, db, member_user, member_headers):
"""By design any authed user manages their OWN tokens."""
create = _create_token(client, member_headers, name='mine')
def test_roleless_member_cannot_create_token(client, db, member_user,
member_headers):
"""Creating a token now requires apitokens.create; a role-less member is
denied with 403."""
response = _create_token(client, member_headers, name='nope')
assert response.status_code == 403
def test_member_with_permission_can_manage_own_token(client, db):
"""A non-admin granted apitokens.create manages their OWN tokens."""
_user, headers = _user_with_perms(client, db, 'tokenmaker',
['apitokens.create'])
create = _create_token(client, headers, name='mine')
assert create.status_code == 201
tokenid = create.get_json()['data']['tokenid']
revoke = client.delete(f'/api/apitokens/{tokenid}', headers=member_headers)
revoke = client.delete(f'/api/apitokens/{tokenid}', headers=headers)
assert revoke.status_code == 200
assert _db.session.get(ApiToken, tokenid).isactive is False
def test_admin_all_true_lists_everyone(client, db, admin_user, auth_headers,
member_user, member_headers):
def test_admin_all_true_lists_everyone(client, db, admin_user, auth_headers):
_create_token(client, auth_headers, name='admin token')
member, member_headers = _user_with_perms(client, db, 'tokenmaker',
['apitokens.create'])
_create_token(client, member_headers, name='member token')
# Own-only (default) for admin: just the admin's token.
@@ -146,19 +190,20 @@ def test_admin_all_true_lists_everyone(client, db, admin_user, auth_headers,
everyone = client.get('/api/apitokens?all=true',
headers=auth_headers).get_json()['data']
userids = {t['userid'] for t in everyone}
assert admin_user.userid in userids and member_user.userid in userids
assert admin_user.userid in userids and member.userid in userids
assert any(t.get('username') for t in everyone)
def test_member_all_true_ignored(client, db, member_user, member_headers,
admin_user, auth_headers):
def test_member_all_true_ignored(client, db, admin_user, auth_headers):
"""A non-admin passing ?all=true still only sees their own tokens."""
_create_token(client, auth_headers, name='admin token')
member, member_headers = _user_with_perms(client, db, 'tokenmaker',
['apitokens.create'])
_create_token(client, member_headers, name='member token')
result = client.get('/api/apitokens?all=true',
headers=member_headers).get_json()['data']
assert all(t['userid'] == member_user.userid for t in result)
assert all(t['userid'] == member.userid for t in result)
def test_import_mode_works_over_pat(client, db, admin_user, auth_headers):
@@ -176,3 +221,183 @@ def test_import_mode_works_over_pat(client, db, admin_user, auth_headers):
vendor = Vendor.query.filter_by(vendor='Legacy Vendor').first()
assert vendor is not None
assert vendor.createddate == datetime(2019, 1, 2, 3, 4, 5)
# --- Scoped tokens -------------------------------------------------------
def test_scoped_token_passes_listed_permission(client, db, admin_user,
auth_headers):
"""A token scoped to applications.create can create an application."""
secret = _create_token(client, auth_headers,
scopes=['applications.create']).get_json()['data']['secret']
response = client.post('/api/applications', json={'appname': 'Scoped App'},
headers=_pat_headers(secret))
assert response.status_code == 201
assert Application.query.filter_by(appname='Scoped App').first() is not None
def test_scoped_token_403_on_unlisted_permission(client, db, admin_user,
auth_headers):
"""A token scoped to only applications.create cannot delete (needs
applications.delete, which is not in scope)."""
app = Application(appname='Target App')
_db.session.add(app)
_db.session.commit()
appid = app.appid
secret = _create_token(client, auth_headers,
scopes=['applications.create']).get_json()['data']['secret']
response = client.delete(f'/api/applications/{appid}',
headers=_pat_headers(secret))
assert response.status_code == 403
assert _db.session.get(Application, appid) is not None
def test_scoped_admin_token_does_not_bypass(client, db, admin_user, auth_headers):
"""An admin owner's scoped token is genuinely limited: the admin permission
bypass is suspended, so an unlisted action is 403 even for an admin."""
secret = _create_token(client, auth_headers,
scopes=['applications.create']).get_json()['data']['secret']
# settings.edit is not scoped -> 403 despite the owner being admin.
response = client.put('/api/settings/site_name',
json={'value': 'Hacked'}, headers=_pat_headers(secret))
assert response.status_code == 403
def test_scoped_token_denied_on_require_role_route(client, db, admin_user,
auth_headers):
"""Scoped tokens cannot pass a role gate. Vendor writes are require_role
admin, so even an admin-owned scoped token is denied."""
secret = _create_token(client, auth_headers,
scopes=['applications.create']).get_json()['data']['secret']
response = client.post('/api/vendors', json={'vendor': 'RoleGated'},
headers=_pat_headers(secret))
assert response.status_code == 403
assert Vendor.query.filter_by(vendor='RoleGated').first() is None
def test_unscoped_token_unchanged(client, db, admin_user, auth_headers):
"""Regression: an unscoped admin token still acts with full authority,
including role-gated writes."""
secret = _create_token(client, auth_headers).get_json()['data']['secret']
response = client.post('/api/vendors', json={'vendor': 'Unscoped Vendor'},
headers=_pat_headers(secret))
assert response.status_code == 201
token = ApiToken.query.filter_by(
tokenhash=ApiToken.hash_secret(secret)).first()
assert token.scopelist is None
def test_unknown_scope_name_rejected_at_create(client, db, admin_user,
auth_headers):
"""An unknown permission name in scopes is rejected 400, listing it."""
response = _create_token(client, auth_headers, scopes=['not.a.permission'])
assert response.status_code == 400
assert 'not.a.permission' in response.get_json()['data']['error']['message']
def test_scope_ceiling_rejects_permission_owner_lacks(client, db):
"""The scope ceiling: a non-admin cannot mint a token scoped to a
permission they do not hold."""
_user, headers = _user_with_perms(client, db, 'tokenmaker',
['apitokens.create'])
response = _create_token(client, headers, scopes=['applications.create'])
assert response.status_code == 400
assert 'applications.create' in response.get_json()['data']['error']['message']
def test_non_admin_scoped_token_capped_at_own_permissions(client, db):
"""A non-admin holding applications.create + apitokens.create may mint a
token scoped to applications.create and use it."""
_user, headers = _user_with_perms(
client, db, 'tokenmaker', ['apitokens.create', 'applications.create'])
secret = _create_token(client, headers,
scopes=['applications.create']).get_json()['data']['secret']
response = client.post('/api/applications', json={'appname': 'Capped App'},
headers=_pat_headers(secret))
assert response.status_code == 201
def test_scoped_token_intersects_at_use_time(client, db):
"""Defense in depth: if the owner loses a scoped permission after the token
is minted, the token can no longer use it (use-time intersection)."""
from shopdb.core.models import Role
_user, headers = _user_with_perms(
client, db, 'tokenmaker', ['apitokens.create', 'applications.create'])
secret = _create_token(client, headers,
scopes=['applications.create']).get_json()['data']['secret']
# Strip applications.create from the owner's role.
role = Role.query.filter_by(rolename='tokenmakerrole').first()
role.permissions = [p for p in role.permissions
if p.name != 'applications.create']
_db.session.commit()
response = client.post('/api/applications', json={'appname': 'Gone'},
headers=_pat_headers(secret))
assert response.status_code == 403
assert Application.query.filter_by(appname='Gone').first() is None
def test_import_mode_denied_over_scoped_admin_token(client, db, admin_user,
auth_headers):
"""A scoped admin token does NOT get import mode (an admin-role capability):
the createddate in the body is ignored and 'now' is stamped instead."""
secret = _create_token(
client, auth_headers,
scopes=['applications.create']).get_json()['data']['secret']
headers = _pat_headers(secret)
headers['X-Import-Mode'] = 'true'
before = _naive_utcnow()
response = client.post(
'/api/applications',
json={'appname': 'No Backdate', 'createddate': '2019-01-02 03:04:05'},
headers=headers)
assert response.status_code == 201
app = Application.query.filter_by(appname='No Backdate').first()
assert app is not None
# Import mode was denied, so createddate is ~now, not the 2019 value.
assert app.createddate >= before
def test_scopes_update_round_trip(client, db, admin_user, auth_headers):
"""Scopes can be set, changed, and cleared via PUT, and surface in GET."""
create = _create_token(client, auth_headers, scopes=['applications.create'])
tokenid = create.get_json()['data']['tokenid']
got = client.get('/api/apitokens', headers=auth_headers).get_json()['data']
row = next(t for t in got if t['tokenid'] == tokenid)
assert row['scopes'] == ['applications.create']
# Change scopes.
updated = client.put(f'/api/apitokens/{tokenid}',
json={'scopes': ['applications.edit']},
headers=auth_headers)
assert updated.status_code == 200
assert updated.get_json()['data']['scopes'] == ['applications.edit']
# Clear scopes (back to unscoped/full).
cleared = client.put(f'/api/apitokens/{tokenid}', json={'scopes': None},
headers=auth_headers)
assert cleared.status_code == 200
assert cleared.get_json()['data']['scopes'] is None
def test_update_scopes_ceiling_enforced(client, db):
"""PUT scopes is also capped at the owner's permissions."""
_user, headers = _user_with_perms(client, db, 'tokenmaker',
['apitokens.create'])
tokenid = _create_token(client, headers).get_json()['data']['tokenid']
response = client.put(f'/api/apitokens/{tokenid}',
json={'scopes': ['applications.delete']},
headers=headers)
assert response.status_code == 400
assert 'applications.delete' in response.get_json()['data']['error']['message']

View File

@@ -37,14 +37,10 @@ EXEMPT_BLUEPRINTS = {'auth', 'collector', 'setup'}
# role-less member MAY edit their own record, so it does not fit the
# 403-for-every-member contract this sweep asserts. The other-user 403 is
# covered by test_member_cannot_update_other_user below.
# apitokens.create_apitoken / update_apitoken / revoke_apitoken - personal
# API tokens. By design ANY authenticated user may create and manage their
# OWN tokens (own-resource logic, not a flat deny), so a role-less member
# gets 201/200 here, not the 403 this sweep asserts. The non-owner 403 is
# covered by test_apitokens.py (member cannot revoke another user's token).
EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user',
'apitokens.create_apitoken', 'apitokens.update_apitoken',
'apitokens.revoke_apitoken'}
# The apitokens create/update/revoke endpoints are NOT exempt: they now require
# the apitokens.create permission, so a role-less member gets the 403 this sweep
# asserts (ownership is still enforced inside the handler for non-admins).
EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user'}
@pytest.fixture(autouse=True)