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:
19
CHANGELOG.md
19
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
49
migrations/versions/7d22_apitokens_scopes.py
Normal file
49
migrations/versions/7d22_apitokens_scopes.py
Normal 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')
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user