Add personal API tokens; wire measuring tools into remaining surfaces
API tokens: any user mints named, optionally-expiring tokens (shopdb_pat_..., sha256-stored, secret shown once) at Settings > API Tokens; a before-request shim swaps a valid PAT for a request-scoped JWT of its owner, so the entire existing auth/authz/import-mode stack works unchanged and revoked/expired tokens 401 cleanly. Built for long-running scripts - the legacy import no longer dies when a login JWT expires. Migration 7d21_apitokens; create/revoke audit-logged. Audited integration gaps fixed: Asset.to_dict serializes measuring tools (typedata + pluginid - relationship links to tools resolve); map subtype filter/colors and MapEditor include them; dashboard totals count them; warranty links use a new by-asset route; the measuringtools ADR-010 hooks are real (corrected presentation token, implemented map-overlay endpoint); the login avatar resolves through the employee-photo helper. 737 tests pass; naming green; frontend builds; both features verified live end-to-end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -221,6 +221,16 @@ them under `instance/branding/`.
|
||||
| `saml_auto_create_users` | `true` | Auto-create users on first SAML login. |
|
||||
| `saml_admin_group` | (empty) | SAML group name that grants the admin role. |
|
||||
|
||||
**Personal API tokens.** Besides login JWTs and SAML, a user may create
|
||||
personal API tokens (PATs) for scripts and integrations, from Settings > API
|
||||
Tokens (or `POST /api/apitokens`). A PAT is sent like a JWT
|
||||
(`Authorization: Bearer shopdb_pat_...`), authenticates as its owning user
|
||||
across the whole API, and does not carry the hourly `JWT_ACCESS_TOKEN_EXPIRES`
|
||||
limit (it never expires unless an explicit expiry is set). Only the sha256 hash
|
||||
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.
|
||||
|
||||
### identifiers (dynamic)
|
||||
|
||||
One boolean key per asset identifier per asset type, keyed
|
||||
|
||||
@@ -28,15 +28,34 @@ Contents:
|
||||
|
||||
### Admin token
|
||||
|
||||
Every write needs a JWT, and import mode additionally needs an admin. Get one:
|
||||
Every write needs authentication, and import mode additionally needs an admin.
|
||||
|
||||
A large import can outlast a login JWT: `access_token` expires after one hour,
|
||||
so a long run dies mid-import with 401s. Use a **personal API token (PAT)**
|
||||
instead. A PAT never expires (unless you set an expiry), acts as the user that
|
||||
created it, and is sent exactly like a JWT. Create one as an admin (via the
|
||||
Settings > API Tokens page, or the API):
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:5001/api/auth/login \
|
||||
# Bootstrap: a short login JWT is fine just to mint the long-lived PAT.
|
||||
JWT=$(curl -s http://localhost:5001/api/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"<admin>","password":"<password>"}' | jq -r '.data.access_token'
|
||||
-d '{"username":"<admin>","password":"<password>"}' | jq -r '.data.access_token')
|
||||
|
||||
# The full secret (shopdb_pat_...) is returned ONCE. Save it now.
|
||||
curl -s http://localhost:5001/api/apitokens \
|
||||
-H "Authorization: Bearer $JWT" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name":"legacy import runner"}' | jq -r '.data.secret'
|
||||
```
|
||||
|
||||
Send it on every request as `Authorization: Bearer <token>`.
|
||||
Send the PAT on every request as `Authorization: Bearer shopdb_pat_...`. It
|
||||
authenticates the whole import surface (every create/update/delete plus import
|
||||
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.
|
||||
|
||||
A short-lived login JWT still works for quick one-off calls if you prefer.
|
||||
|
||||
### Import mode: the `X-Import-Mode` header
|
||||
|
||||
@@ -378,27 +397,26 @@ Each import-relevant list endpoint has an exact-match filter for its natural key
|
||||
|
||||
### Worked example
|
||||
|
||||
A small, dependency-free importer (`requests`) that logs in, does the
|
||||
lookup-then-upsert loop in import mode, supports a `--dry-run` flag, and reports
|
||||
errors without aborting the whole run:
|
||||
A small, dependency-free importer (`requests`) that authenticates with a PAT
|
||||
(so a multi-hour run cannot expire mid-import), does the lookup-then-upsert loop
|
||||
in import mode, supports a `--dry-run` flag, and reports errors without aborting
|
||||
the whole run:
|
||||
|
||||
```python
|
||||
import argparse
|
||||
import os
|
||||
import requests
|
||||
|
||||
BASE = "http://localhost:5001"
|
||||
|
||||
|
||||
class ImportClient:
|
||||
def __init__(self, username, password, dryrun=False):
|
||||
def __init__(self, token=None, dryrun=False):
|
||||
self.session = requests.Session()
|
||||
self.dryrun = dryrun
|
||||
resp = self.session.post(
|
||||
f"{BASE}/api/auth/login",
|
||||
json={"username": username, "password": password},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
token = resp.json()["data"]["access_token"]
|
||||
# A personal API token (shopdb_pat_...) does not expire like a login
|
||||
# JWT, so it survives a long import. See section 1 to mint one.
|
||||
token = token or os.environ["SHOPDB_TOKEN"]
|
||||
# X-Import-Mode makes createddate/modifieddate passthrough take effect.
|
||||
self.session.headers.update({
|
||||
"Authorization": f"Bearer {token}",
|
||||
@@ -448,12 +466,12 @@ def import_vendors(client, legacyrows):
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--user", required=True)
|
||||
parser.add_argument("--password", required=True)
|
||||
# PAT from the SHOPDB_TOKEN env var, or pass --token explicitly.
|
||||
parser.add_argument("--token", default=None)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
client = ImportClient(args.user, args.password, dryrun=args.dry_run)
|
||||
client = ImportClient(args.token, dryrun=args.dry_run)
|
||||
# read legacy rows from prodscratch (read-only) and call the import_* fns
|
||||
# in the order of section 2, keeping a legacy-id -> new-id map as you go.
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user