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

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