From 848a8fb34f69ff5e847bc7f0edf1cb1e6ce5f179 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Sun, 12 Jul 2026 08:58:31 -0400 Subject: [PATCH] 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 --- CHANGELOG.md | 19 ++ docs/CONFIG.md | 9 + docs/IMPORT-API.md | 7 + frontend/src/views/settings/ApiTokensList.vue | 262 ++++++++++++++++- migrations/versions/7d22_apitokens_scopes.py | 49 ++++ shopdb/core/api/apitokens.py | 55 +++- shopdb/core/models/apitoken.py | 40 ++- shopdb/core/models/user.py | 2 + shopdb/utils/apitoken_auth.py | 6 + shopdb/utils/authz.py | 38 ++- shopdb/utils/import_mode.py | 11 +- tests/test_core/test_apitokens.py | 263 ++++++++++++++++-- tests/test_core/test_authz.py | 12 +- 13 files changed, 728 insertions(+), 45 deletions(-) create mode 100644 migrations/versions/7d22_apitokens_scopes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fcc41d7..3db0a64 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 5e51785..a1e0761 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -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 diff --git a/docs/IMPORT-API.md b/docs/IMPORT-API.md index 1a99b6e..b6c6d50 100644 --- a/docs/IMPORT-API.md +++ b/docs/IMPORT-API.md @@ -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/`) 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 diff --git a/frontend/src/views/settings/ApiTokensList.vue b/frontend/src/views/settings/ApiTokensList.vue index 67eec6d..1227987 100644 --- a/frontend/src/views/settings/ApiTokensList.vue +++ b/frontend/src/views/settings/ApiTokensList.vue @@ -10,7 +10,9 @@ Personal access tokens let scripts and integrations authenticate as you without an hourly-expiring login session. Send the token as Authorization: Bearer shopdb_pat_.... 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.

Loading...
@@ -22,6 +24,7 @@ Name Token + Access Created Expires Last Used @@ -33,6 +36,7 @@ {{ token.name }} {{ token.displayprefix }}... + {{ scopeSummary(token) }} {{ formatDate(token.createddate) }} {{ token.expiresat ? formatDate(token.expiresat) : 'Never' }} {{ token.lastusedat ? formatDate(token.lastusedat) : 'Never' }} @@ -42,12 +46,14 @@ Active + - + No tokens yet @@ -67,6 +73,7 @@ Owner Name Token + Access Expires Last Used Status @@ -78,6 +85,7 @@ {{ token.username || '-' }} {{ token.name }} {{ token.displayprefix }}... + {{ scopeSummary(token) }} {{ token.expiresat ? formatDate(token.expiresat) : 'Never' }} {{ token.lastusedat ? formatDate(token.lastusedat) : 'Never' }} @@ -91,7 +99,7 @@ - + No tokens @@ -102,7 +110,7 @@