geenforce: resource-scope binding for fetch tokens (0.15.0)

A geenforce.fetch token can now be pinned to specific manifest scopes so a
fleet-wide key (a display's, delivered by DSC or baked into the image) is not a
skeleton key for the whole content store. NULL binding = unrestricted, so every
existing service token keeps working.

Core:
- ApiToken.resourcescopes column + resourcescopelist property (migration
  7d30_apitoken_resourcescopes; NULL = unrestricted).
- apitokens API create/update accept + persist an optional resourcescopes list
  (a resource-name allowlist; not permission-catalog names).
- New contract helper authorized_service_token(scope): same check as
  service_token_authorized but returns the ApiToken so a plugin can read its
  binding. Contract 0.14.0 -> 0.15.0; also export SupportTeam.

GE-Enforce enforcement:
- get_manifest: a bound token requesting a scope outside its allowlist -> 403.
- get_payload: a bound token may only pull a blob its own scope(s) reference
  (service.blob_referenced_by_scopes); anything else -> 404 (no hash probing).
- Decorator stashes the authorized token on g for the route to read.

Also fixes a pre-existing contract-surface violation: the printers/printedparts
alert helpers imported shopdb.core.models / shopdb.extensions directly; now
via shopdb.api (SupportTeam newly exported). Docs: GE-ENFORCE-DISPLAY.md
provisioning note, PLUGIN-HOOKS.md, CLAUDE.md.

9 new resource-binding tests; full suite 1131 passing.
This commit is contained in:
cproudlock
2026-07-23 09:02:42 -04:00
parent d0bf37ced7
commit 75386d2f51
15 changed files with 344 additions and 25 deletions

View File

@@ -45,7 +45,7 @@ Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) largely
### Active state
- 1077 tests, naming/style check green, Gitea Actions CI (backend + naming + frontend build + a lean-build job + a migrations-mysql job that runs the real fresh upgrade on utf8mb4 MySQL 8)
- `__contract_version__` at 0.14.0 (0.12.0 mailer, 0.13.0 User/Role, 0.14.0 send_webhook) (product `__version__` 0.7.0, tags v0.5.0/v0.6.0/v0.7.0 - distinct series, ADR-007)
- `__contract_version__` at 0.15.0 (0.12.0 mailer, 0.13.0 User/Role, 0.14.0 send_webhook, 0.15.0 authorized_service_token) (product `__version__` 0.7.0, tags v0.5.0/v0.6.0/v0.7.0 - distinct series, ADR-007)
- 13 bundled plugins all satisfy contract: computers, employees, geenforce, knowledgebase, machines, measuringtools, network, notifications, printedparts, printers, slides, usb, warranty
- Core Alembic chain: baseline `68b3947ae14f` -> head `7d26_settings_description_text` (33 core migrations). Each plugin owns its own chain (ADR-008); deploy runs `flask db upgrade` then `flask plugin upgrade-all`. Reproducible + idempotent from empty (env.py relaxes session sql_mode so the chain runs on strict MySQL 8).
- Lean per-site builds (ADR-013 + ADR-014): `scripts/build-site.sh` (backend) + `SITE_PLUGINS` via `scripts/stage-frontend.mjs` (frontend) ship only chosen plugins; `flask plugin prune-schema` drops non-installed plugins' tables at provisioning. Sidebar nav / settings / Displays all gate on staged routes. Manifest-less `plugins/<name>/frontend/` dirs (e.g. `applications`) are core and always ship.

View File

