Accept managed collector service tokens on the collector API
All checks were successful
CI / backend (push) Successful in 1m21s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s

A token scoped to the new collector.ingest permission is a collector
service token: the collector endpoints accept it via X-API-Key or
Bearer alongside the env fleet keys (which remain the fallback), giving
the fleet credential rotation, revocation, and last-used visibility
from the API Tokens page. Containment holds both ways: a collector
token authorizes nothing else, and no other credential gains collector
access. Shared token validation refactored out of the auth shim; a
Collector service token quick-preset in the create modal; integration
guide documents minting, rotation via site-config.json, and the
service-identity pattern.

765 tests pass; live acceptance matrix verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-12 09:13:33 -04:00
parent 848a8fb34f
commit 12175169e4
9 changed files with 381 additions and 29 deletions

View File

@@ -48,6 +48,29 @@ ADR-007 and ADR-002.
create/edit modals gain a "Restrict permissions" section (a category-grouped create/edit modals gain a "Restrict permissions" section (a category-grouped
checkbox grid limited to the permissions the creator holds) and the token checkbox grid limited to the permissions the creator holds) and the token
lists show a full-access / N-permissions access chip. lists show a full-access / N-permissions access chip.
- Managed collector service tokens: the collector ingest API
(`/api/collector/<plugin>` + the legacy `/pc` `/apps` `/heartbeat` `/bulk`
`/status` endpoints) now ALSO accepts a managed API token scoped to the new
`collector.ingest` permission (category `collector`), on top of the existing
`COLLECTOR_API_KEY[_<PLUGIN>]` env keys (which stay supported as a
bootstrap/legacy fallback - nothing breaks). The token may be presented in
`X-API-Key` (as GE-Enforce sends today) OR as an `Authorization: Bearer`
token; both transports validate the PAT the same way the login shim does
(hash lookup, active, unexpired, active owner) via a shared
`resolve_api_token` helper refactored out of `apitoken_auth.py`, require
`collector.ingest` in the token's scope list AND that the owner holds it, and
stamp `lastusedat` (same 60s throttle). A token scoped to ONLY
`collector.ingest` is a collector service token: it authorizes the collector
API and NOTHING else - the existing scoped-token machinery denies it on every
permission- and role-gated route and on import mode, so a leaked collector
token cannot touch the regular API. Recommended flow (documented): an admin
mints the scoped token (the scope suspends the admin bypass, containing it);
rotate by minting a new one, deploying via `site-config.json`, watching
`lastusedat`, then revoking the old. The Settings > API Tokens create modal
gains a "Collector service token" quick-preset (pre-selects only
`collector.ingest`). Docs: `docs/COLLECTOR-INTEGRATION.md` (new "Managed
collector tokens" section) and `docs/CONFIG.md`. Core feature; no
plugin-contract change.
- Vendor-model photos on asset detail heroes: computers and printers now - Vendor-model photos on asset detail heroes: computers and printers now
surface the linked model's `imageurl` in their extension payloads (the surface the linked model's `imageurl` in their extension payloads (the
field machines already exposed), and the machine, PC, printer, network field machines already exposed), and the machine, PC, printer, network

View File

