diff --git a/agent/installer/install-shopdbagent.ps1 b/agent/installer/install-shopdbagent.ps1 index 4fcbea71..5a08d3db 100644 --- a/agent/installer/install-shopdbagent.ps1 +++ b/agent/installer/install-shopdbagent.ps1 @@ -13,13 +13,25 @@ [CmdletBinding()] param( - # ShopDB server root URL for this instance. Defaulted to the test instance; - # pass -BaseUrl "http://:" to override. - [string] $BaseUrl = "http://H1HWNL84.device.geaerospace.net:5001", + # ShopDB server root URL for this instance. REQUIRED - there is no default, + # because a default here means one site's hostname shipped to every site + # (ADR-015) and, worse, a working install pointed at somebody else's server. + # + # https is required. The collector key travels in the X-API-Key header and + # every telemetry payload follows it, so plain http puts both on the wire in + # clear. Pass -AllowInsecureHttp only for a local test instance. + [Parameter(Mandatory = $true)] + [string] $BaseUrl, + + # Endpoints-only collector key: this instance's .env COLLECTOR_API_KEY_ENDPOINTS. + # REQUIRED and never defaulted - a key in source is a key in git history, and + # this one is also the command channel's kill switch. + [Parameter(Mandatory = $true)] + [string] $CollectorKey, + + # Permit an http:// BaseUrl. For a local test instance only. + [switch] $AllowInsecureHttp, - # Endpoints-only collector key (this instance's .env - # COLLECTOR_API_KEY_ENDPOINTS). - [string] $CollectorKey = "l755ddRw1iSeOcSTh9N9wXM4gGT7N6WIVN9crOY6uZU", [string] $InstallDir = "C:\Program Files\ShopDB\EndpointAgent", [string] $ServiceName = "ShopDbEndpointAgent", @@ -39,6 +51,12 @@ param( [switch] $NoDefenderExclusion ) +# Refuse cleartext unless it was asked for explicitly. The key is a bearer +# credential for the whole fleet's telemetry and command channel. +if ($BaseUrl -notmatch '^https://' -and -not $AllowInsecureHttp) { + throw "BaseUrl must be https:// (got '$BaseUrl'). Pass -AllowInsecureHttp for a local test instance." +} + $ErrorActionPreference = "Stop" $sourceDir = Split-Path -Parent $MyInvocation.MyCommand.Path diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 715161b3..3fed03f9 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -208,6 +208,11 @@ export const computersApi = { list(params = {}) { return api.get('/computers/types', { params }) }, + // Every type, for a dropdown. perpage is capped at 100 server-side, so + // asking for 200 returns 100 and silently truncates the list. + listAll(params = {}) { + return fetchAllPages('/computers/types', params) + }, get(id) { return api.get(`/computers/types/${id}`) }, diff --git a/frontend/src/views/settings/ImportSiteAssets.vue b/frontend/src/views/settings/ImportSiteAssets.vue index d2004c07..679eedc1 100644 --- a/frontend/src/views/settings/ImportSiteAssets.vue +++ b/frontend/src/views/settings/ImportSiteAssets.vue @@ -672,8 +672,8 @@ async function addComputerType() { async function loadComputerTypes() { try { - const response = await computersApi.types.list({ per_page: 200 }) - computerTypes.value = response.data?.data || response.data || [] + // listAll returns the rows themselves, already paged through. + computerTypes.value = await computersApi.types.listAll() } catch { computerTypes.value = [] } diff --git a/plugins/computers/api/policy_routes.py b/plugins/computers/api/policy_routes.py index c62f2d8d..e8e749be 100644 --- a/plugins/computers/api/policy_routes.py +++ b/plugins/computers/api/policy_routes.py @@ -12,7 +12,6 @@ allowlist, so a bad tree or an unknown action never reaches the database. """ from flask import request -from flask_jwt_extended import jwt_required from shopdb.api import (db, AuditLog, success_response, error_response, ErrorCodes, require_permission) @@ -63,7 +62,7 @@ def _businesspolicy_dict(policy): # ============================================================================= @computers_bp.route('/policies/configuration', methods=['GET']) -@jwt_required(optional=True) +@require_permission('computers.view') def list_configuration_policies(): gate = _feature_gate() if gate: @@ -73,7 +72,7 @@ def list_configuration_policies(): @computers_bp.route('/policies/configuration/', methods=['GET']) -@jwt_required(optional=True) +@require_permission('computers.view') def get_configuration_policy(policy_id: int): gate = _feature_gate() if gate: @@ -113,7 +112,6 @@ def _apply_actions(policy, actions): @computers_bp.route('/policies/configuration', methods=['POST']) -@jwt_required() @require_permission('computers.create') def create_configuration_policy(): gate = _feature_gate() @@ -155,7 +153,6 @@ def create_configuration_policy(): @computers_bp.route('/policies/configuration/', methods=['PUT']) -@jwt_required() @require_permission('computers.edit') def update_configuration_policy(policy_id: int): gate = _feature_gate() @@ -204,7 +201,6 @@ def update_configuration_policy(policy_id: int): @computers_bp.route('/policies/configuration/', methods=['DELETE']) -@jwt_required() @require_permission('computers.delete') def delete_configuration_policy(policy_id: int): gate = _feature_gate() @@ -236,7 +232,7 @@ def delete_configuration_policy(policy_id: int): # ============================================================================= @computers_bp.route('/policies/business', methods=['GET']) -@jwt_required(optional=True) +@require_permission('computers.view') def list_business_policies(): gate = _feature_gate() if gate: @@ -248,7 +244,7 @@ def list_business_policies(): @computers_bp.route('/policies/business/', methods=['GET']) -@jwt_required(optional=True) +@require_permission('computers.view') def get_business_policy(policy_id: int): gate = _feature_gate() if gate: @@ -284,7 +280,6 @@ def _apply_items(policy, items): @computers_bp.route('/policies/business', methods=['POST']) -@jwt_required() @require_permission('computers.create') def create_business_policy(): gate = _feature_gate() @@ -315,7 +310,6 @@ def create_business_policy(): @computers_bp.route('/policies/business/', methods=['PUT']) -@jwt_required() @require_permission('computers.edit') def update_business_policy(policy_id: int): gate = _feature_gate() @@ -355,7 +349,6 @@ def update_business_policy(policy_id: int): @computers_bp.route('/policies/business/', methods=['DELETE']) -@jwt_required() @require_permission('computers.delete') def delete_business_policy(policy_id: int): gate = _feature_gate() @@ -380,7 +373,7 @@ def delete_business_policy(policy_id: int): # ============================================================================= @computers_bp.route('/policies/actions', methods=['GET']) -@jwt_required(optional=True) +@require_permission('computers.view') def list_policy_actions(): """The allowlisted action names + labels a policy may reference.""" gate = _feature_gate() @@ -395,7 +388,7 @@ def list_policy_actions(): @computers_bp.route('/policies/effective/', methods=['GET']) -@jwt_required(optional=True) +@require_permission('computers.view') def get_effective_policy(asset_id: int): """Preview the resolved effective policy for one endpoint (debug/UI).""" gate = _feature_gate() diff --git a/shopdb/core/api/settings.py b/shopdb/core/api/settings.py index a2bb18de..4daaecb6 100644 --- a/shopdb/core/api/settings.py +++ b/shopdb/core/api/settings.py @@ -512,6 +512,20 @@ def import_asset_csvs(): # even when commit was asked for. db.session.rollback() elif commit: + # One audit row for the whole run, written INSIDE the same + # transaction so it cannot survive a rollback or go missing from a + # commit. Every other write in this app is auditable; a bulk import + # that creates thousands of rows was the one path where nobody could + # answer "who loaded this, and when". + AuditLog.log( + 'imported', 'SiteAssets', + entityname=', '.join(f['table'] for f in files_report) or 'none', + changes={ + 'files': [f['table'] for f in files_report], + 'created': sum(f['created'] for f in files_report), + 'updated': sum(f['updated'] for f in files_report), + 'reconciled': reconciled_count, + }) db.session.commit() applied = True else: diff --git a/shopdb/core/services/csvimport.py b/shopdb/core/services/csvimport.py index c5ebe904..7e093028 100644 --- a/shopdb/core/services/csvimport.py +++ b/shopdb/core/services/csvimport.py @@ -78,9 +78,12 @@ IMPORTABLE = OrderedDict([ # site can download the set, see the pattern, and keep it as a starter workbook: # every name referenced by a later file (Bay 3, Dell, Computer, ...) is defined # in an earlier one, so the whole set imports cleanly as a demo before a single -# real row is typed. Emitted as commented rows the engine skips, so a freshly -# downloaded template never writes example data by accident. Values avoid commas -# on purpose - the generator writes them without CSV quoting. +# real row is typed. They are emitted as LIVE ROWS, not comments: importing a +# freshly downloaded template writes PC-001, CNC-014, Dell and Bay 3, and +# updates any existing In Use / Retired status row it matches on. The emitted +# instructions say so, and test_template_example_set_imports_cleanly depends on +# it. Delete the example rows before importing real data. Values avoid commas on +# purpose - the generator writes them without CSV quoting. EXAMPLE_ROWS = OrderedDict([ ('assetstatuses', [ {'status': 'In Use', 'description': 'Actively deployed', 'color': '#22c55e', 'isactive': '1'}, diff --git a/tests/test_core/test_public_read_surface.py b/tests/test_core/test_public_read_surface.py index 8b4f552a..5ce526c1 100644 --- a/tests/test_core/test_public_read_surface.py +++ b/tests/test_core/test_public_read_surface.py @@ -73,6 +73,16 @@ PUBLIC_ENDPOINTS = { # The unguessable token IS the authorisation, and the recipient of a secure # send has no account here. 'tools.peek_secret', + + # The endpoint agent long-polls for its next command. Authenticated inside + # the handler by the endpoints collector API key - the same shape as the + # collector blueprint - because a bay has no user session. The credential is + # also the channel's kill switch. Note which half is public: CLAIMING a + # queued command is agent-facing, while ENQUEUEING one is the actual + # authority and stays gated on endpoints.command, audited with the + # requesting user. Same reasoning as endpoints.report_command_result in + # tests/test_core/test_authz.py. + 'endpoints.claim_next_command', } # Reads that need a LOGIN but no named permission, because the handler decides