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>
261 lines
8.7 KiB
Python
261 lines
8.7 KiB
Python
"""Flask application factory."""
|
|
|
|
import os
|
|
import logging
|
|
from flask import Flask, send_from_directory
|
|
|
|
from .config import config
|
|
from .extensions import db, migrate, jwt, cors, ma, init_extensions
|
|
from .plugins import plugin_manager
|
|
|
|
# Platform contract version. See ADR-001 for the contract surface and
|
|
# ADR-002 for the bump rules. Plugins declare a compatible range in
|
|
# their manifest.json `core_version` field. Pre-1.0 (0.x) means the
|
|
# contract is still settling; sister sites should pin tight ranges.
|
|
# 0.3.0: shopdb.api expanded to the full plugin import surface (db, cache,
|
|
# model bases, core models, response + pagination helpers, employee_connection)
|
|
# so plugins no longer import internal core paths. Additive, hence minor bump.
|
|
# 0.4.0: removed the never-implemented get_searchable_fields hook (search is a
|
|
# core concern over the asset model) and wired the get_dashboard_widgets hook to
|
|
# a real consumer (/api/dashboard/widgets). Pre-1.0 contract reduction.
|
|
# 0.6.0: added the get_reports hook, consumed by GET /api/reports to merge
|
|
# plugin report cards into the Reports hub. Additive optional hook, minor bump.
|
|
# 0.7.0: added the four ADR-010 frontend-contribution hooks (get_settings_cards,
|
|
# get_asset_panels, get_map_overlays, get_asset_presentation), consumed by the
|
|
# GET /api/pluginui/* endpoints. Four additive optional hooks, one minor bump.
|
|
# 0.9.0: added the dualpath single-machine collapse helpers to shopdb.api
|
|
# (resolve_dualpath_pairs, dualpath_single_machine_enabled), consumed by the
|
|
# machines plugin list/detail to collapse dual-bay pairs. Two additive names,
|
|
# minor bump.
|
|
# 0.10.0: added the get_permissions hook so a plugin declares the RBAC
|
|
# permissions its own routes enforce, instead of core accumulating them in one
|
|
# catalog. Consumed by full_permission_catalog() (core + enabled plugins),
|
|
# which backs seeding, the role grid, and API-token scope validation. Additive
|
|
# optional hook, minor bump.
|
|
# 0.11.0: added service_token_authorized(scope) to shopdb.api so a plugin's
|
|
# unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped
|
|
# managed service token without importing core token internals. Additive name
|
|
# on the import surface, minor bump.
|
|
__contract_version__ = '0.11.0'
|
|
|
|
# Product release version (see ADR-007). The product version and the
|
|
# plugin-contract version above are distinct series with independent
|
|
# bump rules. Not part of the shopdb.api contract surface, so it is
|
|
# not re-exported there.
|
|
__version__ = '0.7.0'
|
|
|
|
|
|
def create_app(config_name: str = None) -> Flask:
|
|
"""
|
|
Application factory.
|
|
|
|
Args:
|
|
config_name: Configuration name ('development', 'production', 'testing')
|
|
|
|
Returns:
|
|
Configured Flask application
|
|
"""
|
|
if config_name is None:
|
|
config_name = os.environ.get('FLASK_ENV', 'development')
|
|
|
|
app = Flask(__name__, instance_relative_config=True)
|
|
|
|
config_class = config.get(config_name, config['default'])
|
|
|
|
# Production must validate its env-driven config before boot.
|
|
if config_name == 'production' and hasattr(config_class, 'validate'):
|
|
config_class.validate()
|
|
|
|
app.config.from_object(config_class)
|
|
|
|
# Load instance config if exists
|
|
app.config.from_pyfile('config.py', silent=True)
|
|
|
|
# Per-plugin collector keys (ADR-006) are dynamic env-vars
|
|
# (COLLECTOR_API_KEY_<PLUGINNAME>) that from_object cannot pick up because
|
|
# they are not class attributes. Copy them in explicitly so per-plugin
|
|
# credential isolation works in real deploys, not just tests.
|
|
for envname, envvalue in os.environ.items():
|
|
if envname.startswith('COLLECTOR_API_KEY_') and envvalue:
|
|
app.config[envname] = envvalue
|
|
|
|
# Ensure instance folder exists
|
|
os.makedirs(app.instance_path, exist_ok=True)
|
|
|
|
# Configure logging
|
|
configure_logging(app)
|
|
|
|
# Initialize extensions
|
|
init_extensions(app)
|
|
|
|
# Initialize plugin manager
|
|
with app.app_context():
|
|
plugin_manager.init_app(app, db)
|
|
|
|
# Register core blueprints
|
|
register_blueprints(app)
|
|
|
|
# Personal API token auth shim: recognize `Bearer shopdb_pat_...` before
|
|
# any JWT decode and mint a request-scoped JWT for the token's owner.
|
|
from .utils.apitoken_auth import install_apitoken_auth
|
|
install_apitoken_auth(app)
|
|
|
|
# Register CLI commands
|
|
register_cli_commands(app)
|
|
|
|
# Register error handlers
|
|
register_error_handlers(app)
|
|
|
|
# Serve Vue frontend
|
|
register_frontend_routes(app)
|
|
|
|
# JWT user loader (identity is a string in JWT, convert to int for DB lookup)
|
|
@jwt.user_lookup_loader
|
|
def user_lookup_callback(_jwt_header, jwt_data):
|
|
from .core.models import User
|
|
identity = jwt_data["sub"]
|
|
return db.session.get(User, int(identity))
|
|
|
|
return app
|
|
|
|
|
|
CORE_BLUEPRINT_NAMES = (
|
|
'auth',
|
|
'assets',
|
|
'modeltypes',
|
|
'plugins',
|
|
'vendors',
|
|
'models',
|
|
'businessunits',
|
|
'locations',
|
|
'operatingsystems',
|
|
'dashboard',
|
|
'dashboarddefaults',
|
|
'applications',
|
|
'supportteams',
|
|
'search',
|
|
'reports',
|
|
'collector',
|
|
'settings',
|
|
'auditlogs',
|
|
'users',
|
|
'customfields',
|
|
'setup',
|
|
'pluginui',
|
|
'apitokens',
|
|
)
|
|
|
|
|
|
def register_blueprints(app: Flask):
|
|
"""Register core API blueprints from CORE_BLUEPRINT_NAMES.
|
|
|
|
Each entry maps to an attribute `<name>_bp` exported by
|
|
`shopdb.core.api` and a URL prefix `/api/<name>`. Adding a new
|
|
core resource is one entry in CORE_BLUEPRINT_NAMES, not a 3-line
|
|
edit in this function.
|
|
"""
|
|
from .core import api as api_module
|
|
|
|
api_prefix = '/api'
|
|
for name in CORE_BLUEPRINT_NAMES:
|
|
attr_name = f'{name}_bp'
|
|
if not hasattr(api_module, attr_name):
|
|
raise RuntimeError(
|
|
f'Core blueprint "{attr_name}" missing from shopdb.core.api. '
|
|
f'Either add it or remove "{name}" from CORE_BLUEPRINT_NAMES.'
|
|
)
|
|
bp = getattr(api_module, attr_name)
|
|
app.register_blueprint(bp, url_prefix=f'{api_prefix}/{name}')
|
|
|
|
|
|
def register_cli_commands(app: Flask):
|
|
"""Register Flask CLI commands."""
|
|
from .plugins.cli import plugin_cli
|
|
from .cli import db_cli, seed_cli, relationships_cli
|
|
|
|
app.cli.add_command(plugin_cli)
|
|
app.cli.add_command(db_cli)
|
|
app.cli.add_command(seed_cli)
|
|
app.cli.add_command(relationships_cli)
|
|
|
|
|
|
def register_error_handlers(app: Flask):
|
|
"""Register error handlers."""
|
|
from .utils.responses import error_response, ErrorCodes
|
|
from .exceptions import ShopDBException
|
|
|
|
@app.errorhandler(ShopDBException)
|
|
def handle_shopdb_exception(error):
|
|
http_codes = {
|
|
'NOT_FOUND': 404,
|
|
'UNAUTHORIZED': 401,
|
|
'FORBIDDEN': 403,
|
|
'CONFLICT': 409,
|
|
'VALIDATION_ERROR': 400,
|
|
}
|
|
http_code = http_codes.get(error.code, 400)
|
|
return error_response(
|
|
error.code,
|
|
error.message,
|
|
details=error.details,
|
|
http_code=http_code
|
|
)
|
|
|
|
@app.errorhandler(404)
|
|
def not_found_error(error):
|
|
return error_response(
|
|
ErrorCodes.NOT_FOUND,
|
|
'Resource not found',
|
|
http_code=404
|
|
)
|
|
|
|
@app.errorhandler(500)
|
|
def internal_error(error):
|
|
return error_response(
|
|
ErrorCodes.INTERNAL_ERROR,
|
|
'An internal error occurred',
|
|
http_code=500
|
|
)
|
|
|
|
@app.errorhandler(401)
|
|
def unauthorized_error(error):
|
|
return error_response(
|
|
ErrorCodes.UNAUTHORIZED,
|
|
'Authentication required',
|
|
http_code=401
|
|
)
|
|
|
|
|
|
def register_frontend_routes(app: Flask):
|
|
"""Serve Vue frontend static files."""
|
|
frontend_dist = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'frontend', 'dist')
|
|
|
|
@app.route('/', defaults={'path': ''})
|
|
@app.route('/<path:path>')
|
|
def serve_frontend(path):
|
|
# Don't serve API routes as frontend
|
|
if path.startswith('api/'):
|
|
from .utils.responses import error_response, ErrorCodes
|
|
return error_response(ErrorCodes.NOT_FOUND, 'API endpoint not found', http_code=404)
|
|
|
|
# Try to serve a static asset directly. send_from_directory handles
|
|
# the safe-join + 404 itself; no explicit existence probe needed
|
|
# (the probe was a path-traversal risk surface).
|
|
if path:
|
|
try:
|
|
return send_from_directory(frontend_dist, path)
|
|
except Exception:
|
|
pass
|
|
|
|
return send_from_directory(frontend_dist, 'index.html')
|
|
|
|
|
|
def configure_logging(app: Flask):
|
|
"""Configure application logging."""
|
|
log_level = app.config.get('LOG_LEVEL', 'INFO')
|
|
|
|
logging.basicConfig(
|
|
level=getattr(logging, log_level),
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
)
|