@@ -30,6 +30,80 @@ a real caller (the GE-Enforce fleet agent) to it.
HTTP 500 `Collector API key not configured` and rejects every request. An HTTP 500 `Collector API key not configured` and rejects every request. An
unconfigured server never silently accepts unauthenticated data. unconfigured server never silently accepts unauthenticated data.
- A caller that sends the wrong key (or no key) gets HTTP 401 `Invalid API key`. - A caller that sends the wrong key (or no key) gets HTTP 401 `Invalid API key`.
- In addition to the env keys, a managed API token scoped to `collector.ingest`
is accepted as a collector credential on every collector endpoint. See
"Managed collector tokens" below; env keys remain the fallback.
### Managed collector tokens (recommended)
Alongside the env keys, every collector endpoint (`/api/collector/<plugin>`,
`/pc`, `/apps`, `/heartbeat`, `/bulk`, `/status`) also accepts a **managed API
token** (PAT) scoped to the `collector.ingest` permission. The env keys stay
supported as a bootstrap/legacy fallback - nothing breaks - but a managed token
is the preferred credential because it can be minted, rotated, and revoked from
the UI (Settings > API Tokens) and its use shows up in `lastusedat` and the
audit log.
What makes a token a collector service token: it is scoped to ONLY
`collector.ingest`. That scope authorizes the collector ingest API and NOTHING
else. The existing scoped-token machinery contains it automatically - a scoped
token passes `require_permission` only for its listed permissions and is denied
on every role-gated (`require_role`) endpoint and on import mode, and
`collector.ingest` gates no normal route. So a collector token that leaks cannot
be used to read or write anything through the regular API; it can only submit
collector payloads.
Both wire transports are accepted (send whichever is convenient; GE-Enforce
sends `X-API-Key` today, so that stays ergonomic):
```
POST /api/collector/computers
X-API-Key: shopdb_pat_<40 hex>
```
or
```
POST /api/collector/computers
Authorization: Bearer shopdb_pat_<40 hex>
```
An unscoped PAT, or a PAT scoped to some other permission, is NOT a collector
token and is rejected (401) - only `collector.ingest` in the scope list counts.
A revoked or expired token is rejected (401) on both transports.
#### How to mint one (admin flow)
The simplest contained flow: an **admin** mints the token, scoped to
`collector.ingest`. Because the token is scoped, the admin-role bypass is
suspended for it, so the token is contained to the collector API even though its
owner is an admin - it cannot act with admin authority anywhere.
1. Settings > API Tokens > New Token.
2. Click the **Collector service token** preset (pre-selects only
`collector.ingest`), name it (e.g. `wj-fleet-collector`), optionally set an
expiry, Create.
3. Copy the `shopdb_pat_...` secret (shown once) and deploy it to the fleet the
same way as the env key: the `collectorApiKey` field in per-site
`site-config.json` (see "Delivering the API key to clients" below). The
client sends it in `X-API-Key` exactly as it sends an env key today - no
client code change.
Service identity (documented, not built): if you prefer a non-admin owner,
create a dedicated low-privilege user (e.g. `svc-collector`) whose role holds
only `collector.ingest`, plus `apitokens.create` if that user is to mint its own
token. The scope ceiling then caps any token it mints at `collector.ingest`.
The admin-minted route above is simpler and equally contained, so it is the
recommended default.
#### Rotation
Managed tokens rotate without a fleet re-image:
1. Mint a new collector token (steps above).
2. Deploy it via `site-config.json` (`collectorApiKey`) - update the one per-site
value.
3. Confirm the new token is in use: watch its `lastusedat` climb in Settings >
API Tokens (and the old token's `lastusedat` go stale).
4. Revoke the old token once traffic has moved. Revocation is immediate.
### Generic endpoint contract: `POST /api/collector/<plugin>` ### Generic endpoint contract: `POST /api/collector/<plugin>`
@@ -256,6 +330,13 @@ collector schema, and POSTs with the `X-API-Key` header over TLS 1.2. Every
field name below was checked against `get_collector_schema` in field name below was checked against `get_collector_schema` in
`plugins/computers/plugin.py`. `plugins/computers/plugin.py`.
The `X-API-Key` value can be EITHER a `COLLECTOR_API_KEY[_COMPUTERS]` env key OR
a managed token scoped to `collector.ingest` (a `shopdb_pat_...` secret; see
"Managed collector tokens"). The script is identical for both - it just carries
whatever `collectorApiKey` the site-config supplies - so switching a site from an
env key to a managed token (and rotating it) is a config change, not a script
change.
```powershell ```powershell
# Send-ShopdbCollectorReport.ps1 # Send-ShopdbCollectorReport.ps1
# Reports this PC's identity to shopdb-flask via POST /api/collector/computers. # Reports this PC's identity to shopdb-flask via POST /api/collector/computers.

View File

@@ -59,9 +59,17 @@ limit is approximate across multiple gunicorn workers).
| Variable | Required | Default | Notes | | Variable | Required | Default | Notes |
|----------|----------|---------|-------| |----------|----------|---------|-------|
| `COLLECTOR_API_KEY` | No | (empty) | Shared key for `/api/collector/*`. Endpoint fails closed (denies) when unset. Sent as the `X-API-Key` header. | | `COLLECTOR_API_KEY` | No | (empty) | Shared key for `/api/collector/*`. Endpoint fails closed (denies) when unset and no managed token is presented. Sent as the `X-API-Key` header. |
| `COLLECTOR_API_KEY_<PLUGIN>` | No | (empty) | Per-plugin override, e.g. `COLLECTOR_API_KEY_COMPUTERS`. Checked before the shared key. | | `COLLECTOR_API_KEY_<PLUGIN>` | No | (empty) | Per-plugin override, e.g. `COLLECTOR_API_KEY_COMPUTERS`. Checked before the shared key. |
The collector endpoints ALSO accept a managed API token (PAT) scoped to the
`collector.ingest` permission, sent in `X-API-Key` or as an
`Authorization: Bearer` token. Env keys stay supported as a bootstrap/legacy
fallback; a managed token is preferred because it is minted, rotated, and
revoked from Settings > API Tokens with `lastusedat` visibility. A
collector-scoped token is contained to the collector API and nothing else. See
`docs/COLLECTOR-INTEGRATION.md` (Managed collector tokens).
### Zabbix (printer supply monitoring) ### Zabbix (printer supply monitoring)
| Variable | Required | Default | Notes | | Variable | Required | Default | Notes |

