Make main the lean product a site receives
main was 16 days and 259 commits behind the line the product is actually built on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It was neither current nor curated - just stale. This brings it forward and, at the same time, makes it mean something: main is what a receiving site installs, and nothing else. WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides, knowledgebase, machines, network, notifications, printers, slides, tools, warranty - plus applications, which is manifest-less core and always ships. The only manifest dependency inside that set is endpoints on computers, and both are in it, so the closure is self-contained. WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb stay on the site line. So do the Windows installer tree, the VM rehearsal helpers, the agent tooling config, one site's data loader, the internal audit and findings records, and the planner spreadsheet. 1,135 tracked files against 1,438. A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped: deploy/site-profile-universal.json still named all seventeen plugins. It is the file docs/DEPLOY.md tells a site to provision from, so a fresh install would have failed at `flask plugin apply-profile` with PluginError - the first thing a receiving site does, broken by the branch meant to serve them. It now names the twelve that exist, and its comment says the two must agree. agent/ was never refreshed. Whoever populated this branch copied shopdb/, plugins/, frontend/ and tests/ from the site line and left the agent tree at its September state: 22 C# and PowerShell sources missing, including AgentCredentials, EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the site line had deleted still present. The old tree is internally consistent, so it compiles, and nothing in CI builds it - every gate was green over a server that speaks enrollment and per-PC credentials to a bundled agent source that cannot. Resynced whole. DECISIONS THIS ENCODES, rather than defers: - The plugin-authoring lab builds printedparts, whose manifest depends on employees, so it could not stay. The lab remains on the site line and main ships no build-along tutorial. That is a real loss for an adopting developer and is recorded here rather than discovered later. - Recognition, Training and Recertification notifications are no longer seeded. Each splits one notification into a card per employee and renders a photo, which needs a plugin this branch does not bundle. Seeding them would offer an author a type that cannot resolve anybody. A site that installs employees gets them from that plugin's own seed. - Windows/IIS is documented as the legacy path throughout, and the README leads with Docker, because that is what a receiving site gets. The API description is regenerated for THIS branch - 347 paths and 483 operations, down from 415 and 573 - so the published contract describes what is here rather than what the site line serves. docs/CONFIG.md lost the two measuring-tool label keys the code no longer declares, which two lanes had decided opposite ways. Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run rather than skip, and every index line count is derived from the file it names.
This commit is contained in:
@@ -127,8 +127,7 @@ IDENTIFIER_LABELS = {
|
||||
'maintenancereference': 'Maintenance Reference',
|
||||
'fqdn': 'FQDN / hostname',
|
||||
}
|
||||
IDENTIFIER_ASSETTYPES = ['machine', 'computer', 'printer', 'network_device',
|
||||
'measuring_tool']
|
||||
IDENTIFIER_ASSETTYPES = ['machine', 'computer', 'printer', 'network_device']
|
||||
|
||||
# Global-search result types that can be toggled on/off independently of whether
|
||||
# the owning plugin is enabled. Keys match the `type` field on search results;
|
||||
@@ -143,11 +142,8 @@ SEARCH_DOMAINS = {
|
||||
'computer': 'PCs',
|
||||
'printer': 'Printers',
|
||||
'network_device': 'Network Devices',
|
||||
'measuring_tool': 'Measuring Tools',
|
||||
'notification': 'Notifications',
|
||||
'subnet': 'Subnets',
|
||||
'usb_device': 'USB Devices',
|
||||
'printed_item': 'Printed Items',
|
||||
}
|
||||
|
||||
def _declared_default(key: str) -> dict:
|
||||
@@ -161,14 +157,50 @@ def _declared_default(key: str) -> dict:
|
||||
return _plugin_declared_settings().get(key, {})
|
||||
|
||||
|
||||
def _is_secret(key: str) -> bool:
|
||||
return 'password' in key or 'token' in key or 'secret' in key
|
||||
def _is_secret(key: str, valuetype=None) -> bool:
|
||||
"""True when a key's value must never leave the API in plaintext.
|
||||
|
||||
The test is the key NAME, which is a heuristic, so it is narrowed by the
|
||||
stored type when the caller knows it. A boolean, integer or float setting
|
||||
cannot hold a secret, and masking one breaks the control that renders it:
|
||||
plugin.endpoints.bootstrapquerytoken is a true/false switch whose name
|
||||
contains 'token', and the settings page read back '********' instead of
|
||||
its state, so the checkbox could not show whether it was on.
|
||||
|
||||
valuetype omitted means "judge by name alone", which is the safe direction:
|
||||
an unknown type is treated as a string.
|
||||
"""
|
||||
named = 'password' in key or 'token' in key or 'secret' in key
|
||||
if not named:
|
||||
return False
|
||||
return valuetype is None or str(valuetype).lower() == 'string'
|
||||
|
||||
|
||||
def _inferred_valuetype(value) -> str:
|
||||
"""Valuetype for a key nobody declares, from the JSON type sent.
|
||||
|
||||
Only reached on the upsert path in update_setting. JSON already carries the
|
||||
distinction the row has to keep, so honour it: true/false is a boolean row,
|
||||
a number is integer or float, everything else is a string. Guessing from
|
||||
the TEXT (e.g. the string 'true') is deliberately not done - a genuine
|
||||
string setting whose value happens to read 'true' would then be typed
|
||||
boolean and coerced out from under its reader.
|
||||
|
||||
bool is checked before int because bool subclasses int in Python.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return 'boolean'
|
||||
if isinstance(value, int):
|
||||
return 'integer'
|
||||
if isinstance(value, float):
|
||||
return 'float'
|
||||
return 'string'
|
||||
|
||||
|
||||
def _serialize_setting(setting):
|
||||
"""Serialize a setting, masking secret values so they never leave the API."""
|
||||
data = setting.to_dict()
|
||||
if _is_secret(setting.key):
|
||||
if _is_secret(setting.key, setting.valuetype):
|
||||
data['value'] = SECRET_MASK if setting.value else ''
|
||||
return data
|
||||
|
||||
@@ -795,7 +827,14 @@ def update_setting(key: str):
|
||||
setting = Setting(
|
||||
key=key,
|
||||
value='',
|
||||
valuetype=declared.get('valuetype', 'string'),
|
||||
# An undeclared key used to be born valuetype='string'
|
||||
# unconditionally, and that was a trap: a caller PUTting JSON true
|
||||
# got a string row, get_typed_value then handed every reader the
|
||||
# text 'true', and a reader doing bool(...) or `is True` silently
|
||||
# disagreed with the UI. Infer from the JSON type the client sent
|
||||
# instead, so a boolean key is born boolean.
|
||||
valuetype=declared.get('valuetype') or _inferred_valuetype(
|
||||
data.get('value')),
|
||||
category=declared.get('category', 'plugin'),
|
||||
description=declared.get('description'),
|
||||
)
|
||||
@@ -808,7 +847,7 @@ def update_setting(key: str):
|
||||
|
||||
# A secret submitted as the mask placeholder means "leave unchanged" - the
|
||||
# client only ever received the mask, so don't overwrite the real secret.
|
||||
if _is_secret(key) and value == SECRET_MASK:
|
||||
if _is_secret(key, setting.valuetype) and value == SECRET_MASK:
|
||||
return success_response(_serialize_setting(setting), message='Setting unchanged')
|
||||
|
||||
# The declared valuetype is a promise about what every reader will get, and
|
||||
@@ -846,7 +885,7 @@ def update_setting(key: str):
|
||||
setting.value = str(value) if value is not None else None
|
||||
|
||||
# Audit log (mask sensitive values)
|
||||
is_sensitive = _is_secret(key)
|
||||
is_sensitive = _is_secret(key, setting.valuetype)
|
||||
AuditLog.log('updated', 'Setting', entityname=key, changes={
|
||||
'value': {
|
||||
'old': '***' if is_sensitive else old_value,
|
||||
@@ -915,10 +954,13 @@ def create_setting():
|
||||
else:
|
||||
value_str = str(value) if value is not None else None
|
||||
|
||||
# Same reasoning as the upsert in update_setting: a caller that omits
|
||||
# valuetype and sends JSON true must not get a string row that every
|
||||
# boolean reader then misreads.
|
||||
setting = Setting(
|
||||
key=data['key'],
|
||||
value=value_str,
|
||||
valuetype=data.get('valuetype', 'string'),
|
||||
valuetype=data.get('valuetype') or _inferred_valuetype(value),
|
||||
category=data.get('category', 'general'),
|
||||
description=data.get('description')
|
||||
)
|
||||
@@ -1025,18 +1067,14 @@ def build_default_settings():
|
||||
'description': "IANA timezone for this site (e.g. America/New_York, America/Chicago). Notification start/end times and daily-reset expiry are computed in this zone."
|
||||
},
|
||||
{
|
||||
'key': 'employee_directory_mode',
|
||||
'value': 'selfhosted',
|
||||
# Where a relative Picture path from the external HR directory
|
||||
# resolves. Blank = this site's /static/employees/. A site whose
|
||||
# photos live on another host points this at that host instead.
|
||||
'key': 'employee_photo_base_url',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'site',
|
||||
'description': "Employee directory source: 'selfhosted' (tables in this app, default) or 'external' (a separate HR database)"
|
||||
},
|
||||
{
|
||||
'key': 'usb_directory_mode',
|
||||
'value': 'selfhosted',
|
||||
'valuetype': 'string',
|
||||
'category': 'site',
|
||||
'description': "USB check-in/out source: 'selfhosted' (tables in this app, default) or 'external' (a separate cmmc_usb database)"
|
||||
'description': 'Base URL for employee photos supplied as relative paths by the HR directory, e.g. https://host/EmployeeDBAPP/images/. Blank = this site\'s /static/employees/.'
|
||||
},
|
||||
{
|
||||
'key': 'site_base_url',
|
||||
@@ -1183,20 +1221,6 @@ def build_default_settings():
|
||||
'category': 'printing',
|
||||
'description': 'Custom URL template for printer QR labels. Blank = link to the printer page. Placeholders: {printerid}, {assetid}, {assetnumber}, {serialnumber}, {ip}, {hostname}.'
|
||||
},
|
||||
{
|
||||
'key': 'qr_target_usb',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'printing',
|
||||
'description': 'Custom URL template for USB label QR codes. Blank = link to the USB device page. Placeholders: {id}, {serialnumber}, {alias}.'
|
||||
},
|
||||
{
|
||||
'key': 'usb_label_style',
|
||||
'value': 'barcode',
|
||||
'valuetype': 'string',
|
||||
'category': 'printing',
|
||||
'description': "USB mini-label code style: 'barcode' (CODE128 of the serial number) or 'qr' (QR code linking to the QR target)."
|
||||
},
|
||||
{
|
||||
'key': 'qr_target_machine',
|
||||
'value': '',
|
||||
@@ -1218,13 +1242,6 @@ def build_default_settings():
|
||||
'category': 'printing',
|
||||
'description': 'Custom URL template for network-device labels. Blank = link to the device page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}.'
|
||||
},
|
||||
{
|
||||
'key': 'qr_target_measuring_tool',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'printing',
|
||||
'description': 'Custom URL template for measuring-tool labels. Blank = link to the tool page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}, {locationcode}, {locationname}.'
|
||||
},
|
||||
{
|
||||
'key': 'label_default_style',
|
||||
'value': 'card',
|
||||
@@ -1242,15 +1259,15 @@ def build_default_settings():
|
||||
]
|
||||
|
||||
# Per-asset-type default for what a label's code encodes. Machines default
|
||||
# to their machine number (assetnumber), measuring tools to their inspection
|
||||
# location code, everything else to a link to the asset page. Values:
|
||||
# assetpage | assetnumber | serialnumber | location | custom.
|
||||
# to their machine number (assetnumber), everything else to a link to the
|
||||
# asset page. Values: assetpage | assetnumber | serialnumber | location |
|
||||
# custom - 'location' stays a legal value because a plugin may add an asset
|
||||
# type whose label is read off a shelf, not off the asset.
|
||||
labelencodesdefaults = {
|
||||
'machine': 'assetnumber',
|
||||
'computer': 'assetpage',
|
||||
'printer': 'assetpage',
|
||||
'network_device': 'assetpage',
|
||||
'measuring_tool': 'location',
|
||||
}
|
||||
printingdefaults += [
|
||||
{
|
||||
@@ -1259,16 +1276,23 @@ def build_default_settings():
|
||||
'valuetype': 'string',
|
||||
'category': 'printing',
|
||||
'description': f'What a {assettype} label encodes by default: assetpage, '
|
||||
'assetnumber, serialnumber'
|
||||
+ (', location' if assettype == 'measuring_tool' else '')
|
||||
+ ', or custom.',
|
||||
'assetnumber, serialnumber, or custom.',
|
||||
}
|
||||
for assettype, value in labelencodesdefaults.items()
|
||||
]
|
||||
|
||||
# Collector pc-type -> ComputerType mapping is computers-plugin domain;
|
||||
# the plugin seeds pctypemap_<pxetype> settings on install.
|
||||
# Collector authentication. The wording lives with the code that enforces
|
||||
# it (shopdb/core/api/collector.py); seeding it here is what makes the row
|
||||
# exist, and the row is what the settings UI can render a control for.
|
||||
# Imported inside the function because core.api.__init__ imports collector
|
||||
# and settings in the same package - a module-level import would couple
|
||||
# their import order for no gain.
|
||||
from shopdb.core.api.collector import LEGACY_SHARED_KEY_SETTING_DEFAULT
|
||||
|
||||
defaults = sitedefaults + brandingdefaults + printingdefaults + identifierdefaults + searchdefaults + mapdefaults + [
|
||||
LEGACY_SHARED_KEY_SETTING_DEFAULT,
|
||||
# ServiceNow ticket links. Each instance points these at its own
|
||||
# ServiceNow tenant; blank/disabled renders tickets as plain text.
|
||||
#
|
||||
@@ -1320,6 +1344,21 @@ def build_default_settings():
|
||||
'DeviceSettingsMenuBlade/~/overview/aadDeviceId/{deviceid}'
|
||||
)
|
||||
},
|
||||
{
|
||||
# ADR-015-OK: the DEFAULT is empty, so no cloud is assumed. Same
|
||||
# reasoning as entra_device_url - the LAPS blade lives in the same
|
||||
# tenant-specific portal, so the host cannot be a product fact.
|
||||
'key': 'laps_device_url',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'integrations',
|
||||
'description': (
|
||||
'Azure LAPS (local admin password) URL template. {objectid} is '
|
||||
'the device object id. Example (US Gov): https://portal.azure.us/'
|
||||
'?feature.msaljs=true#view/Microsoft_AAD_Devices/'
|
||||
'DeviceDetailsMenuBlade/~/LocalAdminPassword/objectId/{objectid}'
|
||||
)
|
||||
},
|
||||
{
|
||||
'key': 'servicenow_ticket_prefixes',
|
||||
'value': 'GEINC,GECHG,GERIT,GESCT',
|
||||
@@ -1488,6 +1527,99 @@ def build_default_settings():
|
||||
'Incoming Webhook), adaptivecard (Teams Workflow), '
|
||||
'or json (generic {title,text})'
|
||||
},
|
||||
# Backup destination settings
|
||||
#
|
||||
# These are the ground wiring for where the one-shot backup container
|
||||
# ships each set. 'local' keeps sets on the mounted backups volume and
|
||||
# needs no credentials. 'smb' and 'aws' are declared so the UI and the
|
||||
# backup runner can be configured now; the client libraries (an SMB
|
||||
# stack, boto3/awscli) are deliberately NOT pulled in yet - those land
|
||||
# behind the security review that vets third-party deps against
|
||||
# external exploit. Until then a non-local destination is accepted and
|
||||
# persisted but the runner treats it as not-yet-armed.
|
||||
#
|
||||
# Secrets follow the existing convention: any key containing
|
||||
# 'password'/'secret'/'token' is masked by _is_secret on the way out
|
||||
# and preserved when the client submits the mask back unchanged.
|
||||
{
|
||||
'key': 'backup_destination_kind',
|
||||
'value': 'local',
|
||||
'valuetype': 'string',
|
||||
'category': 'backup',
|
||||
'description': 'Where backup sets are shipped: local, smb, or aws. '
|
||||
'Non-local destinations are wiring-only until the '
|
||||
'security review clears their client libraries.'
|
||||
},
|
||||
{
|
||||
'key': 'backup_smb_host',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'backup',
|
||||
'description': 'SMB/CIFS server hostname or IP for the backup share'
|
||||
},
|
||||
{
|
||||
'key': 'backup_smb_share',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'backup',
|
||||
'description': 'SMB share name (the top-level export, no path)'
|
||||
},
|
||||
{
|
||||
'key': 'backup_smb_path',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'backup',
|
||||
'description': 'Subpath within the SMB share to write sets into'
|
||||
},
|
||||
{
|
||||
'key': 'backup_smb_username',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'backup',
|
||||
'description': 'SMB authentication username (DOMAIN\\user allowed)'
|
||||
},
|
||||
{
|
||||
'key': 'backup_smb_password',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'backup',
|
||||
'description': 'SMB authentication password'
|
||||
},
|
||||
{
|
||||
'key': 'backup_aws_region',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'backup',
|
||||
'description': 'AWS region for the S3 backup bucket (e.g. us-east-1)'
|
||||
},
|
||||
{
|
||||
'key': 'backup_aws_bucket',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'backup',
|
||||
'description': 'S3 bucket name that holds backup sets'
|
||||
},
|
||||
{
|
||||
'key': 'backup_aws_prefix',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'backup',
|
||||
'description': 'Optional key prefix within the S3 bucket'
|
||||
},
|
||||
{
|
||||
'key': 'backup_aws_accesskeyid',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'backup',
|
||||
'description': 'AWS access key id for the backup bucket'
|
||||
},
|
||||
{
|
||||
'key': 'backup_aws_secretaccesskey',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'backup',
|
||||
'description': 'AWS secret access key for the backup bucket'
|
||||
},
|
||||
# Audit log retention lives at 'audit_retentiondays' (category site),
|
||||
# defined above and read by shopdb/core/services/auditretention.py. A
|
||||
# second key 'audit_retention_days' was seeded here with a different
|
||||
|
||||
Reference in New Issue
Block a user