Build GE-Enforce manifest-store plugin (P0/P1): model, importer, parity gate
First execution phases of docs/proposals/ge-enforce-plugin.md. The GE-Enforce manifest becomes shopdb data. P0 scaffold: new geenforce plugin (api_prefix /api/geenforce, default_enabled false, core_version >=0.7.0). Registered in PLUGIN_TABLE_OWNERS (ADR-008); its 0001 baseline really creates the tables. P1a model: one wide manifestentries table + entrytype discriminator (not STI, not JSON blob), manifestscopes (UNIQUE scopename+phase), the three multi-value filter child tables, inusechecks + processes, immutable manifestpublishedversions (frozen rendered JSON), manifestpayloads (inline, capped), pctypealiases (mirror of the engine lib's alias graph). regvalue stored as its raw JSON literal so DWord typing survives. P1c importer + exporter: parse common + gea-shopfloor-* + preinstall.json into draft rows and rebuild the JSON verbatim from rows in sortorder. P1d parity harness (GATE A): filters.py mirrors the engine's four filter functions + alias graph; parity.py proves import+export is behaviorally lossless (field-identical + same-entries-fire across 18 machine-profile fixtures) WITHOUT byte-diffing. Verified PASS against all 11 real reference manifests (64 entries) and a synthetic site-neutral fixture covering every type/filter (the CI gate). First slice (gea-shopfloor-cmm shape): service layer (import/publish/rollback/ export-to-share), CLI (parity, import-share, publish, export-share), and the client endpoint GET /api/geenforce/manifest serving the current published snapshot (never the draft) with ETag/304. Split permissions geenforce.manage/publish/fetch. Tests prove import->publish->serve, draft edits never change served bytes, publish+rollback, and auth (401 unauth/wrong-scope). Contract 0.11.0: added service_token_authorized(scope) to shopdb.api so plugin service endpoints authorize a scoped managed token without importing core token internals. Documented in PLUGIN-HOOKS.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -76,6 +76,40 @@ def touch_apitoken_lastused(token):
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def service_token_authorized(scope):
|
||||
"""True when the current request carries a managed token scoped for `scope`
|
||||
whose owner is active and holds that permission. Accepts X-API-Key or a
|
||||
Bearer PAT (the before_request shim resolves Bearer into g.apitokenid).
|
||||
Touches lastusedat on success.
|
||||
|
||||
The single contract-surface entry point for unattended SERVICE tokens
|
||||
(collector.ingest, geenforce.fetch, ...), so plugins authorize a service
|
||||
token without reaching into core token internals. Returns False on any
|
||||
miss; the caller returns its own 401.
|
||||
"""
|
||||
from shopdb.core.models import User
|
||||
|
||||
api_key = request.headers.get('X-API-Key')
|
||||
token = None
|
||||
if api_key and api_key.startswith(TOKEN_SECRET_PREFIX):
|
||||
resolved = resolve_api_token(api_key)
|
||||
token = resolved[0] if resolved else None
|
||||
else:
|
||||
tokenid = getattr(g, 'apitokenid', None)
|
||||
if tokenid is not None:
|
||||
token = db.session.get(ApiToken, tokenid)
|
||||
if token is None:
|
||||
return False
|
||||
scopelist = token.scopelist
|
||||
if not scopelist or scope not in scopelist:
|
||||
return False
|
||||
user = db.session.get(User, token.userid)
|
||||
if user is None or not user.isactive or not user.haspermission(scope):
|
||||
return False
|
||||
touch_apitoken_lastused(token)
|
||||
return True
|
||||
|
||||
|
||||
def install_apitoken_auth(app):
|
||||
"""Register the before_request PAT shim on the app."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user