View File

@@ -125,6 +125,18 @@
<small class="form-hint">Leave blank for a token that never expires.</small> <small class="form-hint">Leave blank for a token that never expires.</small>
</div> </div>
<div v-if="canCollectorPreset" class="form-group preset-group">
<button type="button" class="btn btn-secondary btn-sm"
@click="applyCollectorPreset">
Collector service token
</button>
<small class="form-hint">
For GE-Enforce / fleet reporting. Pre-selects only
<code>collector.ingest</code>; the token works only on the
collector API and nothing else.
</small>
</div>
<div class="form-group"> <div class="form-group">
<label class="checkbox-label"> <label class="checkbox-label">
<input type="checkbox" v-model="form.restrict" /> <input type="checkbox" v-model="form.restrict" />
@@ -308,6 +320,17 @@ const availableGrouped = computed(() => {
return result return result
}) })
// The collector preset is offered only when the caller can actually mint a
// collector.ingest token (admins hold everything; others must hold the perm).
const COLLECTOR_SCOPE = 'collector.ingest'
const canCollectorPreset = computed(
() => isAdmin.value || myPermissions.value.includes(COLLECTOR_SCOPE))
function applyCollectorPreset() {
form.value.restrict = true
form.value.scopes = [COLLECTOR_SCOPE]
}
onMounted(() => loadData()) onMounted(() => loadData())
async function loadData() { async function loadData() {
@@ -499,6 +522,17 @@ async function revokeToken() {
color: var(--text-light); color: var(--text-light);
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.preset-group {
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
}
.preset-group code {
background: var(--bg-card);
padding: 0.1rem 0.3rem;
border-radius: 3px;
}
.secret-warning { .secret-warning {
color: var(--danger); color: var(--danger);
margin-bottom: 1rem; margin-bottom: 1rem;

View File

@@ -8,15 +8,21 @@ API key (not JWT) for unattended scripts. Writes the asset/computer model
from datetime import datetime, timezone from datetime import datetime, timezone
from functools import wraps from functools import wraps
from flask import Blueprint, request, current_app from flask import Blueprint, request, current_app, g
from shopdb.extensions import db from shopdb.extensions import db
from shopdb.core.models import Asset, Application from shopdb.core.models import Asset, Application
from shopdb.core.models.apitoken import ApiToken, TOKEN_SECRET_PREFIX
from shopdb.utils.responses import success_response, error_response, ErrorCodes from shopdb.utils.responses import success_response, error_response, ErrorCodes
collector_bp = Blueprint('collector', __name__) collector_bp = Blueprint('collector', __name__)
# A managed token scoped to this permission is a collector service token: it
# authorizes the collector ingest API and nothing else (ADR-006, machine
# identity - no require_permission on these routes).
COLLECTOR_SCOPE = 'collector.ingest'
def _computer_models(): def _computer_models():
"""Lazily import the computers plugin models. """Lazily import the computers plugin models.
@@ -33,24 +39,80 @@ def _computer_models():
return None return None
def _token_is_collector(token):
"""True when this managed token is a collector service token: scoped for
collector.ingest AND its owner actually holds that permission (admins do)."""
from shopdb.core.models import User
scopelist = token.scopelist
if not scopelist or COLLECTOR_SCOPE not in scopelist:
return False
user = db.session.get(User, token.userid)
return (user is not None and user.isactive
and user.haspermission(COLLECTOR_SCOPE))
def _collector_managed_token(api_key):
"""Find the managed token backing this collector request, or None.
Two transports are accepted (GE-Enforce sends X-API-Key today; Bearer is
the standard PAT wire form):
X-API-Key: the before_request PAT shim ignores this header, so resolve the
secret here.
Bearer: the shim already resolved+swapped it (the raw Bearer secret is
gone by now), leaving g.apitokenid. Reuse that.
"""
from shopdb.utils.apitoken_auth import resolve_api_token
if api_key and api_key.startswith(TOKEN_SECRET_PREFIX):
resolved = resolve_api_token(api_key)
return resolved[0] if resolved else None
tokenid = getattr(g, 'apitokenid', None)
if tokenid is not None:
return db.session.get(ApiToken, tokenid)
return None
def _check_collector_auth(expected_key):
"""Authorize a collector request. Returns None when authorized, else the
error_response to return.
Accepts a collector-scoped managed token (Bearer or X-API-Key) OR the env
key via X-API-Key (legacy/bootstrap fallback). Env keys stay supported so
nothing breaks; a valid managed token works even with no env key set.
"""
from shopdb.utils.apitoken_auth import touch_apitoken_lastused
api_key = request.headers.get('X-API-Key')
token = _collector_managed_token(api_key)
if token is not None and _token_is_collector(token):
touch_apitoken_lastused(token)
return None
if expected_key and api_key == expected_key:
return None
# Fail-closed only on pure server misconfiguration: no env key AND the
# caller presented no managed token at all. Otherwise it is a bad credential.
presented_pat = token is not None or (
api_key is not None and api_key.startswith(TOKEN_SECRET_PREFIX))
if not expected_key and not presented_pat:
return error_response(ErrorCodes.INTERNAL_ERROR,
'Collector API key not configured', http_code=500)
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
http_code=401)
def require_api_key(f): def require_api_key(f):
"""Require API key authentication.""" """Require collector API-key OR collector-scoped managed-token auth."""
@wraps(f) @wraps(f)
def decorated(*args, **kwargs): def decorated(*args, **kwargs):
# Header only. Querystring api_key was dropped so keys do not land in # Header only. Querystring api_key was dropped so keys do not land in
# access logs / proxy history (breaking change, see COLLECTOR-INTEGRATION.md). # access logs / proxy history (breaking change, see COLLECTOR-INTEGRATION.md).
api_key = request.headers.get('X-API-Key') error = _check_collector_auth(current_app.config.get('COLLECTOR_API_KEY'))
expected_key = current_app.config.get('COLLECTOR_API_KEY') if error is not None:
return error
if not expected_key:
return error_response(
ErrorCodes.INTERNAL_ERROR,
'Collector API key not configured',
http_code=500
)
if api_key != expected_key:
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
http_code=401)
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated return decorated
@@ -136,15 +198,11 @@ def generic_collect(pluginname):
plugin, schema = plugins[pluginname] plugin, schema = plugins[pluginname]
expected_key = _plugin_api_key(pluginname) # Per-plugin env key (fallback) OR a collector-scoped managed token. Header
if not expected_key: # only (querystring fallback dropped, see require_api_key).
return error_response(ErrorCodes.INTERNAL_ERROR, error = _check_collector_auth(_plugin_api_key(pluginname))
'Collector API key not configured', http_code=500) if error is not None:
# Header only (querystring fallback dropped, see require_api_key). return error
api_key = request.headers.get('X-API-Key')
if api_key != expected_key:
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
http_code=401)
payload = request.get_json(silent=True) payload = request.get_json(silent=True)
if not payload or not isinstance(payload, dict): if not payload or not isinstance(payload, dict):

View File

@@ -105,6 +105,12 @@ class Permission(db.Model):
('audit.view', 'View audit logs', 'admin'), ('audit.view', 'View audit logs', 'admin'),
# API tokens # API tokens
('apitokens.create', 'Create and manage API tokens', 'apitokens'), ('apitokens.create', 'Create and manage API tokens', 'apitokens'),
# Collector service tokens. A token scoped to ONLY this permission is a
# collector service token: it authorizes the collector ingest API and
# nothing else (scoped tokens pass require_permission only for listed
# perms, and this perm gates no other route). See collector.py.
('collector.ingest', 'Submit collector payloads (fleet reporting)',
'collector'),
] ]
def __repr__(self): def __repr__(self):

