Act on the wj architectural review: races, limits, pool and contract gaps
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:
79
tests/test_core/test_asset_type_descriptor_enforcement.py
Normal file
79
tests/test_core/test_asset_type_descriptor_enforcement.py
Normal 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))
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user