Accept managed collector service tokens on the collector API
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:
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user