client_ip() read the first X-Forwarded-For hop unconditionally. The
login rate limit and the account lockout are keyed on that value and the
audit log records it, so rotating the header per request bought an
unbounded budget and an untraceable trail.
This walked around TRUSTED_PROXY_COUNT, which exists to decide exactly
this. The app leaves ProxyFix off by default and says why, ten lines of
comment away: ProxyFix trusts X-Forwarded-*, a client can send those
itself, and the login limiter is keyed on the address. Then client_ip
read the header anyway, so the stated protection never applied.
Resolution is now from remote_addr, which ProxyFix has already filled
from the header wherever a proxy is genuinely configured. Both cases end
at the same trustworthy source, and the port still comes off because ARR
forwards clientip:port and ProxyFix passes it through verbatim.
geenforce and tools had each hand-rolled the same mistake for their own
limiters. Rather than duplicate security-relevant logic a third time,
client_ip and strip_port join the shopdb.api surface (contract 0.28.0).
geenforce's _trusted_client_ip, which already refused the raw header for
its IP allowlist, is untouched and was the model for this.
The stability table was also missing rows for 0.26.0 and 0.27.0.
INSTALL-WINDOWS-IIS.md never mentioned TRUSTED_PROXY_COUNT, so a site
installed from it runs behind IIS with the setting at 0: every request
looks like it came from IIS, the whole site shares one rate-limit
bucket, and the audit log records the proxy for every action. It now
says so, and says not to set it when nothing is in front.
Three test modules simulated separate callers by setting
X-Forwarded-For, which is the header a real attacker rotates to escape
the limit. One of them, test_client_ip_prefers_forwarded_first_hop_
stripped, asserted the defect outright. The suite could not have caught
this. They now drive REMOTE_ADDR, with a regression guard that forging
the header buys nothing.
Scope: product
(cherry picked from commit 7260244b7e)
15 KiB
Path to contract 1.0
This page is an honest read of how stable the plugin contract is today, for a sister site deciding how much to build on it. It is derived from the ADRs and the live code, not aspiration. The authoritative hook reference is PLUGIN-HOOKS.md; the versioning rules are in ADR-002.
Current version
The plugin contract version is declared in shopdb/__init__.py as
__contract_version__, and the current value is in
PROJECT-MAP.md, which is generated. It is pre-1.0, which under
semver means any 0.x minor bump is allowed to break the contract, and this
project has used that latitude - see 0.19.0 below.
The product release version (__version__) is a separate series with its own
bump rules; see ADR-007.
Do not pin against it for compatibility - pin against __contract_version__.
0.x history
Recorded in the comment block in shopdb/__init__.py:
| Version | Change | Kind |
|---|---|---|
| 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 stop importing internal core paths |
additive (minor) |
| 0.4.0 | Removed the never-implemented get_searchable_fields hook (search is a core concern over the asset model) and wired get_dashboard_widgets 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) |
| 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 |
additive optional hooks (minor) |
| 0.9.0 | Exposed the dualpath pair-resolution helpers on shopdb.api for the machines plugin |
additive surface (minor) |
| 0.10.0 | Added the get_permissions hook so plugins declare their own RBAC permissions; the catalog is resolved dynamically from core + enabled plugins |
additive optional hook (minor) |
| 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 surface (minor) |
| 0.12.0 | Added the mailer helpers (send_email, send_alert) to shopdb.api |
additive surface (minor) |
| 0.13.0 | Added the User model to the shopdb.api surface |
additive surface (minor) |
| 0.14.0 | Added send_webhook to the shopdb.api surface |
additive surface (minor) |
| 0.15.0 | Added authorized_service_token / the SupportTeam model to the shopdb.api surface |
additive surface (minor) |
| 0.16.0 | Added the get_settings_defaults hook so a plugin declares the Setting rows it owns; the framework seeds them at install, at enable, and on flask plugin upgrade-all, files a first-time write under the declared category, and honours public: True for pages that render before login |
additive optional hook (minor) |
| 0.17.0 | Added DashboardDefault to the shopdb.api surface, so a plugin can resolve a display without reaching into core |
additive surface (minor) |
| 0.18.0 | Added DISPLAY_ROLES, DISPLAY_ROLE_PATHS and normalize_display_role, and finally exported DashboardDefault, which 0.17.0 imported but left out of __all__. The role vocabulary became the kiosk's own - Dashboard, Lobby, 3DPrintRoom - so a plugin holding its own copy of that map reads core's instead of drifting from it |
additive surface (minor) |
| 0.19.0 | BREAKING. get_dashboard_widgets returns DATA AND SHAPE, not a component name. The old shape (name + component + size) named a Vue component per widget, which cannot survive a lean build - a plugin's component may never be staged into the frontend bundle (ADR-013) - and five plugins were declaring widgets that pointed at components nobody had written. A card now declares id / title / endpoint / render / severity / permission / empty / position; see PLUGIN-HOOKS.md. Also added send_upload so a plugin serving user-supplied bytes gets the headers that keep an SVG from running as script |
contract change (minor, pre-1.0) |
| 0.20.0 | The Asset model gained levelid (which floor plan its mapx/mapy are pixels of) and mapverifiedat; Location gained levelid too, and resolve_asset_position now returns the levelid belonging to whichever source supplied the coordinates. A plugin that writes a map position MUST write the level with it - a position without one renders as "level unknown" rather than being drawn on the default drawing, because a marker on the wrong floor plan looks correct (ADR-017) |
additive surface (minor) |
| 0.21.0 | Added the get_guest_grantable_permissions hook: a plugin names the WRITE permissions a site may grant to the seeded guest role, which is who anyone without a token now holds. Reads (.view/.read) are grantable without a plugin saying anything; anything else is refused. Also exposed require_any_permission on shopdb.api for a read two different rights should reach |
additive optional hook + surface (minor) |
| 0.22.0 | Added the register_import_hook surface: a plugin owns a VIRTUAL COLUMN on an importable CSV table - a header the bulk importer accepts but never writes - plus a post-write row hook that turns the value into a plugin concept. The computers plugin uses it for machineassetnumber on computers.csv, which builds the PC->machine controls link, so a site writes the machine number in the PC's own row instead of hand-building a separate relationships sheet. Core never learns what the column means. RowWarning is exported with it so a hook can report a soft failure without aborting the import |
additive surface (minor) |
| 0.23.0 | Added mint_service_token, list_service_tokens and revoke_service_tokens so a plugin can provision, list and revoke a single-scope managed service token without importing core token internals. The endpoints plugin uses them for a UI-managed, regenerable agent key scoped to collector.ingest.endpoints alone - narrow on purpose, so rotating or revoking it cannot disturb the computers or GE-Enforce collectors, and revocation is an instant kill switch on agent ingest |
additive surface (minor) |
| 0.24.0 | Added collector_authorized(pluginname) so a plugin can gate an agent-facing route that lives OUTSIDE the generic collector blueprint on the same per-plugin key and kill switch the ingest route uses. The endpoints command channel needs it: the agent long-polls /api/endpoints/commands/next and posts results back, and those are agent callbacks rather than JWT routes. Without it a plugin would reach into core collector internals to authorize them |
additive surface (minor) |
| 0.25.0 | Added caller_holds(permission), which answers whether the current caller holds a permission WITHOUT refusing the request - the complement to require_permission for a route that serves everyone but should only offer what the caller can act on. The endpoints services panel is the case: it emitted Start/Stop row actions to every reader, because the emission never consulted the caller. Clicking them was always refused server-side, so nothing was exploitable, but offering a read-only viewer controls over a shop-floor PC is the wrong signal, and it becomes a real problem once a site grants that panel to guests. Guests are included in the answer, since a signed-out visitor holds whatever the guest role was granted |
additive surface (minor) |
| 0.26.0 | Dashboard cards may declare shortname. The dashboard merged its per-source cards into one worst-first queue, and a card's title is written as a heading ("Endpoints with crashes or shutdowns") which as a row badge ran wider than the hostname it qualified. Optional, defaults to the title, so a plugin that does not declare one is unaffected |
additive field (minor) |
| 0.27.0 | Added Script so a plugin can read the ShopDB-hosted script library that backs endpoint run_script delivery without importing core model internals. The endpoints plugin resolves a delivered script by name and serves its body by digest to the agent |
additive surface (minor) |
| 0.28.0 | Added client_ip and strip_port so a plugin does not hand-roll caller-IP extraction. Two plugins had, and both read the first X-Forwarded-For hop, which the caller supplies - so a rate limit keyed on it was defeated by rotating the header. The helper resolves from remote_addr, which ProxyFix has already filled from the header when TRUSTED_PROXY_COUNT says a proxy is really in front |
additive surface (minor) |
The source comment block documents 0.3.0, 0.4.0, 0.6.0, 0.7.0, 0.9.0, 0.10.0, 0.11.0, 0.16.0, 0.18.0 and 0.19.0; 0.12.0 through 0.15.0 and 0.17.0 are recorded in this table only.
0.19.0 is the one to read before pinning. It is the only entry in this
series that took something away, and it shipped before it was recorded here -
which is precisely the reason to pin tight rather than to trust that a minor
bump is safe. Earlier points
(0.1.x / 0.2.x) predate that recorded rationale; PluginMeta's fallback
core_version default of >=0.2.0,<1.0.0 is the only remaining trace of the
0.2 baseline.
Settled surface
These are unlikely to break before 1.0. A plugin can depend on them with reasonable confidence; a breaking change to any would be a major bump and would land with a new or amended ADR.
| Surface | What it is |
|---|---|
meta / manifest schema |
PluginMeta fields and manifest.json (ADR-002 single source of truth) |
get_blueprint |
Flask Blueprint registration at the manifest api_prefix |
get_models |
SQLAlchemy model classes the plugin owns |
init_app |
Custom init after blueprint + models are known |
get_cli_commands |
Click commands added to the Flask CLI |
get_services |
Named service classes, consumed by plugin_manager.get_service |
| Lifecycle hooks | on_install, on_uninstall, on_enable, on_disable |
get_navigation_items |
Sidebar menu entries |
get_dashboard_widgets |
Dashboard widgets, consumed by /api/dashboard/widgets |
get_reports |
Report cards, consumed by /api/reports (added 0.6.0) |
get_permissions |
Plugin RBAC permissions, merged into the catalog for roles and token scopes (added 0.10.0) |
| Frontend-contribution hooks | get_settings_cards, get_asset_panels, get_map_overlays, get_asset_presentation, consumed by /api/pluginui/* (added 0.7.0, ADR-010) |
| Collector pair | get_collector_schema + apply_collector_payload per ADR-006 |
| Settings helpers | get_setting / set_setting, namespaced to the plugin |
get_provisioning_note |
Setup-wizard transparency note for extra tables |
get_config_schema |
Setup-wizard config field declarations |
shopdb.api import surface |
The only core module plugins may import (plus shopdb.plugins.base); adding a name is minor, removing one is major |
The asset model itself - Asset, AssetType, AssetStatus,
AssetRelationship, and the shared reference models - is the platform contract
locked in ADR-001.
Expected churn before 1.0
Known-unstable areas. Building on these means expecting rework.
| Area | Status | Reference |
|---|---|---|
| Frontend renderers (residual) | The four data-only hooks and their /api/pluginui/* consumers are settled (0.7.0). The generic core renderers are landing incrementally: the settings-cards rail/landing renderer ships with 0.7.0; the asset-panel, map-overlay, and search-presentation renderers are wired opt-in per the ADR adoption plan. Real component-backed panels (bespoke charts, custom overlays) remain deferred to Option C (build-time glob discovery). |
ADR-010 (ACCEPTED) |
| Per-plugin migrations | Brand new. The per-plugin Alembic engine exists and every bundled plugin now carries a chain, but the pattern has one release of production mileage, not years. | ADR-008 (2026-07-10) |
| Pip distribution | Deferred to v2. External plugins install by clone / submodule / symlink; there is no entry-point discovery and no automatic update path yet. | ADR-003 |
Bump rules
From ADR-002, applied to __contract_version__:
| Bump | Trigger |
|---|---|
| major | Breaking change to the BasePlugin ABC, the PluginMeta schema, or any model in the platform contract (Asset, AssetType, AssetStatus, AssetRelationship, Vendor, Location, BusinessUnit, Model, OperatingSystem). Removing a name from shopdb.api is major. |
| minor | Additive change: a new optional hook, a new field on a contract model with a default, a new name added to shopdb.api. |
| patch | Bug fix with no change to the contract surface. |
Deprecation policy
ADR-002 defines the bump classification above but is silent on any
deprecation window or notice period for pre-1.0 removals. In practice a removal
is simply a major (or, pre-1.0, a breaking minor) bump: the hook or name is
gone, and the loader fails loud in dev or excludes the mismatched plugin in prod
(see the load-time table in PLUGIN-EXTERNAL-REPO.md).
Because the ADR says nothing about a grace period, this document proposes none;
the honest guidance for sister sites is the mitigation that already exists - pin
a tight core_version range and re-test before widening it.
Criteria for declaring 1.0
Provisional. This is the maintainer's working list, not a committed checklist,
and it will move. ADR-002's own open question ("When does the framework declare
1.0.0?") ties 1.0 to the Machine retirement from ADR-001 and the framework
being "ready for sister sites"; the items below expand that intent.
- Frontend hook contract defined via its own ADR (asset-detail panels, map markers, search results), closing the biggest churn item above.
Machine/ legacy-model retirement complete per ADR-001, so the asset model is the only contract.- At least two external plugins running in production at a second site - the bar ADR-003 already sets before pip distribution and sister-site readiness are considered justified.
- The per-plugin migration pattern (ADR-008) proven across real upgrades, not just fresh installs.
- No contract bump needed for N consecutive product releases (N to be fixed when the list is firmed up), showing the surface has actually settled.
Until then: pre-1.0, pin tight, re-test on every framework bump.