@@ -1,6 +1,6 @@
# GE-Enforce: the gea-shopfloor-display scope
Displays are the share-less corner of the fleet. They are Intune/Entra-joined,
Displays are the share-less corner of the fleet. They are Entra-joined,
credential-less kiosk PCs that pull their manifest over HTTPS on port 443 and
authenticate with a read-only service PAT scoped `geenforce.fetch`, sent as
`X-API-Key`. They have no SMB share mount. The kiosk engine and the kiosk
@@ -8,6 +8,25 @@ browser are baked into the display image, not shipped over HTTPS, so the display
manifest heals POLICY / CONFIG drift only, never EXEs. It is self-sufficient and
does not inherit the fleet-wide `common` scope (see below).
## The display fetch token MUST be resource-bound
The same read-only key ships to every display (delivered by DSC, or baked into
the image), so it must not be a skeleton key for the whole content store. Mint
the display token bound to just this scope, so a leak cannot pull any other
scope's manifest or any blob by hash:
```
POST /api/apitokens
{ "name": "display fetch", "scopes": ["geenforce.fetch"],
"resourcescopes": ["gea-shopfloor-display"] }
```
With `resourcescopes` set, `GET /manifest?pctype=<other>` returns 403 and
`GET /payload/<sha>` returns 404 for any blob the display scope does not
reference. `resourcescopes` NULL (unset) = unrestricted, for back-compat with
existing service tokens. Rotate by minting a new bound token and revoking the
old one (deactivate it server-side); DSC re-delivers, or re-image.
There are three display subtypes, selected by `C:\Enrollment\display-type.txt`:
`Dashboard`, `Lobby`, and `3DPrintRoom`.

View File

@@ -9,7 +9,7 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra
The framework declares its contract version in `shopdb/__init__.py`:
```python
__contract_version__ = '0.14.0'
__contract_version__ = '0.15.0'
```
Each plugin's `manifest.json` declares the range of contract versions it supports:
@@ -497,6 +497,12 @@ What `shopdb.api` exposes:
(`service_token_authorized(scope)` returns True when the request carries a
managed service token scoped for `scope` whose owner holds that permission -
for unattended plugin endpoints like the GE-Enforce fetch API)
- `authorized_service_token(scope)` (0.15.0) - same check as
`service_token_authorized` but returns the `ApiToken` itself (or None), so a
plugin can honor the token's optional resource binding
(`token.resourcescopelist`: an allowlist of resource names the token may
reach, NULL = unrestricted). GE-Enforce uses it to pin a display's fetch
token to its own manifest scope + that scope's blobs.
- Helpers: `audit_log`, `resolve_asset_position`, `resolve_dualpath_pairs`,
`dualpath_single_machine_enabled`
- Import mode: `apply_import_timestamps`, `import_mode_active`,
@@ -506,6 +512,8 @@ What `shopdb.api` exposes:
`cmmc_usb_connection`
- `User` / `Role` (0.13.0) - the account and role models, e.g. resolving
alert recipients' emails from selected user ids or role membership
- `SupportTeam` (0.15.0) - the support-team model (carries a `webhookurl`), so
an alerting plugin can route a notification to a chosen team's Teams webhook
- Mailer (0.12.0): `send_email(to, subject, html, text=None)` and
`send_alert(subject, html, text=None)` - settings-first, no-op safe when
email is unconfigured; send_alert targets the site's alert_recipients

View File

