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:
@@ -8,15 +8,21 @@ API key (not JWT) for unattended scripts. Writes the asset/computer model
|
||||
|
||||
from datetime import datetime, timezone
|
||||
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.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
|
||||
|
||||
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():
|
||||
"""Lazily import the computers plugin models.
|
||||
@@ -33,24 +39,80 @@ def _computer_models():
|
||||
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):
|
||||
"""Require API key authentication."""
|
||||
"""Require collector API-key OR collector-scoped managed-token auth."""
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
# Header only. Querystring api_key was dropped so keys do not land in
|
||||
# access logs / proxy history (breaking change, see COLLECTOR-INTEGRATION.md).
|
||||
api_key = request.headers.get('X-API-Key')
|
||||
expected_key = current_app.config.get('COLLECTOR_API_KEY')
|
||||
|
||||
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)
|
||||
error = _check_collector_auth(current_app.config.get('COLLECTOR_API_KEY'))
|
||||
if error is not None:
|
||||
return error
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
@@ -136,15 +198,11 @@ def generic_collect(pluginname):
|
||||
|
||||
plugin, schema = plugins[pluginname]
|
||||
|
||||
expected_key = _plugin_api_key(pluginname)
|
||||
if not expected_key:
|
||||
return error_response(ErrorCodes.INTERNAL_ERROR,
|
||||
'Collector API key not configured', http_code=500)
|
||||
# Header only (querystring fallback dropped, see require_api_key).
|
||||
api_key = request.headers.get('X-API-Key')
|
||||
if api_key != expected_key:
|
||||
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
|
||||
http_code=401)
|
||||
# Per-plugin env key (fallback) OR a collector-scoped managed token. Header
|
||||
# only (querystring fallback dropped, see require_api_key).
|
||||
error = _check_collector_auth(_plugin_api_key(pluginname))
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
payload = request.get_json(silent=True)
|
||||
if not payload or not isinstance(payload, dict):
|
||||
|
||||
Reference in New Issue
Block a user