Bump plugin contract to 0.32.0 and register new plugin tables

Exports caller_is_role on the plugin authorization surface. Additive name, so a minor bump. Scoped personal tokens are always refused by it, matching require_role, which stops a plugin from accidentally letting a scoped token inherit role authority from its owner.

Registers the tables added by the endpoint lock and policy hold work (endpointlocks, policyactionholds) plus the endpoint health tables in the alembic template, so plugin migrations see them and the ownership guard does not report them as unclaimed.
This commit is contained in:
benjamin.baker
2026-09-07 12:40:12 -04:00
parent 0d5b9730e3
commit 4c1d96b6ba
6 changed files with 36 additions and 9 deletions

View File

@@ -115,7 +115,13 @@ from .plugins import plugin_manager
# tab groups (serviceprocess, reports), a datetime column format, a map body # tab groups (serviceprocess, reports), a datetime column format, a map body
# key, and a tabs section that renders as a table. All optional, so a plugin # key, and a tabs section that renders as a table. All optional, so a plugin
# that declares none of them is unaffected. Additive, minor bump. # that declares none of them is unaffected. Additive, minor bump.
__contract_version__ = '0.31.0' # 0.32.0 adds caller_is_role to the authorization surface: the body-answering
# counterpart to require_role, for a route that serves everyone but treats one
# class of caller differently (admin break-glass override). Scoped personal
# tokens are always refused by it, matching require_role, so a plugin cannot
# accidentally let a scoped token inherit role authority from its owner.
# Additive name on the surface, minor bump.
__contract_version__ = '0.32.0'
# Product release version (see ADR-007). The product version and the # Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent # plugin-contract version above are distinct series with independent

View File

@@ -70,7 +70,7 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
# Authorization decorators for gating plugin write routes # Authorization decorators for gating plugin write routes
from shopdb.utils.authz import (require_permission, require_any_permission, from shopdb.utils.authz import (require_permission, require_any_permission,
require_role, caller_holds) require_role, caller_holds, caller_is_role)
# Service-token authorization for unattended plugin endpoints (collector, # Service-token authorization for unattended plugin endpoints (collector,
# GE-Enforce fetch, ...): checks a scoped managed token without exposing token # GE-Enforce fetch, ...): checks a scoped managed token without exposing token
@@ -380,6 +380,7 @@ __all__ = [
'require_permission', 'require_permission',
'require_any_permission', 'require_any_permission',
'caller_holds', 'caller_holds',
'caller_is_role',
'require_role', 'require_role',
'service_token_authorized', 'service_token_authorized',
'authorized_service_token', 'authorized_service_token',

View File

@@ -51,9 +51,14 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
'computers': ('computertypes', 'computers', 'computerinstalledapps', 'computers': ('computertypes', 'computers', 'computerinstalledapps',
'accessprotocols', 'computeraccess', 'accessprotocols', 'computeraccess',
'configurationpolicies', 'configurationpolicyactions', 'configurationpolicies', 'configurationpolicyactions',
'businesspolicies', 'businesspolicyitems'), 'businesspolicies', 'businesspolicyitems',
'policyactionholds'),
'employees': ('directoryemployees',), 'employees': ('directoryemployees',),
'endpoints': ('endpointstates', 'endpointevents', 'endpointcommands', 'endpointapplications', 'endpointmonitors', 'operatorreports'), 'endpoints': ('endpointstates', 'endpointevents', 'endpointcommands',
'endpointapplications', 'endpointmonitors',
'operatorreports', 'endpointhealthstates',
'endpointhealthtransitions', 'endpointmonitorimpacts',
'endpointlocks'),
'geenforce': ('manifestscopes', 'manifestentries', 'manifestentrypctypes', 'geenforce': ('manifestscopes', 'manifestentries', 'manifestentrypctypes',
'manifestentryhostnames', 'manifestentrymachinenumbers', 'manifestentryhostnames', 'manifestentrymachinenumbers',
'manifestinusechecks', 'manifestinusecheckprocesses', 'manifestinusechecks', 'manifestinusecheckprocesses',

View File

@@ -47,6 +47,7 @@ def test_a_served_upload_is_sandboxed(client, app, tmp_path, auth_headers):
else: else:
path.write_text(svg) path.write_text(svg)
resp = None
try: try:
resp = client.get('/api/applications/image/probe-headers.svg', headers=auth_headers) resp = client.get('/api/applications/image/probe-headers.svg', headers=auth_headers)
assert resp.status_code == 200 assert resp.status_code == 200
@@ -54,4 +55,8 @@ def test_a_served_upload_is_sandboxed(client, app, tmp_path, auth_headers):
assert resp.headers.get('X-Content-Type-Options') == 'nosniff' assert resp.headers.get('X-Content-Type-Options') == 'nosniff'
finally: finally:
import os import os
# The streamed file response keeps the handle open, and Windows refuses
# to delete a file that is still open. Release it before cleaning up.
if resp is not None:
resp.close()
os.remove(path) os.remove(path)

View File

@@ -28,7 +28,10 @@ def _write_pkg_plugin(plugins_dir, name, marker='source'):
'core_version': '>=0.1.0,<1.0.0', 'api_prefix': f'/api/{name}', 'core_version': '>=0.1.0,<1.0.0', 'api_prefix': f'/api/{name}',
})) }))
(pdir / '__init__.py').write_text('') (pdir / '__init__.py').write_text('')
(pdir / 'plugin.py').write_text('# plugin\n') # write_bytes, not write_text: on Windows write_text translates \n to
# \r\n, which changes the bytes that get signed and hashed and makes the
# verified_source assertion below fail for a purely platform reason.
(pdir / 'plugin.py').write_bytes(b'# plugin\n')
(pdir / 'models' / '__init__.py').write_text(f'MARKER = {marker!r}\n') (pdir / 'models' / '__init__.py').write_text(f'MARKER = {marker!r}\n')
return pdir return pdir
@@ -81,7 +84,7 @@ def test_verified_source_signed_ok_tampered_raises(guarded):
assert guard.verified_source(name, 'plugin.py') == b'# plugin\n' assert guard.verified_source(name, 'plugin.py') == b'# plugin\n'
(pdir / 'plugin.py').write_text('# tampered\n') (pdir / 'plugin.py').write_bytes(b'# tampered\n')
with pytest.raises(PluginVerificationError): with pytest.raises(PluginVerificationError):
guard.verified_source(name, 'plugin.py') guard.verified_source(name, 'plugin.py')