@@ -0,0 +1,36 @@
"""apitokens.resourcescopes: pin a service token to specific resource scopes.
A geenforce.fetch token handed to a fleet (e.g. displays) should reach only its
own manifest scope(s) and the blobs those scopes ship, not every scope by name
or every blob by hash. This nullable JSON column carries that allowlist; NULL =
unrestricted, so every existing token keeps working unchanged. Idempotent.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '7d30_apitoken_resourcescopes'
down_revision = '7d29_supportteam_webhookurl'
branch_labels = None
depends_on = None
def _has_column(bind, table, column):
inspector = sa.inspect(bind)
if table not in inspector.get_table_names():
return False
return column in {c['name'] for c in inspector.get_columns(table)}
def upgrade():
bind = op.get_bind()
if not _has_column(bind, 'apitokens', 'resourcescopes'):
op.add_column('apitokens',
sa.Column('resourcescopes', sa.Text(), nullable=True))
def downgrade():
bind = op.get_bind()
if _has_column(bind, 'apitokens', 'resourcescopes'):
op.drop_column('apitokens', 'resourcescopes')

View File

@@ -12,13 +12,13 @@ import os
import time
from functools import wraps
from flask import Blueprint, request, Response, send_file, current_app
from flask import Blueprint, request, Response, send_file, current_app, g
from flask_jwt_extended import jwt_required
from sqlalchemy.exc import IntegrityError
from shopdb.api import (
db, cache, success_response, error_response, ErrorCodes, require_permission,
service_token_authorized, Setting, Application,
authorized_service_token, Setting, Application,
)
SHAREROOT_SETTING = 'geenforce_share_root'
@@ -51,7 +51,12 @@ def _require_service_token(scope):
def wrapper(f):
@wraps(f)
def decorated(*args, **kwargs):
if service_token_authorized(scope):
token = authorized_service_token(scope)
if token is not None:
# Stash for the route so it can honor the token's resource
# binding (token.resourcescopelist restricts which manifest
# scopes + blobs this token may pull; None = unrestricted).
g.geenforce_token = token
return f(*args, **kwargs)
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
http_code=401)
@@ -59,6 +64,14 @@ def _require_service_token(scope):
return wrapper
def _token_resource_scopes():
"""Resource-scope allowlist for the authorized token, or None if the token
may reach any scope. A bound token (e.g. a display's) is pinned to its own
manifest scope(s) so a leaked key cannot pull every scope's manifest+blobs."""
token = getattr(g, 'geenforce_token', None)
return token.resourcescopelist if token is not None else None
require_fetch_token = _require_service_token(FETCH_SCOPE)
require_report_token = _require_service_token(REPORT_SCOPE)
@@ -80,6 +93,11 @@ def get_manifest():
return error_response(ErrorCodes.VALIDATION_ERROR,
'pctype is required', http_code=400)
allowed = _token_resource_scopes()
if allowed is not None and scopename not in allowed:
return error_response(ErrorCodes.FORBIDDEN,
'token is not allowed this scope', http_code=403)
scope = ManifestScope.query.filter_by(
scopename=scopename, phase=phase).first()
if not scope:
@@ -178,6 +196,15 @@ def get_payload(sha256):
if len(sha) != 64 or any(c not in '0123456789abcdef' for c in sha):
return error_response(ErrorCodes.VALIDATION_ERROR, 'bad sha256',
http_code=400)
# A resource-bound token may only pull a blob its own scope(s) reference.
# Return 404 (not 403) so it cannot probe which hashes exist. Checked before
# the 304 shortcut so a bound token cannot even confirm a hash via ETag.
allowed = _token_resource_scopes()
if allowed is not None and not service.blob_referenced_by_scopes(sha, allowed):
return error_response(ErrorCodes.NOT_FOUND, 'no such payload',
http_code=404)
etag = f'"{sha}"'
if request.headers.get('If-None-Match') == etag:
return Response(status=304, headers={'ETag': etag})

View File

@@ -8,6 +8,7 @@ Kept out of the CLI and routes so both share one implementation:
"""
import hashlib
import json
import os
import tempfile
from datetime import datetime, timezone
@@ -288,6 +289,34 @@ def blob_path(sha256):
return os.path.join(_payload_dir(), sha256)
def blob_referenced_by_scopes(sha256, scopenames):
"""True when any of these scopes' CURRENT published manifest references
sha256 as an entry payload.
Backs the resource-bound token check on GET /payload: a token pinned to its
own scope(s) may only pull blobs those scopes actually ship, not any blob by
hash. Empty scopenames -> False (a bound-but-empty token reaches nothing).
"""
if not scopenames:
return False
rows = db.session.query(ManifestPublishedVersion).join(
ManifestScope,
ManifestPublishedVersion.scopeid == ManifestScope.scopeid,
).filter(
ManifestScope.scopename.in_(list(scopenames)),
ManifestPublishedVersion.iscurrent == True, # noqa: E712
).all()
for row in rows:
try:
doc = json.loads(row.manifestjson)
except (ValueError, TypeError):
continue
for entry in doc.get('Applications', []) or []:
if entry.get('PayloadSha256') == sha256:
return True
return False
def store_blob(rawbytes, filename, contenttype=None):
"""Store bytes in the content-addressed payload store; return the sha256.

View File

@@ -380,7 +380,7 @@ def _alert_team_webhook():
if not team_id:
return None
try:
from shopdb.core.models import SupportTeam
from shopdb.api import SupportTeam
team = db.session.get(SupportTeam, int(team_id))
except (ValueError, TypeError):
return None

View File

@@ -7,8 +7,7 @@ printer + supply. One row per (printerid, supplykey); supplykey is the toner
color (black/cyan/magenta/yellow) or the raw item name when color is unknown.
"""
from shopdb.extensions import db
from shopdb.core.models.base import BaseModel
from shopdb.api import db, BaseModel
class PrinterSupplyAlert(BaseModel):

View File

@@ -85,7 +85,7 @@ def alert_team_webhook():
if not team_id:
return None
try:
from shopdb.core.models import SupportTeam
from shopdb.api import SupportTeam
team = db.session.get(SupportTeam, int(team_id))
except (ValueError, TypeError):
return None

View File

@@ -36,7 +36,7 @@ from .plugins import plugin_manager
# unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped
# managed service token without importing core token internals. Additive name
# on the import surface, minor bump.
__contract_version__ = '0.14.0'
__contract_version__ = '0.15.0'
# Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent

View File

@@ -46,6 +46,7 @@ from shopdb.core.models import (
RelationshipType,
User,
Role,
SupportTeam,
)
# Response + pagination helpers for plugin API blueprints
@@ -63,7 +64,9 @@ from shopdb.utils.authz import require_permission, require_role
# Service-token authorization for unattended plugin endpoints (collector,
# GE-Enforce fetch, ...): checks a scoped managed token without exposing token
# internals.
from shopdb.utils.apitoken_auth import service_token_authorized
from shopdb.utils.apitoken_auth import (
service_token_authorized, authorized_service_token,
)
# Import-mode helpers: preserve legacy timestamps during a bulk data import
from shopdb.utils.import_mode import (
@@ -263,6 +266,8 @@ __all__ = [
'require_permission',
'require_role',
'service_token_authorized',
'authorized_service_token',
'SupportTeam',
# Import-mode helpers
'apply_import_timestamps',
'import_mode_active',

View File

@@ -45,6 +45,24 @@ def _validate_scopes(scopes, owner):
return scopes, None
def _validate_resourcescopes(resourcescopes):
"""Validate a resourcescopes payload. Return (list_or_none, error_response).
resourcescopes is a DIFFERENT axis from scopes: it pins the token to a set
of resource names (today geenforce manifest scope names, e.g.
gea-shopfloor-display), not permission names, so there is no catalog to
check against - a plugin owns the meaning. None/absent = unrestricted.
"""
if resourcescopes is None:
return None, None
if not isinstance(resourcescopes, list) \
or not all(isinstance(s, str) for s in resourcescopes):
return None, error_response(
ErrorCodes.VALIDATION_ERROR,
'resourcescopes must be a list of resource-scope names')
return [s.strip() for s in resourcescopes if s.strip()], None
@apitokens_bp.route('', methods=['GET'])
@jwt_required()
def list_apitokens():
@@ -93,6 +111,11 @@ def create_apitoken():
if scope_error is not None:
return scope_error
resourcescopelist, resource_error = _validate_resourcescopes(
data.get('resourcescopes'))
if resource_error is not None:
return resource_error
secret = ApiToken.generate_secret()
token = ApiToken(
userid=current_user.userid,
@@ -102,6 +125,7 @@ def create_apitoken():
expiresat=expiresat,
)
token.scopelist = scopelist
token.resourcescopelist = resourcescopelist
db.session.add(token)
db.session.flush()
@@ -147,6 +171,12 @@ def update_apitoken(tokenid: int):
if scope_error is not None:
return scope_error
token.scopelist = scopelist
if 'resourcescopes' in data:
resourcescopelist, resource_error = _validate_resourcescopes(
data.get('resourcescopes'))
if resource_error is not None:
return resource_error
token.resourcescopelist = resourcescopelist
db.session.commit()
return success_response(token.to_dict(), message='Token updated')

View File

@@ -53,6 +53,13 @@ class ApiToken(BaseModel):
# 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)
# JSON array of RESOURCE-scope names (a different axis from `scopes`, which
# is what the token may DO). Today these are geenforce manifest scope names
# (e.g. gea-shopfloor-display): a resource-bound geenforce.fetch token may
# only pull those scopes' manifests and only blobs those manifests
# reference. NULL = unrestricted (any resource), for back-compat. See
# resourcescopelist below.
resourcescopes = db.Column(db.Text, nullable=True)
user = db.relationship('User', backref=db.backref('apitokens', lazy='dynamic'))
@@ -98,6 +105,25 @@ class ApiToken(BaseModel):
else:
self.scopes = json.dumps(list(names))
@property
def resourcescopelist(self):
"""Parsed resource-scope names, or None when the token is unrestricted."""
if self.resourcescopes is None:
return None
try:
value = json.loads(self.resourcescopes)
except (ValueError, TypeError):
return None
return value if isinstance(value, list) else None
@resourcescopelist.setter
def resourcescopelist(self, names):
"""Store a resource-scope list, or None to clear the restriction."""
if names is None:
self.resourcescopes = None
else:
self.resourcescopes = json.dumps(list(names))
@staticmethod
def unknown_scope_names(names) -> list:
"""Return the subset of names that are not in the permission catalog.
@@ -119,6 +145,7 @@ class ApiToken(BaseModel):
'expiresat': self.expiresat.isoformat() + 'Z' if self.expiresat else None,
'lastusedat': self.lastusedat.isoformat() + 'Z' if self.lastusedat else None,
'scopes': self.scopelist,
'resourcescopes': self.resourcescopelist,
'isactive': self.isactive,
'isexpired': self.is_expired,
'createddate': self.createddate.isoformat() + 'Z' if self.createddate else None,

View File

@@ -76,16 +76,13 @@ def touch_apitoken_lastused(token):
db.session.commit()
def service_token_authorized(scope):
"""True when the current request carries a managed token scoped for `scope`
whose owner is active and holds that permission. Accepts X-API-Key or a
Bearer PAT (the before_request shim resolves Bearer into g.apitokenid).
Touches lastusedat on success.
def authorized_service_token(scope):
"""Return the ApiToken authorizing this request for `scope`, or None.
The single contract-surface entry point for unattended SERVICE tokens
(collector.ingest, geenforce.fetch, ...), so plugins authorize a service
token without reaching into core token internals. Returns False on any
miss; the caller returns its own 401.
Same checks as service_token_authorized (managed token scoped for `scope`,
active owner holding the permission), but hands back the token itself so a
caller can read its resource binding (token.resourcescopelist) without
reaching into core token internals. Touches lastusedat on success.
"""
from shopdb.core.models import User
@@ -99,15 +96,29 @@ def service_token_authorized(scope):
if tokenid is not None:
token = db.session.get(ApiToken, tokenid)
if token is None:
return False
return None
scopelist = token.scopelist
if not scopelist or scope not in scopelist:
return False
return None
user = db.session.get(User, token.userid)
if user is None or not user.isactive or not user.haspermission(scope):
return False
return None
touch_apitoken_lastused(token)
return True
return token
def service_token_authorized(scope):
"""True when the current request carries a managed token scoped for `scope`
whose owner is active and holds that permission. Accepts X-API-Key or a
Bearer PAT (the before_request shim resolves Bearer into g.apitokenid).
Touches lastusedat on success.
The single contract-surface entry point for unattended SERVICE tokens
(collector.ingest, geenforce.fetch, ...), so plugins authorize a service
token without reaching into core token internals. Returns False on any
miss; the caller returns its own 401.
"""
return authorized_service_token(scope) is not None
def install_apitoken_auth(app):

View File

@@ -0,0 +1,128 @@
"""Resource-scope binding on geenforce fetch tokens.
A geenforce.fetch token MAY be pinned to specific manifest scopes
(ApiToken.resourcescopes). A bound token can only pull those scopes' manifests
and only the blobs those scopes reference; an unbound token (NULL) keeps the
original behavior. This is what lets a fleet-wide display key be handed out
without exposing every scope + every blob in the content store.
"""
import hashlib
from plugins.geenforce import service
DISPLAY = 'gea-shopfloor-display'
OTHER = 'gea-shopfloor-cmm'
KIOSK_BYTES = b'kiosk-installer-payload-bytes'
KIOSK_SHA = hashlib.sha256(KIOSK_BYTES).hexdigest()
def _publish_display_scope(app):
"""Publish a display scope whose one entry references KIOSK_SHA over http."""
with app.app_context():
service.store_blob(KIOSK_BYTES, 'kiosk.exe', 'application/octet-stream')
manifest = {
'Version': '2.0',
'Applications': [
{'Name': 'Kiosk', 'Type': 'EXE', 'Installer': 'apps/kiosk.exe',
'DetectionMethod': 'Always',
'PayloadSource': 'http', 'PayloadSha256': KIOSK_SHA},
],
}
service.replace_scope_draft(DISPLAY, 'runtime', manifest)
service.publish_scope(DISPLAY, 'runtime', notes='display')
# A second, unrelated scope + blob the display token must NOT reach.
service.store_blob(b'secret-other-scope-bytes', 'other.exe')
service.replace_scope_draft(OTHER, 'runtime', {
'Version': '2.6',
'Applications': [{'Name': 'Beta', 'Type': 'PS1',
'Script': 'scripts/beta.ps1',
'DetectionMethod': 'Always'}]})
service.publish_scope(OTHER, 'runtime', notes='other')
service.db.session.commit()
def _mint(client, auth_headers, resourcescopes=None):
payload = {'name': 'display svc', 'scopes': ['geenforce.fetch']}
if resourcescopes is not None:
payload['resourcescopes'] = resourcescopes
resp = client.post('/api/apitokens', json=payload, headers=auth_headers)
assert resp.status_code == 201, resp.get_json()
return resp.get_json()['data']
def _key(client, auth_headers, resourcescopes=None):
return {'X-API-Key': _mint(client, auth_headers, resourcescopes)['secret']}
# -- manifest binding ---------------------------------------------------------
def test_bound_token_gets_its_own_scope(client, app, db, auth_headers):
_publish_display_scope(app)
headers = _key(client, auth_headers, resourcescopes=[DISPLAY])
resp = client.get(f'/api/geenforce/manifest?pctype={DISPLAY}', headers=headers)
assert resp.status_code == 200
def test_bound_token_forbidden_other_scope(client, app, db, auth_headers):
_publish_display_scope(app)
headers = _key(client, auth_headers, resourcescopes=[DISPLAY])
resp = client.get(f'/api/geenforce/manifest?pctype={OTHER}', headers=headers)
assert resp.status_code == 403
def test_unbound_token_reaches_any_scope(client, app, db, auth_headers):
_publish_display_scope(app)
headers = _key(client, auth_headers) # no resourcescopes -> unrestricted
assert client.get(f'/api/geenforce/manifest?pctype={DISPLAY}',
headers=headers).status_code == 200
assert client.get(f'/api/geenforce/manifest?pctype={OTHER}',
headers=headers).status_code == 200
# -- payload binding ----------------------------------------------------------
def test_bound_token_gets_referenced_blob(client, app, db, auth_headers):
_publish_display_scope(app)
headers = _key(client, auth_headers, resourcescopes=[DISPLAY])
resp = client.get(f'/api/geenforce/payload/{KIOSK_SHA}', headers=headers)
assert resp.status_code == 200
assert resp.data == KIOSK_BYTES
def test_bound_token_404_on_unreferenced_blob(client, app, db, auth_headers):
_publish_display_scope(app)
# A blob the display scope does NOT reference (belongs to OTHER).
other_sha = hashlib.sha256(b'secret-other-scope-bytes').hexdigest()
headers = _key(client, auth_headers, resourcescopes=[DISPLAY])
resp = client.get(f'/api/geenforce/payload/{other_sha}', headers=headers)
assert resp.status_code == 404
def test_unbound_token_gets_any_blob(client, app, db, auth_headers):
_publish_display_scope(app)
other_sha = hashlib.sha256(b'secret-other-scope-bytes').hexdigest()
headers = _key(client, auth_headers)
assert client.get(f'/api/geenforce/payload/{other_sha}',
headers=headers).status_code == 200
# -- token model / API --------------------------------------------------------
def test_create_persists_resourcescopes(client, app, db, auth_headers):
data = _mint(client, auth_headers, resourcescopes=[DISPLAY])
assert data['resourcescopes'] == [DISPLAY]
def test_resourcescopes_null_by_default(client, app, db, auth_headers):
data = _mint(client, auth_headers)
assert data['resourcescopes'] is None
def test_bad_resourcescopes_rejected(client, app, db, auth_headers):
resp = client.post('/api/apitokens',
json={'name': 'x', 'scopes': ['geenforce.fetch'],
'resourcescopes': 'not-a-list'},
headers=auth_headers)
assert resp.status_code == 400