diff --git a/shopdb/__init__.py b/shopdb/__init__.py index cdb3677a..a499270c 100644 --- a/shopdb/__init__.py +++ b/shopdb/__init__.py @@ -115,7 +115,13 @@ from .plugins import plugin_manager # 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 # 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 # plugin-contract version above are distinct series with independent diff --git a/shopdb/api/__init__.py b/shopdb/api/__init__.py index ff16e3f5..7fd1830e 100644 --- a/shopdb/api/__init__.py +++ b/shopdb/api/__init__.py @@ -70,7 +70,7 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query # Authorization decorators for gating plugin write routes 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, # GE-Enforce fetch, ...): checks a scoped managed token without exposing token @@ -380,6 +380,7 @@ __all__ = [ 'require_permission', 'require_any_permission', 'caller_holds', + 'caller_is_role', 'require_role', 'service_token_authorized', 'authorized_service_token', diff --git a/shopdb/plugins/alembic_template.py b/shopdb/plugins/alembic_template.py index d73b9187..86d4888d 100644 --- a/shopdb/plugins/alembic_template.py +++ b/shopdb/plugins/alembic_template.py @@ -51,9 +51,14 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = { 'computers': ('computertypes', 'computers', 'computerinstalledapps', 'accessprotocols', 'computeraccess', 'configurationpolicies', 'configurationpolicyactions', - 'businesspolicies', 'businesspolicyitems'), + 'businesspolicies', 'businesspolicyitems', + 'policyactionholds'), 'employees': ('directoryemployees',), - 'endpoints': ('endpointstates', 'endpointevents', 'endpointcommands', 'endpointapplications', 'endpointmonitors', 'operatorreports'), + 'endpoints': ('endpointstates', 'endpointevents', 'endpointcommands', + 'endpointapplications', 'endpointmonitors', + 'operatorreports', 'endpointhealthstates', + 'endpointhealthtransitions', 'endpointmonitorimpacts', + 'endpointlocks'), 'geenforce': ('manifestscopes', 'manifestentries', 'manifestentrypctypes', 'manifestentryhostnames', 'manifestentrymachinenumbers', 'manifestinusechecks', 'manifestinusecheckprocesses', diff --git a/tests/test_core/test_security_headers.py b/tests/test_core/test_security_headers.py index b20e2ae2..50a35e62 100644 --- a/tests/test_core/test_security_headers.py +++ b/tests/test_core/test_security_headers.py @@ -47,6 +47,7 @@ def test_a_served_upload_is_sandboxed(client, app, tmp_path, auth_headers): else: path.write_text(svg) + resp = None try: resp = client.get('/api/applications/image/probe-headers.svg', headers=auth_headers) 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' finally: 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) diff --git a/tests/test_plugin_importguard.py b/tests/test_plugin_importguard.py index 0cdead78..80b8b79a 100644 --- a/tests/test_plugin_importguard.py +++ b/tests/test_plugin_importguard.py @@ -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}', })) (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') 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' - (pdir / 'plugin.py').write_text('# tampered\n') + (pdir / 'plugin.py').write_bytes(b'# tampered\n') with pytest.raises(PluginVerificationError): guard.verified_source(name, 'plugin.py') diff --git a/tests/test_plugin_migrations.py b/tests/test_plugin_migrations.py index 971b85fc..37917a72 100644 --- a/tests/test_plugin_migrations.py +++ b/tests/test_plugin_migrations.py @@ -48,8 +48,10 @@ EXPECTED_HEAD_REVISION = {plugin: f'{plugin}0001anchor' for plugin in CUTOVER_PL EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline' # computers adds the Entra/Azure device identity columns (azuredeviceid, then # azureobjectid + azuredevicecategory), then the Configuration/Business policy -# tables, on top of its anchor, so its head is not the f-string default. -EXPECTED_HEAD_REVISION['computers'] = 'computers0004policies' +# tables, then the break-glass policyactionholds table, on top of its anchor, so +# 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 # 0002 adds lastseenat - when a config was last CONFIRMED unchanged, which dedup # otherwise throws away. @@ -106,7 +108,12 @@ EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof' # 0013 adds the inventory columns. # 0014 adds endpointstates.diskhealthjson, and 0015 adds the system telemetry # 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 # core chain never owned.