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

@@ -390,6 +390,22 @@ def test_scopes_update_round_trip(client, db, admin_user, auth_headers):
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):
"""PUT scopes is also capped at the owner's permissions."""
_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."""
resp = client.get('/api/collector/status', headers={'X-API-Key': KEY})
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'