View File

@@ -48,8 +48,10 @@ EXPECTED_HEAD_REVISION = {plugin: f'{plugin}0001anchor' for plugin in CUTOVER_PL
EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline' EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline'
# computers adds the Entra/Azure device identity columns (azuredeviceid, then # computers adds the Entra/Azure device identity columns (azuredeviceid, then
# azureobjectid + azuredevicecategory), then the Configuration/Business policy # azureobjectid + azuredevicecategory), then the Configuration/Business policy
# tables, on top of its anchor, so its head is not the f-string default. # tables, then the break-glass policyactionholds table, on top of its anchor, so
EXPECTED_HEAD_REVISION['computers'] = 'computers0004policies' # its head is not the f-string default. 0006 widens policyactionholds.expiresat
# to nullable, where null means an indefinite hold that never self-clears.
EXPECTED_HEAD_REVISION['computers'] = 'computers0006holdindefinite'
# backups is also post-cutover: its 0001 really creates backuprevisions, and # backups is also post-cutover: its 0001 really creates backuprevisions, and
# 0002 adds lastseenat - when a config was last CONFIRMED unchanged, which dedup # 0002 adds lastseenat - when a config was last CONFIRMED unchanged, which dedup
# otherwise throws away. # otherwise throws away.
@@ -106,7 +108,12 @@ EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof'
# 0013 adds the inventory columns. # 0013 adds the inventory columns.
# 0014 adds endpointstates.diskhealthjson, and 0015 adds the system telemetry # 0014 adds endpointstates.diskhealthjson, and 0015 adds the system telemetry
# columns (boot info, reboot reasons, Defender health, NIC link state). # columns (boot info, reboot reasons, Defender health, NIC link state).
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0016dellupdates' # 0016 adds the Dell update tables.
# 0017 adds the health rollup: endpointhealthstates, endpointhealthtransitions
# and endpointmonitorimpacts.
# 0018 adds endpointlocks: the admin quarantine refusing actions from non-admins
# on one endpoint, a caller-authority axis separate from the policy hold.
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0018endpointlocks'
# Plugins built after the cutover: their 0001 baseline really creates tables the # Plugins built after the cutover: their 0001 baseline really creates tables the
# core chain never owned. # core chain never owned.