View File

@@ -47,8 +47,13 @@ def _extract_pat_secret():
return None return None
def _resolve_pat(secret): def resolve_api_token(secret):
"""Validate a PAT secret. Return (token, user) or None.""" """Validate a PAT secret. Return (token, user) or None.
Shared validator: hash lookup, active token, unexpired, active owner. The
before_request shim and the collector API (which does not decode JWT) both
call this so the checks live in one place.
"""
from shopdb.core.models import User from shopdb.core.models import User
token = ApiToken.query.filter_by( token = ApiToken.query.filter_by(
@@ -61,7 +66,7 @@ def _resolve_pat(secret):
return token, user return token, user
def _touch_lastused(token): def touch_apitoken_lastused(token):
"""Throttled lastusedat write. Independent commit; nothing else is pending """Throttled lastusedat write. Independent commit; nothing else is pending
this early in the request, so it cannot clobber route work.""" this early in the request, so it cannot clobber route work."""
now = _utcnow() now = _utcnow()
@@ -79,7 +84,7 @@ def install_apitoken_auth(app):
secret = _extract_pat_secret() secret = _extract_pat_secret()
if secret is None: if secret is None:
return return
resolved = _resolve_pat(secret) resolved = resolve_api_token(secret)
if resolved is None: if resolved is None:
# The caller clearly meant to use a PAT (shopdb_pat_ prefix) but it # The caller clearly meant to use a PAT (shopdb_pat_ prefix) but it
# is unknown, revoked, or expired. Reject with a clear 401 instead # is unknown, revoked, or expired. Reject with a clear 401 instead
@@ -105,7 +110,7 @@ def install_apitoken_auth(app):
g.apitokenid = token.tokenid g.apitokenid = token.tokenid
g.apitokenuser = user g.apitokenuser = user
_touch_lastused(token) touch_apitoken_lastused(token)
# Mint a request-scoped JWT for the owner and swap it into the header # Mint a request-scoped JWT for the owner and swap it into the header
# so the whole downstream auth stack authenticates as that user. # so the whole downstream auth stack authenticates as that user.

View File

@@ -390,6 +390,22 @@ def test_scopes_update_round_trip(client, db, admin_user, auth_headers):
assert cleared.get_json()['data']['scopes'] is None assert cleared.get_json()['data']['scopes'] is None
def test_collector_scoped_token_denied_on_normal_endpoints(client, db,
admin_user,
auth_headers):
"""A collector-scoped token (collector.ingest only) is contained: the shim
still swaps it into a JWT, but collector.ingest gates no normal route, so it
is denied on a permission-gated create just like any other unlisted scope."""
secret = _create_token(
client, auth_headers,
scopes=['collector.ingest']).get_json()['data']['secret']
response = client.post('/api/applications', json={'appname': 'Nope'},
headers=_pat_headers(secret))
assert response.status_code == 403
assert Application.query.filter_by(appname='Nope').first() is None
def test_update_scopes_ceiling_enforced(client, db): def test_update_scopes_ceiling_enforced(client, db):
"""PUT scopes is also capped at the owner's permissions.""" """PUT scopes is also capped at the owner's permissions."""
_user, headers = _user_with_perms(client, db, 'tokenmaker', _user, headers = _user_with_perms(client, db, 'tokenmaker',

View File

@@ -208,3 +208,124 @@ def test_legacy_header_api_key_accepted(client, db, collector_key):
"""The X-API-Key header still authenticates the legacy endpoints.""" """The X-API-Key header still authenticates the legacy endpoints."""
resp = client.get('/api/collector/status', headers={'X-API-Key': KEY}) resp = client.get('/api/collector/status', headers={'X-API-Key': KEY})
assert resp.status_code == 200 assert resp.status_code == 200
# --- Managed collector service tokens ------------------------------------
def _mint_collector_token(client, auth_headers, scopes):
"""Mint a managed token with the given scopes as the logged-in admin."""
resp = client.post('/api/apitokens',
json={'name': 'collector svc', 'scopes': scopes},
headers=auth_headers)
assert resp.status_code == 201, resp.get_json()
return resp.get_json()['data']['secret']
def test_collector_token_via_x_api_key(client, db, auth_headers,
computer_assettype):
"""A collector-scoped managed token authorizes ingest via X-API-Key, with
no env key set."""
secret = _mint_collector_token(client, auth_headers, ['collector.ingest'])
resp = client.post('/api/collector/computers',
json={'hostname': 'WJTOK01'},
headers={'X-API-Key': secret})
assert resp.status_code == 200, resp.get_json()
assert resp.get_json()['data']['action'] == 'created'
def test_collector_token_via_bearer(client, db, auth_headers,
computer_assettype):
"""The same token also works on the standard Bearer PAT transport."""
secret = _mint_collector_token(client, auth_headers, ['collector.ingest'])
resp = client.post('/api/collector/computers',
json={'hostname': 'WJTOK02'},
headers={'Authorization': f'Bearer {secret}'})
assert resp.status_code == 200, resp.get_json()
assert resp.get_json()['data']['action'] == 'created'
def test_collector_token_stamps_lastusedat(client, db, auth_headers,
computer_assettype):
"""Using a collector token stamps lastusedat."""
from shopdb.core.models import ApiToken
secret = _mint_collector_token(client, auth_headers, ['collector.ingest'])
token = ApiToken.query.filter_by(
tokenhash=ApiToken.hash_secret(secret)).first()
assert token.lastusedat is None
client.post('/api/collector/computers', json={'hostname': 'WJTOK03'},
headers={'X-API-Key': secret})
db.session.expire_all()
token = ApiToken.query.filter_by(
tokenhash=ApiToken.hash_secret(secret)).first()
assert token.lastusedat is not None
def test_unscoped_token_rejected_by_collector(client, db, auth_headers,
collector_key, computer_assettype):
"""An unscoped PAT is NOT a collector token: it lacks the collector.ingest
scope, so the collector API rejects it (env-key semantics unchanged)."""
secret = client.post('/api/apitokens', json={'name': 'full'},
headers=auth_headers).get_json()['data']['secret']
resp = client.post('/api/collector/computers', json={'hostname': 'WJTOK04'},
headers={'X-API-Key': secret})
assert resp.status_code == 401
def test_wrong_scoped_token_rejected_by_collector(client, db, auth_headers,
collector_key,
computer_assettype):
"""A PAT scoped to some OTHER permission is not a collector token."""
secret = _mint_collector_token(client, auth_headers, ['applications.create'])
resp = client.post('/api/collector/computers', json={'hostname': 'WJTOK05'},
headers={'X-API-Key': secret})
assert resp.status_code == 401
def test_revoked_collector_token_rejected(client, db, auth_headers,
collector_key, computer_assettype):
"""A revoked collector token is rejected on both transports."""
from shopdb.core.models import ApiToken
secret = _mint_collector_token(client, auth_headers, ['collector.ingest'])
tokenid = ApiToken.query.filter_by(
tokenhash=ApiToken.hash_secret(secret)).first().tokenid
assert client.delete(f'/api/apitokens/{tokenid}',
headers=auth_headers).status_code == 200
viakey = client.post('/api/collector/computers', json={'hostname': 'WJTOK06'},
headers={'X-API-Key': secret})
assert viakey.status_code == 401
viabearer = client.post('/api/collector/computers',
json={'hostname': 'WJTOK06'},
headers={'Authorization': f'Bearer {secret}'})
assert viabearer.status_code == 401
def test_collector_token_contained_to_collector_api(client, db, admin_user,
auth_headers):
"""Containment: a collector-scoped token is denied on a permission-gated
write (applications.create) AND a role-gated write (vendors, require_role)."""
from shopdb.core.models import Application, Vendor
secret = _mint_collector_token(client, auth_headers, ['collector.ingest'])
perm_gated = client.post('/api/applications', json={'appname': 'Nope'},
headers={'Authorization': f'Bearer {secret}'})
assert perm_gated.status_code == 403
assert Application.query.filter_by(appname='Nope').first() is None
role_gated = client.post('/api/vendors', json={'vendor': 'Nope'},
headers={'Authorization': f'Bearer {secret}'})
assert role_gated.status_code == 403
assert Vendor.query.filter_by(vendor='Nope').first() is None
def test_env_key_still_works_alongside_tokens(client, db, collector_key,
computer_assettype):
"""Regression: the env key path is unchanged when tokens are in play."""
resp = client.post('/api/collector/computers', json={'hostname': 'WJENV01'},
headers={'X-API-Key': KEY})
assert resp.status_code == 200
assert resp.get_json()['data']['action'] == 'created'