Act on the wj architectural review: races, limits, pool and contract gaps
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / gates (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / lean-build (push) Has been cancelled

Findings F2, F4, F5, F6, F7, F8, F9, F10, F11 and F13 from
wj-architectural-review.md. F1/F1a (the non-sargable LOWER pattern) and F3
(the warranty sync holding a pooled connection) are being handled separately
and are untouched here.

- A collector check-in that raced another for the same PC lost everything:
  the unique index on the application fingerprint raised, nothing caught it,
  and the whole check-in rolled back, discarding state, events and hardware,
  answering the agent with form copy meant for a person. The insert now runs
  in a savepoint, reconciles what the other transaction already wrote, one row
  per savepoint so a second collision skips a row rather than the batch, and
  the check-in commits with a warning. This is the shape the delete side of
  the same function already had.
- Enrollment is deliberately unauthenticated, so it now carries a per-address
  rate limit and a cap on pending requests, and the cap is a setting an admin
  can see and change. A flood can no longer bury the approval queue or grow
  the table without bound.
- The fleet alert poll no longer formats debug strings on every request.
- BusinessUnit is on the contract surface (0.34.0), so the endpoints plugin
  stops reaching into a core table with raw SQL.
- A test walks every asset type descriptor and proves the endpoint it names
  really refuses a caller without the declared permission.
- The plugin settings routes now need a permission; they were telling anyone
  which modules a site runs and where its components live.
- The connection pool is sized explicitly with a short timeout, and the
  arithmetic against the server's connection ceiling is written next to the
  worker count instead of being left to defaults nobody chose.
- Fleet alerts carry both the correct lowercase keys and the old camelCase
  pair. Trays older than 1.7.0 match keys case-sensitively and would have gone
  quiet on every PC not yet upgraded; the old pair goes when they are.
- The dev server proxy points at localhost again, with environment variables
  for the remote-box workflow, and one developer's address is out of the
  server certificate page.
This commit is contained in:
cproudlock
2026-09-16 08:57:04 -04:00
parent 2557d1967d
commit 066a19052b
30 changed files with 1149 additions and 114 deletions

View File

@@ -0,0 +1,79 @@
"""A descriptor's edit_permission must be real, not advisory.
`get_asset_type_descriptor` declares `edit_permission`, and
/api/pluginui/asset-type-descriptors hands it to the Asset Type Hub, which
renders generic CRUD against the declared `subtype_endpoint`. Nothing in the
contract makes the plugin actually enforce that permission on its own routes.
Every shipped plugin does today. The risk is structural: a plugin author can
declare a permission, forget the decorator, and ship a UI that looks locked over
an API that is open - and the contract invites exactly that mistake. So walk the
descriptors the API really returns and prove each named endpoint refuses a
caller who holds nothing.
A signed-in caller with no permissions, not an anonymous one: anonymous would
pass on the 401 and prove nothing about authorization.
"""
import pytest
DESCRIPTORS_URL = '/api/pluginui/asset-type-descriptors'
# The write methods the Asset Type Hub drives through subtype_endpoint. GET is
# not here on purpose: a list may legitimately need only a read permission,
# while edit_permission is about changing a subtype.
WRITE_CALLS = (
('post', '{endpoint}'),
('put', '{endpoint}/1'),
('delete', '{endpoint}/1'),
)
def _descriptors(client, auth_headers):
response = client.get(DESCRIPTORS_URL, headers=auth_headers)
assert response.status_code == 200, response.get_data(as_text=True)
return response.get_json()['data']
def test_there_are_descriptors_to_check(client, db, auth_headers):
"""A silently empty list would make the sweep below vacuous."""
assert _descriptors(client, auth_headers), (
'no plugin returned an asset type descriptor; the enforcement sweep '
'below would pass without checking anything')
def test_every_descriptor_endpoint_refuses_an_unpermissioned_caller(
client, db, auth_headers, member_headers):
"""The permission a descriptor advertises has to be the one the API asks
for. A route that answers anything but 403 here is a lock drawn on a door
that is not locked."""
open_routes = []
for descriptor in _descriptors(client, auth_headers):
endpoint = '/api' + descriptor['subtype_endpoint']
for method, template in WRITE_CALLS:
path = template.format(endpoint=endpoint)
call = getattr(client, method)
response = call(path, json={'name': 'x'}, headers=member_headers)
if response.status_code != 403:
open_routes.append(
'{0} declares {1} but {2} {3} answered {4}'.format(
descriptor.get('plugin'),
descriptor.get('edit_permission'),
method.upper(), path, response.status_code))
assert not open_routes, (
'These descriptors advertise protection the API does not enforce. '
'Decorate the subtype routes with @require_permission for the '
'permission the descriptor names:\n ' + '\n '.join(open_routes))
@pytest.mark.parametrize('field', ['assettype', 'subtype_endpoint',
'edit_permission'])
def test_every_descriptor_names_what_the_sweep_needs(client, db, auth_headers,
field):
"""The sweep above can only check a descriptor that says what to check."""
for descriptor in _descriptors(client, auth_headers):
assert descriptor.get(field), (
'{0} returned a descriptor with no {1}'.format(
descriptor.get('plugin'), field))

View File

@@ -1,7 +1,8 @@
"""GET /api/plugins/settings-routes survives a plugin whose hook raises."""
"""GET /api/plugins/settings-routes: isolation, and who may read it."""
def test_a_raising_plugin_is_skipped_not_fatal(app, client, db, monkeypatch):
def test_a_raising_plugin_is_skipped_not_fatal(app, client, db, monkeypatch,
auth_headers):
manager = app.extensions.get('plugin_manager')
plugins = manager.get_all_plugins()
enabled = [name for name in plugins if manager.registry.is_enabled(name)]
@@ -21,8 +22,26 @@ def test_a_raising_plugin_is_skipped_not_fatal(app, client, db, monkeypatch):
monkeypatch.setattr(plugins[healthy], 'get_settings_routes', contribute,
raising=False)
response = client.get('/api/plugins/settings-routes')
response = client.get('/api/plugins/settings-routes',
headers=auth_headers)
assert response.status_code == 200, response.get_json()
names = [route['name'] for route in response.get_json()['data']['routes']]
assert 'probe-route' in names
def test_an_anonymous_caller_is_refused(client, db):
"""It names the enabled plugins, the settings hub layout and the internal
component paths. That is reconnaissance, and nothing needs it before login -
its only caller is the Model Catalog hub, behind auth and admin."""
response = client.get('/api/plugins/settings-routes')
assert response.status_code in (401, 403, 422), response.get_data(as_text=True)
def test_a_caller_without_settings_view_is_refused(client, db, member_headers):
"""Signed in is not enough: the gate is the permission, not the session."""
response = client.get('/api/plugins/settings-routes',
headers=member_headers)
assert response.status_code == 403, response.get_data(as_text=True)

View File

@@ -26,11 +26,12 @@ PUBLIC_ENDPOINTS = {
'setup.needs_admin',
'auth.get_current_user',
'plugins.list_enabled_plugins',
'plugins.get_plugin_settings_routes', # plugin-contributed settings routes
# for dynamic UI extension; must answer
# unauthenticated pages (kiosks, TV
# displays) so they can register plugin
# tabs before login
# plugins.get_plugin_settings_routes was here, on the reasoning that
# unauthenticated pages had to register plugin tabs before login. Nothing
# did: its only caller is the Model Catalog settings hub, which is behind
# requiresAuth + requiresAdmin. Open, it named the enabled plugins, the
# settings hub layout and the internal component paths to anybody who
# asked, so it is now gated on settings.view like the plugin list beside it.
'dashboard.get_navigation', # filtered per caller; a guest sees only
# what the guest role can reach
'dashboard.get_capabilities', # booleans about the CALLER, used to decide