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

@@ -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):

View File

@@ -105,6 +105,12 @@ class Permission(db.Model):
('audit.view', 'View audit logs', 'admin'),
# API tokens
('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):

View File

@@ -47,8 +47,13 @@ def _extract_pat_secret():
return None
def _resolve_pat(secret):
"""Validate a PAT secret. Return (token, user) or None."""
def resolve_api_token(secret):
"""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
token = ApiToken.query.filter_by(
@@ -61,7 +66,7 @@ def _resolve_pat(secret):
return token, user
def _touch_lastused(token):
def touch_apitoken_lastused(token):
"""Throttled lastusedat write. Independent commit; nothing else is pending
this early in the request, so it cannot clobber route work."""
now = _utcnow()
@@ -79,7 +84,7 @@ def install_apitoken_auth(app):
secret = _extract_pat_secret()
if secret is None:
return
resolved = _resolve_pat(secret)
resolved = resolve_api_token(secret)
if resolved is None:
# The caller clearly meant to use a PAT (shopdb_pat_ prefix) but it
# 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.apitokenuser = user
_touch_lastused(token)
touch_apitoken_lastused(token)
# Mint a request-scoped JWT for the owner and swap it into the header
# so the whole downstream auth stack authenticates as that user.